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/// ProtectedInternalEndpoint
119///
120/// Generated metadata for one protected Canic internal endpoint.
121///
122/// Endpoint macros emit this descriptor next to protected internal endpoints.
123/// Callers should pass it to `CanicInternalClient` instead of repeating method
124/// names and accepted-role metadata by hand.
125///
126
127#[derive(Clone, Debug)]
128pub struct ProtectedInternalEndpoint {
129    method: &'static str,
130    accepted_roles: Vec<CanisterRole>,
131}
132
133impl ProtectedInternalEndpoint {
134    #[must_use]
135    pub fn new(method: &'static str, roles: impl IntoIterator<Item = CanisterRole>) -> Self {
136        Self {
137            method,
138            accepted_roles: roles.into_iter().collect(),
139        }
140    }
141
142    #[must_use]
143    pub const fn method(&self) -> &'static str {
144        self.method
145    }
146
147    #[must_use]
148    pub fn accepted_roles(&self) -> &[CanisterRole] {
149        &self.accepted_roles
150    }
151
152    #[must_use]
153    pub fn accepts_role(&self, role: &CanisterRole) -> bool {
154        self.accepted_roles.iter().any(|accepted| accepted == role)
155    }
156
157    #[must_use]
158    pub fn single_role(&self) -> Option<&CanisterRole> {
159        match self.accepted_roles.as_slice() {
160            [role] => Some(role),
161            _ => None,
162        }
163    }
164
165    pub fn required_single_role(&self) -> Result<CanisterRole, Error> {
166        self.single_role().cloned().ok_or_else(|| {
167            Error::invalid(format!(
168                "protected internal endpoint '{}' accepts {} roles; choose a caller role explicitly",
169                self.method(),
170                self.accepted_roles.len()
171            ))
172        })
173    }
174}
175
176///
177/// CanicInternalClient
178///
179/// Generic protected internal client over generated endpoint descriptors.
180///
181
182#[derive(Clone, Copy, Debug)]
183pub struct CanicInternalClient {
184    canister_id: Principal,
185    options: CanicInternalCallOptions,
186}
187
188impl CanicInternalClient {
189    #[must_use]
190    pub const fn new(canister_id: Principal) -> Self {
191        Self {
192            canister_id,
193            options: CanicInternalCallOptions::new(),
194        }
195    }
196
197    #[must_use]
198    pub const fn with_options(mut self, options: CanicInternalCallOptions) -> Self {
199        self.options = options;
200        self
201    }
202
203    #[must_use]
204    pub const fn with_bounded_wait(mut self) -> Self {
205        self.options = self.options.with_bounded_wait();
206        self
207    }
208
209    #[must_use]
210    pub const fn with_unbounded_wait(mut self) -> Self {
211        self.options = self.options.with_unbounded_wait();
212        self
213    }
214
215    #[must_use]
216    pub const fn with_cycles(mut self, cycles: u128) -> Self {
217        self.options = self.options.with_cycles(cycles);
218        self
219    }
220
221    #[must_use]
222    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
223        self.options = self.options.with_proof_ttl_secs(ttl_secs);
224        self
225    }
226
227    pub async fn call_update<A>(
228        &self,
229        endpoint: &ProtectedInternalEndpoint,
230        caller_role: CanisterRole,
231        args: A,
232    ) -> Result<CallResult, Error>
233    where
234        A: ArgumentEncoder,
235    {
236        if !endpoint.accepts_role(&caller_role) {
237            return Err(Error::invalid(format!(
238                "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'",
239                endpoint.method()
240            )));
241        }
242
243        let builder = match self.options.wait {
244            CanicInternalWaitMode::Bounded => {
245                CanicCall::bounded_wait(self.canister_id, endpoint.method())
246            }
247            CanicInternalWaitMode::Unbounded => {
248                CanicCall::unbounded_wait(self.canister_id, endpoint.method())
249            }
250        };
251        let builder = builder
252            .with_caller_role(caller_role)
253            .with_cycles(self.options.cycles);
254        let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
255            builder.with_proof_ttl_secs(ttl_secs)
256        } else {
257            builder
258        };
259
260        builder.with_args(args)?.execute().await
261    }
262
263    pub async fn call_update_with_single_role<A>(
264        &self,
265        endpoint: &ProtectedInternalEndpoint,
266        args: A,
267    ) -> Result<CallResult, Error>
268    where
269        A: ArgumentEncoder,
270    {
271        let role = endpoint.required_single_role()?;
272        self.call_update(endpoint, role, args).await
273    }
274
275    pub async fn call_update_result<T, A>(
276        &self,
277        endpoint: &ProtectedInternalEndpoint,
278        caller_role: CanisterRole,
279        args: A,
280    ) -> Result<T, Error>
281    where
282        T: CandidType + DeserializeOwned,
283        A: ArgumentEncoder,
284    {
285        let call = self.call_update(endpoint, caller_role, args).await?;
286        let result: Result<T, Error> = call.candid()?;
287        result
288    }
289
290    pub async fn call_update_result_with_single_role<T, A>(
291        &self,
292        endpoint: &ProtectedInternalEndpoint,
293        args: A,
294    ) -> Result<T, Error>
295    where
296        T: CandidType + DeserializeOwned,
297        A: ArgumentEncoder,
298    {
299        let role = endpoint.required_single_role()?;
300        self.call_update_result(endpoint, role, args).await
301    }
302}
303
304///
305/// CanicInternalCallOptions
306///
307/// Transport options shared by generated protected internal clients.
308///
309
310#[derive(Clone, Copy, Debug, Eq, PartialEq)]
311pub struct CanicInternalCallOptions {
312    wait: CanicInternalWaitMode,
313    cycles: u128,
314    proof_ttl_secs: Option<u64>,
315}
316
317impl CanicInternalCallOptions {
318    #[must_use]
319    pub const fn new() -> Self {
320        Self {
321            wait: CanicInternalWaitMode::Unbounded,
322            cycles: 0,
323            proof_ttl_secs: None,
324        }
325    }
326
327    #[must_use]
328    pub const fn with_bounded_wait(mut self) -> Self {
329        self.wait = CanicInternalWaitMode::Bounded;
330        self
331    }
332
333    #[must_use]
334    pub const fn with_unbounded_wait(mut self) -> Self {
335        self.wait = CanicInternalWaitMode::Unbounded;
336        self
337    }
338
339    #[must_use]
340    pub const fn with_cycles(mut self, cycles: u128) -> Self {
341        self.cycles = cycles;
342        self
343    }
344
345    #[must_use]
346    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
347        self.proof_ttl_secs = Some(ttl_secs);
348        self
349    }
350}
351
352impl Default for CanicInternalCallOptions {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358///
359/// CanicInternalWaitMode
360///
361
362#[derive(Clone, Copy, Debug, Eq, PartialEq)]
363pub enum CanicInternalWaitMode {
364    Bounded,
365    Unbounded,
366}
367
368///
369/// CanicCallBuilder
370///
371
372pub struct CanicCallBuilder<'a> {
373    wait: WaitMode,
374    canister_id: Principal,
375    method: String,
376    caller_role: Option<CanisterRole>,
377    ttl_secs: Option<u64>,
378    cycles: u128,
379    args: Cow<'a, [u8]>,
380}
381
382impl CanicCallBuilder<'_> {
383    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
384        Self {
385            wait,
386            canister_id,
387            method: method.to_string(),
388            caller_role: None,
389            ttl_secs: None,
390            cycles: 0,
391            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
392        }
393    }
394
395    #[must_use]
396    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
397        self.caller_role = Some(role);
398        self
399    }
400
401    #[must_use]
402    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
403        self.ttl_secs = Some(ttl_secs);
404        self
405    }
406
407    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
408    where
409        A: CandidType,
410    {
411        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
412        Ok(self)
413    }
414
415    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
416    where
417        A: ArgumentEncoder,
418    {
419        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
420        Ok(self)
421    }
422
423    #[must_use]
424    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
425        CanicCallBuilder {
426            wait: self.wait,
427            canister_id: self.canister_id,
428            method: self.method,
429            caller_role: self.caller_role,
430            ttl_secs: self.ttl_secs,
431            cycles: self.cycles,
432            args: args.into(),
433        }
434    }
435
436    #[must_use]
437    pub const fn with_cycles(mut self, cycles: u128) -> Self {
438        self.cycles = cycles;
439        self
440    }
441
442    pub async fn execute(self) -> Result<CallResult, Error> {
443        let ttl_secs = self.proof_ttl_secs()?;
444        let role = self
445            .caller_role
446            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
447        let request = InternalInvocationProofRequest {
448            subject: IcOps::canister_self(),
449            role,
450            subnet_id: EnvOps::subnet_pid().ok(),
451            audience: self.canister_id,
452            audience_method: self.method.clone(),
453            ttl_secs,
454            metadata: None,
455        };
456        let args = self.args.into_owned();
457        let proof = internal_invocation_proof_for_request(request.clone()).await?;
458
459        let envelope =
460            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
461        let result = execute_internal_call_once(
462            self.wait,
463            self.canister_id,
464            &self.method,
465            self.cycles,
466            envelope,
467        )
468        .await?;
469        if !internal_call_result_is_retryable(&result) {
470            return Ok(result);
471        }
472
473        invalidate_internal_invocation_proof(&request)?;
474        let proof = fresh_internal_invocation_proof_for_request(request).await?;
475        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
476        execute_internal_call_once(
477            self.wait,
478            self.canister_id,
479            &self.method,
480            self.cycles,
481            envelope,
482        )
483        .await
484    }
485
486    fn proof_ttl_secs(&self) -> Result<u64, Error> {
487        let requested = self
488            .ttl_secs
489            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
490        let max = ConfigOps::role_attestation_config()
491            .map_err(Error::from)?
492            .max_ttl_secs;
493        Ok(requested.min(max))
494    }
495}
496
497async fn execute_internal_call_once(
498    wait: WaitMode,
499    canister_id: Principal,
500    method: &str,
501    cycles: u128,
502    envelope: CanicInternalCallEnvelopeV1,
503) -> Result<CallResult, Error> {
504    let call = match wait {
505        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
506        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
507    }
508    .with_cycles(cycles)
509    .with_arg(envelope)?;
510
511    call.execute().await
512}
513
514///
515/// InternalInvocationProofCacheKey
516///
517
518#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
519struct InternalInvocationProofCacheKey {
520    root_pid: Principal,
521    attestation_key_name: String,
522    subject: Principal,
523    role: CanisterRole,
524    subnet_id: Option<Principal>,
525    audience: Principal,
526    audience_method: String,
527    ttl_secs: u64,
528}
529
530async fn internal_invocation_proof_for_request(
531    request: InternalInvocationProofRequest,
532) -> Result<SignedInternalInvocationProofV1, Error> {
533    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
534    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
535    let now_secs = IcOps::now_secs();
536
537    if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
538        return Ok(proof);
539    }
540
541    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
542}
543
544async fn fresh_internal_invocation_proof_for_request(
545    request: InternalInvocationProofRequest,
546) -> Result<SignedInternalInvocationProofV1, Error> {
547    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
548    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
549    let now_secs = IcOps::now_secs();
550    fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
551}
552
553async fn fresh_internal_invocation_proof_for_request_with_context(
554    request: InternalInvocationProofRequest,
555    cfg: RoleAttestationConfig,
556    root_pid: Principal,
557    now_secs: u64,
558) -> Result<SignedInternalInvocationProofV1, Error> {
559    let proof =
560        crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
561    cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
562    Ok(proof)
563}
564
565fn internal_invocation_proof_cache_key(
566    request: &InternalInvocationProofRequest,
567    cfg: &RoleAttestationConfig,
568    root_pid: Principal,
569) -> InternalInvocationProofCacheKey {
570    InternalInvocationProofCacheKey {
571        root_pid,
572        attestation_key_name: cfg.ecdsa_key_name.clone(),
573        subject: request.subject,
574        role: request.role.clone(),
575        subnet_id: request.subnet_id,
576        audience: request.audience,
577        audience_method: request.audience_method.clone(),
578        ttl_secs: request.ttl_secs,
579    }
580}
581
582fn cached_internal_invocation_proof(
583    request: &InternalInvocationProofRequest,
584    cfg: &RoleAttestationConfig,
585    root_pid: Principal,
586    now_secs: u64,
587) -> Option<SignedInternalInvocationProofV1> {
588    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
589    let min_accepted_epoch = cfg
590        .min_accepted_epoch_by_role
591        .get(request.role.as_str())
592        .copied()
593        .unwrap_or(0);
594
595    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
596        let proof = cache.get(&key)?;
597        if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
598            Some(proof.clone())
599        } else {
600            cache.remove(&key);
601            None
602        }
603    })
604}
605
606fn cache_internal_invocation_proof(
607    request: &InternalInvocationProofRequest,
608    cfg: &RoleAttestationConfig,
609    root_pid: Principal,
610    now_secs: u64,
611    proof: SignedInternalInvocationProofV1,
612) {
613    let min_accepted_epoch = cfg
614        .min_accepted_epoch_by_role
615        .get(request.role.as_str())
616        .copied()
617        .unwrap_or(0);
618    if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
619        return;
620    }
621
622    let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
623    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
624        cache.insert(key, proof);
625    });
626}
627
628fn invalidate_internal_invocation_proof(
629    request: &InternalInvocationProofRequest,
630) -> Result<(), Error> {
631    let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
632    let root_pid = EnvOps::root_pid().map_err(Error::from)?;
633    let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
634    INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
635        cache.remove(&key);
636    });
637    Ok(())
638}
639
640fn internal_invocation_proof_is_reusable(
641    proof: &SignedInternalInvocationProofV1,
642    request: &InternalInvocationProofRequest,
643    now_secs: u64,
644    min_accepted_epoch: u64,
645) -> bool {
646    let payload = &proof.payload;
647    payload.subject == request.subject
648        && payload.role == request.role
649        && payload.subnet_id == request.subnet_id
650        && payload.audience == request.audience
651        && payload.audience_method == request.audience_method
652        && payload.epoch >= min_accepted_epoch
653        && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
654            < payload.expires_at
655}
656
657fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
658    proof
659        .payload
660        .expires_at
661        .saturating_sub(proof.payload.issued_at)
662        .saturating_div(5)
663        .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
664}
665
666fn internal_call_result_is_retryable(result: &CallResult) -> bool {
667    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
668        return false;
669    };
670    internal_call_error_is_retryable(&err)
671}
672
673const fn internal_call_error_is_retryable(err: &Error) -> bool {
674    matches!(
675        err.code,
676        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
677    )
678}
679
680#[derive(Clone, Copy, Debug, Eq, PartialEq)]
681enum WaitMode {
682    Bounded,
683    Unbounded,
684}
685
686fn build_internal_call_envelope(
687    target_canister: Principal,
688    target_method: &str,
689    proof: SignedInternalInvocationProofV1,
690    args: Vec<u8>,
691) -> CanicInternalCallEnvelopeV1 {
692    CanicInternalCallEnvelopeV1 {
693        version: 1,
694        header: CanicInternalCallHeaderV1 {
695            target_canister,
696            target_method: target_method.to_string(),
697        },
698        proof,
699        args,
700    }
701}
702
703///
704/// IntentKey
705///
706/// Stable, bounded identifier for a contended resource.
707///
708/// An intent key names *what is being reserved*, not how the reservation
709/// is enforced. Keys are opaque strings with a fixed maximum length
710/// to ensure safe storage and indexing.
711///
712/// Examples:
713/// - "vendor:abc123:inventory"
714/// - "collection:xyz:mint"
715///
716
717pub struct IntentKey(BoundedString128);
718
719impl IntentKey {
720    pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
721        BoundedString128::try_new(value)
722            .map(Self)
723            .map_err(Error::invalid)
724    }
725
726    #[must_use]
727    pub fn as_str(&self) -> &str {
728        self.0.as_str()
729    }
730
731    #[must_use]
732    pub fn into_inner(self) -> BoundedString128 {
733        self.0
734    }
735}
736
737impl AsRef<str> for IntentKey {
738    fn as_ref(&self) -> &str {
739        self.0.as_str()
740    }
741}
742
743impl From<IntentKey> for BoundedString128 {
744    fn from(key: IntentKey) -> Self {
745        key.0
746    }
747}
748
749///
750/// IntentReservation
751///
752/// Declarative reservation attached to a call.
753///
754/// An intent expresses *preconditions* for executing a call, such as:
755/// - how much of a resource is required (`quantity`)
756/// - how long the reservation may remain pending (`ttl_secs`)
757/// - optional concurrency caps (`max_in_flight`)
758///
759/// Importantly:
760/// - An intent is **single-shot**
761/// - Failed intents are not reused
762/// - Retrying requires creating a new intent
763///
764/// The reservation itself is enforced by the workflow layer.
765///
766
767pub struct IntentReservation {
768    key: IntentKey,
769    quantity: u64,
770    ttl_secs: Option<u64>,
771    max_in_flight: Option<u64>,
772}
773
774impl IntentReservation {
775    #[must_use]
776    pub const fn new(key: IntentKey, quantity: u64) -> Self {
777        Self {
778            key,
779            quantity,
780            ttl_secs: None,
781            max_in_flight: None,
782        }
783    }
784
785    #[must_use]
786    pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
787        self.ttl_secs = Some(ttl_secs);
788        self
789    }
790
791    #[must_use]
792    pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
793        self.max_in_flight = Some(max_in_flight);
794        self
795    }
796
797    pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
798        WorkflowIntentSpec::new(
799            self.key.into(),
800            self.quantity,
801            self.ttl_secs,
802            self.max_in_flight,
803        )
804    }
805}
806
807///
808/// CallBuilder (api)
809///
810
811pub struct CallBuilder<'a> {
812    inner: WorkflowCallBuilder<'a>,
813}
814
815impl CallBuilder<'_> {
816    // ---------- arguments ----------
817
818    /// Encode a single argument into Candid bytes (fallible).
819    pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
820    where
821        A: CandidType,
822    {
823        Ok(Self {
824            inner: self.inner.with_arg(arg).map_err(Error::from)?,
825        })
826    }
827
828    /// Encode multiple arguments into Candid bytes (fallible).
829    pub fn with_args<A>(self, args: A) -> Result<Self, Error>
830    where
831        A: ArgumentEncoder,
832    {
833        Ok(Self {
834            inner: self.inner.with_args(args).map_err(Error::from)?,
835        })
836    }
837
838    /// Use pre-encoded Candid arguments (no validation performed).
839    #[must_use]
840    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
841        CallBuilder {
842            inner: self.inner.with_raw_args(args),
843        }
844    }
845
846    // ---------- cycles ----------
847
848    #[must_use]
849    pub fn with_cycles(self, cycles: u128) -> Self {
850        Self {
851            inner: self.inner.with_cycles(cycles),
852        }
853    }
854
855    // ---------- intent ----------
856
857    #[must_use]
858    pub fn with_intent(self, intent: IntentReservation) -> Self {
859        Self {
860            inner: self.inner.with_intent(intent.into_spec()),
861        }
862    }
863
864    // ---------- execution ----------
865
866    pub async fn execute(self) -> Result<CallResult, Error> {
867        Ok(CallResult {
868            inner: self.inner.execute().await.map_err(Error::from)?,
869        })
870    }
871}
872
873///
874/// CallResult
875///
876/// Stable wrapper around an inter-canister call response.
877///
878/// This type exists to:
879/// - decouple API consumers from infra response types
880/// - provide uniform decoding helpers
881/// - allow future extension without breaking callers
882///
883
884pub struct CallResult {
885    inner: WorkflowCallResult,
886}
887
888impl CallResult {
889    pub fn candid<R>(&self) -> Result<R, Error>
890    where
891        R: CandidType + DeserializeOwned,
892    {
893        self.inner.candid().map_err(Error::from)
894    }
895
896    pub fn candid_tuple<R>(&self) -> Result<R, Error>
897    where
898        R: for<'de> ArgumentDecoder<'de>,
899    {
900        self.inner.candid_tuple().map_err(Error::from)
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use crate::config::schema::RoleAttestationConfig;
908    use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
909    use candid::decode_args;
910    use std::collections::BTreeMap;
911
912    fn p(id: u8) -> Principal {
913        Principal::from_slice(&[id; 29])
914    }
915
916    fn proof() -> SignedInternalInvocationProofV1 {
917        SignedInternalInvocationProofV1 {
918            payload: InternalInvocationProofPayloadV1 {
919                subject: p(1),
920                role: CanisterRole::new("project_hub"),
921                subnet_id: None,
922                audience: p(2),
923                audience_method: "system_add_project_to_user".to_string(),
924                issued_at: 10,
925                expires_at: 20,
926                epoch: 3,
927            },
928            signature: vec![1, 2, 3],
929            key_id: 1,
930        }
931    }
932
933    fn request() -> InternalInvocationProofRequest {
934        InternalInvocationProofRequest {
935            subject: p(1),
936            role: CanisterRole::new("project_hub"),
937            subnet_id: Some(p(9)),
938            audience: p(2),
939            audience_method: "system_add_project_to_user".to_string(),
940            ttl_secs: 120,
941            metadata: None,
942        }
943    }
944
945    fn cfg(min_epoch: u64) -> RoleAttestationConfig {
946        let mut min_accepted_epoch_by_role = BTreeMap::new();
947        min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
948        RoleAttestationConfig {
949            ecdsa_key_name: "key_1".to_string(),
950            max_ttl_secs: 900,
951            min_accepted_epoch_by_role,
952        }
953    }
954
955    fn clear_internal_invocation_proof_cache() {
956        INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
957    }
958
959    #[test]
960    fn canic_call_envelope_binds_target_method_and_original_args() {
961        let args = encode_args((7_u64, "project")).expect("args encode");
962        let envelope =
963            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
964
965        assert_eq!(envelope.version, 1);
966        assert_eq!(envelope.header.target_canister, p(2));
967        assert_eq!(envelope.header.target_method, "system_add_project_to_user");
968        assert_eq!(
969            envelope.proof.payload.audience_method,
970            "system_add_project_to_user"
971        );
972
973        let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
974        assert_eq!(decoded, (7, "project".to_string()));
975    }
976
977    #[test]
978    fn canic_call_builder_records_role_and_raw_args() {
979        let raw = vec![9_u8, 8, 7];
980        let builder = CanicCall::unbounded_wait(p(3), "target")
981            .with_caller_role(CanisterRole::new("project_hub"))
982            .with_proof_ttl_secs(30)
983            .with_cycles(10)
984            .with_raw_args(raw.clone());
985
986        assert_eq!(builder.wait, WaitMode::Unbounded);
987        assert_eq!(builder.canister_id, p(3));
988        assert_eq!(builder.method, "target");
989        assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
990        assert_eq!(builder.ttl_secs, Some(30));
991        assert_eq!(builder.cycles, 10);
992        assert_eq!(builder.args.as_ref(), raw.as_slice());
993    }
994
995    #[test]
996    fn protected_internal_endpoint_descriptor_matches_roles() {
997        let endpoint = ProtectedInternalEndpoint::new(
998            "system_add_project_to_user",
999            [
1000                CanisterRole::new("project_hub"),
1001                CanisterRole::new("admin_hub"),
1002            ],
1003        );
1004
1005        assert_eq!(endpoint.method(), "system_add_project_to_user");
1006        assert!(endpoint.accepts_role(&CanisterRole::new("project_hub")));
1007        assert!(endpoint.accepts_role(&CanisterRole::new("admin_hub")));
1008        assert!(!endpoint.accepts_role(&CanisterRole::new("user_hub")));
1009        assert!(endpoint.single_role().is_none());
1010    }
1011
1012    #[test]
1013    fn protected_internal_endpoint_single_role_is_available_to_generated_clients() {
1014        let endpoint = ProtectedInternalEndpoint::new(
1015            "system_add_project_to_user",
1016            [CanisterRole::new("project_hub")],
1017        );
1018
1019        assert_eq!(
1020            endpoint.single_role(),
1021            Some(&CanisterRole::new("project_hub"))
1022        );
1023        assert_eq!(
1024            endpoint.required_single_role().expect("single role"),
1025            CanisterRole::new("project_hub")
1026        );
1027    }
1028
1029    #[test]
1030    fn protected_internal_endpoint_requires_explicit_role_when_ambiguous() {
1031        let endpoint = ProtectedInternalEndpoint::new(
1032            "system_add_project_to_user",
1033            [
1034                CanisterRole::new("project_hub"),
1035                CanisterRole::new("admin_hub"),
1036            ],
1037        );
1038
1039        let err = endpoint
1040            .required_single_role()
1041            .expect_err("multi-role endpoint should require explicit caller role");
1042        assert_eq!(err.code, ErrorCode::InvalidInput);
1043    }
1044
1045    #[test]
1046    fn internal_client_options_are_chainable() {
1047        let client = CanicInternalClient::new(p(3))
1048            .with_bounded_wait()
1049            .with_cycles(10)
1050            .with_proof_ttl_secs(30);
1051
1052        assert_eq!(client.canister_id, p(3));
1053        assert_eq!(client.options.wait, CanicInternalWaitMode::Bounded);
1054        assert_eq!(client.options.cycles, 10);
1055        assert_eq!(client.options.proof_ttl_secs, Some(30));
1056    }
1057
1058    #[test]
1059    fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
1060        clear_internal_invocation_proof_cache();
1061        let request = request();
1062        let mut proof = proof();
1063        proof.payload.subnet_id = request.subnet_id;
1064        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
1065
1066        let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
1067            .expect("fresh matching proof should cache-hit");
1068
1069        assert_eq!(cached, proof);
1070    }
1071
1072    #[test]
1073    fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
1074        clear_internal_invocation_proof_cache();
1075        let request = request();
1076        let mut proof = proof();
1077        proof.payload.subnet_id = request.subnet_id;
1078        proof.payload.issued_at = 10;
1079        proof.payload.expires_at = 20;
1080        cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
1081
1082        assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
1083    }
1084
1085    #[test]
1086    fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
1087        clear_internal_invocation_proof_cache();
1088        let request = request();
1089        let mut proof = proof();
1090        proof.payload.subnet_id = request.subnet_id;
1091        proof.payload.epoch = 3;
1092        cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
1093
1094        assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
1095    }
1096
1097    #[test]
1098    fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
1099        assert!(internal_call_error_is_retryable(&Error::new(
1100            ErrorCode::AuthKeyUnknown,
1101            "unknown key".to_string(),
1102        )));
1103        assert!(internal_call_error_is_retryable(&Error::new(
1104            ErrorCode::AuthMaterialStale,
1105            "stale epoch".to_string(),
1106        )));
1107        assert!(!internal_call_error_is_retryable(&Error::new(
1108            ErrorCode::AuthProofExpired,
1109            "expired".to_string(),
1110        )));
1111        assert!(!internal_call_error_is_retryable(&Error::unauthorized(
1112            "role mismatch"
1113        )));
1114    }
1115}