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