Skip to main content

canic_core/api/ic/
call.rs

1//! Public IC call façade with optional intent-based concurrency control.
2//!
3//! This module defines the stable, public API used by application code to make
4//! inter-canister calls. It deliberately exposes a *thin* surface:
5//!
6//! - argument encoding
7//! - cycle attachment
8//! - optional intent declaration
9//!
10//! It does NOT:
11//! - perform orchestration itself
12//! - expose intent internals
13//! - leak workflow or storage details
14//!
15//! If an intent is attached to a call, the actual multi-step behavior
16//! (reserve → call → commit/abort) is handled by the workflow layer.
17//!
18//! This separation keeps application code simple while ensuring correctness
19//! under concurrency.
20use crate::{
21    cdk::{
22        candid::{CandidType, encode_one},
23        types::{BoundedString128, Principal},
24    },
25    config::schema::RoleAttestationConfig,
26    dto::{
27        auth::{
28            CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
29            SignedInternalInvocationProofV1,
30        },
31        error::Error,
32        error::ErrorCode,
33    },
34    ids::CanisterRole,
35    ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
36    workflow::ic::call::{
37        CallBuilder as WorkflowCallBuilder, CallResult as WorkflowCallResult, CallWorkflow,
38        IntentSpec as WorkflowIntentSpec,
39    },
40};
41use candid::{
42    encode_args,
43    utils::{ArgumentDecoder, ArgumentEncoder},
44};
45use serde::de::DeserializeOwned;
46use std::{borrow::Cow, cell::RefCell, collections::BTreeMap};
47
48const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
49const INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS: u64 = 30;
50
51thread_local! {
52    static INTERNAL_INVOCATION_PROOF_CACHE:
53        RefCell<BTreeMap<InternalInvocationProofCacheKey, SignedInternalInvocationProofV1>> =
54        const { RefCell::new(BTreeMap::new()) };
55}
56
57///
58/// Call
59///
60/// Entry point for constructing inter-canister calls.
61///
62/// `Call` itself has no state; it simply selects the wait semantics
63/// (bounded vs unbounded) and produces a `CallBuilder`.
64///
65/// Think of this as the *verb* (“make a call”), not the call itself.
66///
67
68pub struct Call;
69
70impl Call {
71    #[must_use]
72    pub fn bounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
73        CallBuilder {
74            inner: CallWorkflow::bounded_wait(canister_id, method),
75        }
76    }
77
78    #[must_use]
79    pub fn unbounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
80        CallBuilder {
81            inner: CallWorkflow::unbounded_wait(canister_id, method),
82        }
83    }
84}
85
86///
87/// CanicCall
88///
89/// Low-level protected Canic internal-call primitive.
90///
91/// Unlike `Call`, this API is only for Canic-to-Canic protected internal
92/// endpoints. It obtains a root-signed method-scoped invocation proof, wraps
93/// the original Candid arguments in `CanicInternalCallEnvelopeV1`, and dispatches
94/// through the raw call path.
95///
96
97pub struct CanicCall;
98
99impl CanicCall {
100    #[must_use]
101    pub fn bounded_wait(
102        canister_id: impl Into<Principal>,
103        method: &str,
104    ) -> CanicCallBuilder<'static> {
105        CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
106    }
107
108    #[must_use]
109    pub fn unbounded_wait(
110        canister_id: impl Into<Principal>,
111        method: &str,
112    ) -> CanicCallBuilder<'static> {
113        CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
114    }
115}
116
117///
118/// CanicCallBuilder
119///
120
121pub struct CanicCallBuilder<'a> {
122    wait: WaitMode,
123    canister_id: Principal,
124    method: String,
125    caller_role: Option<CanisterRole>,
126    ttl_secs: Option<u64>,
127    cycles: u128,
128    args: Cow<'a, [u8]>,
129}
130
131impl CanicCallBuilder<'_> {
132    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
133        Self {
134            wait,
135            canister_id,
136            method: method.to_string(),
137            caller_role: None,
138            ttl_secs: None,
139            cycles: 0,
140            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
141        }
142    }
143
144    #[must_use]
145    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
146        self.caller_role = Some(role);
147        self
148    }
149
150    #[must_use]
151    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
152        self.ttl_secs = Some(ttl_secs);
153        self
154    }
155
156    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
157    where
158        A: CandidType,
159    {
160        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
161        Ok(self)
162    }
163
164    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
165    where
166        A: ArgumentEncoder,
167    {
168        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
169        Ok(self)
170    }
171
172    #[must_use]
173    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
174        CanicCallBuilder {
175            wait: self.wait,
176            canister_id: self.canister_id,
177            method: self.method,
178            caller_role: self.caller_role,
179            ttl_secs: self.ttl_secs,
180            cycles: self.cycles,
181            args: args.into(),
182        }
183    }
184
185    #[must_use]
186    pub const fn with_cycles(mut self, cycles: u128) -> Self {
187        self.cycles = cycles;
188        self
189    }
190
191    pub async fn execute(self) -> Result<CallResult, Error> {
192        let ttl_secs = self.proof_ttl_secs()?;
193        let role = self
194            .caller_role
195            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
196        let request = InternalInvocationProofRequest {
197            subject: IcOps::canister_self(),
198            role,
199            subnet_id: EnvOps::subnet_pid().ok(),
200            audience: self.canister_id,
201            audience_method: self.method.clone(),
202            ttl_secs,
203            metadata: None,
204        };
205        let args = self.args.into_owned();
206        let proof = internal_invocation_proof_for_request(request.clone()).await?;
207
208        let envelope =
209            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
210        let result = execute_internal_call_once(
211            self.wait,
212            self.canister_id,
213            &self.method,
214            self.cycles,
215            envelope,
216        )
217        .await?;
218        if !internal_call_result_is_retryable(&result) {
219            return Ok(result);
220        }
221
222        invalidate_internal_invocation_proof(&request)?;
223        let proof = fresh_internal_invocation_proof_for_request(request).await?;
224        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
225        execute_internal_call_once(
226            self.wait,
227            self.canister_id,
228            &self.method,
229            self.cycles,
230            envelope,
231        )
232        .await
233    }
234
235    fn proof_ttl_secs(&self) -> Result<u64, Error> {
236        let requested = self
237            .ttl_secs
238            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
239        let max = ConfigOps::role_attestation_config()
240            .map_err(Error::from)?
241            .max_ttl_secs;
242        Ok(requested.min(max))
243    }
244}
245
246async fn execute_internal_call_once(
247    wait: WaitMode,
248    canister_id: Principal,
249    method: &str,
250    cycles: u128,
251    envelope: CanicInternalCallEnvelopeV1,
252) -> Result<CallResult, Error> {
253    let call = match wait {
254        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
255        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
256    }
257    .with_cycles(cycles)
258    .with_arg(envelope)?;
259
260    call.execute().await
261}
262
263///
264/// InternalInvocationProofCacheKey
265///
266
267#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
268struct InternalInvocationProofCacheKey {
269    root_pid: Principal,
270    attestation_key_name: String,
271    subject: Principal,
272    role: CanisterRole,
273    subnet_id: Option<Principal>,
274    audience: Principal,
275    audience_method: String,
276    ttl_secs: u64,
277}
278
279async fn internal_invocation_proof_for_request(
280    request: InternalInvocationProofRequest,
281) -> Result<SignedInternalInvocationProofV1, Error> {
282    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
283    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
284    let now_secs = IcOps::now_secs();
285
286    if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
287        return Ok(proof);
288    }
289
290    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
291}
292
293async fn fresh_internal_invocation_proof_for_request(
294    request: InternalInvocationProofRequest,
295) -> Result<SignedInternalInvocationProofV1, Error> {
296    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
297    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
298    let now_secs = IcOps::now_secs();
299    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
300}
301
302async fn fresh_internal_invocation_proof_for_request_with_context(
303    request: InternalInvocationProofRequest,
304    cfg: RoleAttestationConfig,
305    root_pid: Principal,
306    now_secs: u64,
307) -> Result<SignedInternalInvocationProofV1, Error> {
308    let proof =
309        crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
310    cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
311    Ok(proof)
312}
313
314fn internal_invocation_proof_cache_key(
315    request: &InternalInvocationProofRequest,
316    cfg: &RoleAttestationConfig,
317    root_pid: Principal,
318) -> InternalInvocationProofCacheKey {
319    InternalInvocationProofCacheKey {
320        root_pid,
321        attestation_key_name: cfg.ecdsa_key_name.clone(),
322        subject: request.subject,
323        role: request.role.clone(),
324        subnet_id: request.subnet_id,
325        audience: request.audience,
326        audience_method: request.audience_method.clone(),
327        ttl_secs: request.ttl_secs,
328    }
329}
330
331fn cached_internal_invocation_proof(
332    request: &InternalInvocationProofRequest,
333    cfg: &RoleAttestationConfig,
334    root_pid: Principal,
335    now_secs: u64,
336) -> Option<SignedInternalInvocationProofV1> {
337    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
338    let min_accepted_epoch = cfg
339        .min_accepted_epoch_by_role
340        .get(request.role.as_str())
341        .copied()
342        .unwrap_or(0);
343
344    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
345        let proof = cache.get(&key)?;
346        if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
347            Some(proof.clone())
348        } else {
349            cache.remove(&key);
350            None
351        }
352    })
353}
354
355fn cache_internal_invocation_proof(
356    request: &InternalInvocationProofRequest,
357    cfg: &RoleAttestationConfig,
358    root_pid: Principal,
359    now_secs: u64,
360    proof: SignedInternalInvocationProofV1,
361) {
362    let min_accepted_epoch = cfg
363        .min_accepted_epoch_by_role
364        .get(request.role.as_str())
365        .copied()
366        .unwrap_or(0);
367    if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
368        return;
369    }
370
371    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
372    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
373        cache.insert(key, proof);
374    });
375}
376
377fn invalidate_internal_invocation_proof(
378    request: &InternalInvocationProofRequest,
379) -> Result<(), Error> {
380    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
381    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
382    let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
383    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
384        cache.remove(&key);
385    });
386    Ok(())
387}
388
389fn internal_invocation_proof_is_reusable(
390    proof: &SignedInternalInvocationProofV1,
391    request: &InternalInvocationProofRequest,
392    now_secs: u64,
393    min_accepted_epoch: u64,
394) -> bool {
395    let payload = &proof.payload;
396    payload.subject == request.subject
397        && payload.role == request.role
398        && payload.subnet_id == request.subnet_id
399        && payload.audience == request.audience
400        && payload.audience_method == request.audience_method
401        && payload.epoch >= min_accepted_epoch
402        && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
403            < payload.expires_at
404}
405
406fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
407    proof
408        .payload
409        .expires_at
410        .saturating_sub(proof.payload.issued_at)
411        .saturating_div(5)
412        .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
413}
414
415fn internal_call_result_is_retryable(result: &CallResult) -> bool {
416    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
417        return false;
418    };
419    internal_call_error_is_retryable(&err)
420}
421
422const fn internal_call_error_is_retryable(err: &Error) -> bool {
423    matches!(
424        err.code,
425        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
426    )
427}
428
429#[derive(Clone, Copy, Debug, Eq, PartialEq)]
430enum WaitMode {
431    Bounded,
432    Unbounded,
433}
434
435fn build_internal_call_envelope(
436    target_canister: Principal,
437    target_method: &str,
438    proof: SignedInternalInvocationProofV1,
439    args: Vec<u8>,
440) -> CanicInternalCallEnvelopeV1 {
441    CanicInternalCallEnvelopeV1 {
442        version: 1,
443        header: CanicInternalCallHeaderV1 {
444            target_canister,
445            target_method: target_method.to_string(),
446        },
447        proof,
448        args,
449    }
450}
451
452///
453/// IntentKey
454///
455/// Stable, bounded identifier for a contended resource.
456///
457/// An intent key names *what is being reserved*, not how the reservation
458/// is enforced. Keys are opaque strings with a fixed maximum length
459/// to ensure safe storage and indexing.
460///
461/// Examples:
462/// - "vendor:abc123:inventory"
463/// - "collection:xyz:mint"
464///
465
466pub struct IntentKey(BoundedString128);
467
468impl IntentKey {
469    pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
470        BoundedString128::try_new(value)
471            .map(Self)
472            .map_err(Error::invalid)
473    }
474
475    #[must_use]
476    pub fn as_str(&self) -> &str {
477        self.0.as_str()
478    }
479
480    #[must_use]
481    pub fn into_inner(self) -> BoundedString128 {
482        self.0
483    }
484}
485
486impl AsRef<str> for IntentKey {
487    fn as_ref(&self) -> &str {
488        self.0.as_str()
489    }
490}
491
492impl From<IntentKey> for BoundedString128 {
493    fn from(key: IntentKey) -> Self {
494        key.0
495    }
496}
497
498///
499/// IntentReservation
500///
501/// Declarative reservation attached to a call.
502///
503/// An intent expresses *preconditions* for executing a call, such as:
504/// - how much of a resource is required (`quantity`)
505/// - how long the reservation may remain pending (`ttl_secs`)
506/// - optional concurrency caps (`max_in_flight`)
507///
508/// Importantly:
509/// - An intent is **single-shot**
510/// - Failed intents are not reused
511/// - Retrying requires creating a new intent
512///
513/// The reservation itself is enforced by the workflow layer.
514///
515
516pub struct IntentReservation {
517    key: IntentKey,
518    quantity: u64,
519    ttl_secs: Option<u64>,
520    max_in_flight: Option<u64>,
521}
522
523impl IntentReservation {
524    #[must_use]
525    pub const fn new(key: IntentKey, quantity: u64) -> Self {
526        Self {
527            key,
528            quantity,
529            ttl_secs: None,
530            max_in_flight: None,
531        }
532    }
533
534    #[must_use]
535    pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
536        self.ttl_secs = Some(ttl_secs);
537        self
538    }
539
540    #[must_use]
541    pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
542        self.max_in_flight = Some(max_in_flight);
543        self
544    }
545
546    pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
547        WorkflowIntentSpec::new(
548            self.key.into(),
549            self.quantity,
550            self.ttl_secs,
551            self.max_in_flight,
552        )
553    }
554}
555
556///
557/// CallBuilder (api)
558///
559
560pub struct CallBuilder<'a> {
561    inner: WorkflowCallBuilder<'a>,
562}
563
564impl CallBuilder<'_> {
565    // ---------- arguments ----------
566
567    /// Encode a single argument into Candid bytes (fallible).
568    pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
569    where
570        A: CandidType,
571    {
572        Ok(Self {
573            inner: self.inner.with_arg(arg).map_err(Error::from)?,
574        })
575    }
576
577    /// Encode multiple arguments into Candid bytes (fallible).
578    pub fn with_args<A>(self, args: A) -> Result<Self, Error>
579    where
580        A: ArgumentEncoder,
581    {
582        Ok(Self {
583            inner: self.inner.with_args(args).map_err(Error::from)?,
584        })
585    }
586
587    /// Use pre-encoded Candid arguments (no validation performed).
588    #[must_use]
589    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
590        CallBuilder {
591            inner: self.inner.with_raw_args(args),
592        }
593    }
594
595    // ---------- cycles ----------
596
597    #[must_use]
598    pub fn with_cycles(self, cycles: u128) -> Self {
599        Self {
600            inner: self.inner.with_cycles(cycles),
601        }
602    }
603
604    // ---------- intent ----------
605
606    #[must_use]
607    pub fn with_intent(self, intent: IntentReservation) -> Self {
608        Self {
609            inner: self.inner.with_intent(intent.into_spec()),
610        }
611    }
612
613    // ---------- execution ----------
614
615    pub async fn execute(self) -> Result<CallResult, Error> {
616        Ok(CallResult {
617            inner: self.inner.execute().await.map_err(Error::from)?,
618        })
619    }
620}
621
622///
623/// CallResult
624///
625/// Stable wrapper around an inter-canister call response.
626///
627/// This type exists to:
628/// - decouple API consumers from infra response types
629/// - provide uniform decoding helpers
630/// - allow future extension without breaking callers
631///
632
633pub struct CallResult {
634    inner: WorkflowCallResult,
635}
636
637impl CallResult {
638    pub fn candid<R>(&self) -> Result<R, Error>
639    where
640        R: CandidType + DeserializeOwned,
641    {
642        self.inner.candid().map_err(Error::from)
643    }
644
645    pub fn candid_tuple<R>(&self) -> Result<R, Error>
646    where
647        R: for<'de> ArgumentDecoder<'de>,
648    {
649        self.inner.candid_tuple().map_err(Error::from)
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::config::schema::RoleAttestationConfig;
657    use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
658    use candid::decode_args;
659    use std::collections::BTreeMap;
660
661    fn p(id: u8) -> Principal {
662        Principal::from_slice(&[id; 29])
663    }
664
665    fn proof() -> SignedInternalInvocationProofV1 {
666        SignedInternalInvocationProofV1 {
667            payload: InternalInvocationProofPayloadV1 {
668                subject: p(1),
669                role: CanisterRole::new("project_hub"),
670                subnet_id: None,
671                audience: p(2),
672                audience_method: "system_add_project_to_user".to_string(),
673                issued_at: 10,
674                expires_at: 20,
675                epoch: 3,
676            },
677            signature: vec![1, 2, 3],
678            key_id: 1,
679        }
680    }
681
682    fn request() -> InternalInvocationProofRequest {
683        InternalInvocationProofRequest {
684            subject: p(1),
685            role: CanisterRole::new("project_hub"),
686            subnet_id: Some(p(9)),
687            audience: p(2),
688            audience_method: "system_add_project_to_user".to_string(),
689            ttl_secs: 120,
690            metadata: None,
691        }
692    }
693
694    fn cfg(min_epoch: u64) -> RoleAttestationConfig {
695        let mut min_accepted_epoch_by_role = BTreeMap::new();
696        min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
697        RoleAttestationConfig {
698            ecdsa_key_name: "key_1".to_string(),
699            max_ttl_secs: 900,
700            min_accepted_epoch_by_role,
701        }
702    }
703
704    fn clear_internal_invocation_proof_cache() {
705        INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
706    }
707
708    #[test]
709    fn canic_call_envelope_binds_target_method_and_original_args() {
710        let args = encode_args((7_u64, "project")).expect("args encode");
711        let envelope =
712            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
713
714        assert_eq!(envelope.version, 1);
715        assert_eq!(envelope.header.target_canister, p(2));
716        assert_eq!(envelope.header.target_method, "system_add_project_to_user");
717        assert_eq!(
718            envelope.proof.payload.audience_method,
719            "system_add_project_to_user"
720        );
721
722        let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
723        assert_eq!(decoded, (7, "project".to_string()));
724    }
725
726    #[test]
727    fn canic_call_builder_records_role_and_raw_args() {
728        let raw = vec![9_u8, 8, 7];
729        let builder = CanicCall::unbounded_wait(p(3), "target")
730            .with_caller_role(CanisterRole::new("project_hub"))
731            .with_proof_ttl_secs(30)
732            .with_cycles(10)
733            .with_raw_args(raw.clone());
734
735        assert_eq!(builder.wait, WaitMode::Unbounded);
736        assert_eq!(builder.canister_id, p(3));
737        assert_eq!(builder.method, "target");
738        assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
739        assert_eq!(builder.ttl_secs, Some(30));
740        assert_eq!(builder.cycles, 10);
741        assert_eq!(builder.args.as_ref(), raw.as_slice());
742    }
743
744    #[test]
745    fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
746        clear_internal_invocation_proof_cache();
747        let request = request();
748        let mut proof = proof();
749        proof.payload.subnet_id = request.subnet_id;
750        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
751
752        let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
753            .expect("fresh matching proof should cache-hit");
754
755        assert_eq!(cached, proof);
756    }
757
758    #[test]
759    fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
760        clear_internal_invocation_proof_cache();
761        let request = request();
762        let mut proof = proof();
763        proof.payload.subnet_id = request.subnet_id;
764        proof.payload.issued_at = 10;
765        proof.payload.expires_at = 20;
766        cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
767
768        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
769    }
770
771    #[test]
772    fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
773        clear_internal_invocation_proof_cache();
774        let request = request();
775        let mut proof = proof();
776        proof.payload.subnet_id = request.subnet_id;
777        proof.payload.epoch = 3;
778        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
779
780        assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
781    }
782
783    #[test]
784    fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
785        assert!(internal_call_error_is_retryable(&Error::new(
786            ErrorCode::AuthKeyUnknown,
787            "unknown key".to_string(),
788        )));
789        assert!(internal_call_error_is_retryable(&Error::new(
790            ErrorCode::AuthMaterialStale,
791            "stale epoch".to_string(),
792        )));
793        assert!(!internal_call_error_is_retryable(&Error::new(
794            ErrorCode::AuthProofExpired,
795            "expired".to_string(),
796        )));
797        assert!(!internal_call_error_is_retryable(&Error::unauthorized(
798            "role mismatch"
799        )));
800    }
801}