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    dto::{
26        auth::{
27            CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
28            SignedInternalInvocationProofV1,
29        },
30        error::Error,
31    },
32    ids::CanisterRole,
33    ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
34    workflow::ic::call::{
35        CallBuilder as WorkflowCallBuilder, CallResult as WorkflowCallResult, CallWorkflow,
36        IntentSpec as WorkflowIntentSpec,
37    },
38};
39use candid::{
40    encode_args,
41    utils::{ArgumentDecoder, ArgumentEncoder},
42};
43use serde::de::DeserializeOwned;
44use std::borrow::Cow;
45
46const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
47
48///
49/// Call
50///
51/// Entry point for constructing inter-canister calls.
52///
53/// `Call` itself has no state; it simply selects the wait semantics
54/// (bounded vs unbounded) and produces a `CallBuilder`.
55///
56/// Think of this as the *verb* (“make a call”), not the call itself.
57///
58
59pub struct Call;
60
61impl Call {
62    #[must_use]
63    pub fn bounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
64        CallBuilder {
65            inner: CallWorkflow::bounded_wait(canister_id, method),
66        }
67    }
68
69    #[must_use]
70    pub fn unbounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
71        CallBuilder {
72            inner: CallWorkflow::unbounded_wait(canister_id, method),
73        }
74    }
75}
76
77///
78/// CanicCall
79///
80/// Low-level protected Canic internal-call primitive.
81///
82/// Unlike `Call`, this API is only for Canic-to-Canic protected internal
83/// endpoints. It obtains a root-signed method-scoped invocation proof, wraps
84/// the original Candid arguments in `CanicInternalCallEnvelopeV1`, and dispatches
85/// through the raw call path.
86///
87
88pub struct CanicCall;
89
90impl CanicCall {
91    #[must_use]
92    pub fn bounded_wait(
93        canister_id: impl Into<Principal>,
94        method: &str,
95    ) -> CanicCallBuilder<'static> {
96        CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
97    }
98
99    #[must_use]
100    pub fn unbounded_wait(
101        canister_id: impl Into<Principal>,
102        method: &str,
103    ) -> CanicCallBuilder<'static> {
104        CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
105    }
106}
107
108///
109/// CanicCallBuilder
110///
111
112pub struct CanicCallBuilder<'a> {
113    wait: WaitMode,
114    canister_id: Principal,
115    method: String,
116    caller_role: Option<CanisterRole>,
117    ttl_secs: Option<u64>,
118    cycles: u128,
119    args: Cow<'a, [u8]>,
120}
121
122impl CanicCallBuilder<'_> {
123    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
124        Self {
125            wait,
126            canister_id,
127            method: method.to_string(),
128            caller_role: None,
129            ttl_secs: None,
130            cycles: 0,
131            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
132        }
133    }
134
135    #[must_use]
136    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
137        self.caller_role = Some(role);
138        self
139    }
140
141    #[must_use]
142    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
143        self.ttl_secs = Some(ttl_secs);
144        self
145    }
146
147    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
148    where
149        A: CandidType,
150    {
151        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
152        Ok(self)
153    }
154
155    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
156    where
157        A: ArgumentEncoder,
158    {
159        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
160        Ok(self)
161    }
162
163    #[must_use]
164    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
165        CanicCallBuilder {
166            wait: self.wait,
167            canister_id: self.canister_id,
168            method: self.method,
169            caller_role: self.caller_role,
170            ttl_secs: self.ttl_secs,
171            cycles: self.cycles,
172            args: args.into(),
173        }
174    }
175
176    #[must_use]
177    pub const fn with_cycles(mut self, cycles: u128) -> Self {
178        self.cycles = cycles;
179        self
180    }
181
182    pub async fn execute(self) -> Result<CallResult, Error> {
183        let ttl_secs = self.proof_ttl_secs()?;
184        let role = self
185            .caller_role
186            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
187        let proof = crate::api::auth::AuthApi::request_internal_invocation_proof(
188            InternalInvocationProofRequest {
189                subject: IcOps::canister_self(),
190                role,
191                subnet_id: EnvOps::subnet_pid().ok(),
192                audience: self.canister_id,
193                audience_method: self.method.clone(),
194                ttl_secs,
195                metadata: None,
196            },
197        )
198        .await?;
199
200        let envelope = build_internal_call_envelope(
201            self.canister_id,
202            &self.method,
203            proof,
204            self.args.into_owned(),
205        );
206        let call = match self.wait {
207            WaitMode::Bounded => Call::bounded_wait(self.canister_id, &self.method),
208            WaitMode::Unbounded => Call::unbounded_wait(self.canister_id, &self.method),
209        }
210        .with_cycles(self.cycles)
211        .with_arg(envelope)?;
212
213        call.execute().await
214    }
215
216    fn proof_ttl_secs(&self) -> Result<u64, Error> {
217        let requested = self
218            .ttl_secs
219            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
220        let max = ConfigOps::role_attestation_config()
221            .map_err(Error::from)?
222            .max_ttl_secs;
223        Ok(requested.min(max))
224    }
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
228enum WaitMode {
229    Bounded,
230    Unbounded,
231}
232
233fn build_internal_call_envelope(
234    target_canister: Principal,
235    target_method: &str,
236    proof: SignedInternalInvocationProofV1,
237    args: Vec<u8>,
238) -> CanicInternalCallEnvelopeV1 {
239    CanicInternalCallEnvelopeV1 {
240        version: 1,
241        header: CanicInternalCallHeaderV1 {
242            target_canister,
243            target_method: target_method.to_string(),
244        },
245        proof,
246        args,
247    }
248}
249
250///
251/// IntentKey
252///
253/// Stable, bounded identifier for a contended resource.
254///
255/// An intent key names *what is being reserved*, not how the reservation
256/// is enforced. Keys are opaque strings with a fixed maximum length
257/// to ensure safe storage and indexing.
258///
259/// Examples:
260/// - "vendor:abc123:inventory"
261/// - "collection:xyz:mint"
262///
263
264pub struct IntentKey(BoundedString128);
265
266impl IntentKey {
267    pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
268        BoundedString128::try_new(value)
269            .map(Self)
270            .map_err(Error::invalid)
271    }
272
273    #[must_use]
274    pub fn as_str(&self) -> &str {
275        self.0.as_str()
276    }
277
278    #[must_use]
279    pub fn into_inner(self) -> BoundedString128 {
280        self.0
281    }
282}
283
284impl AsRef<str> for IntentKey {
285    fn as_ref(&self) -> &str {
286        self.0.as_str()
287    }
288}
289
290impl From<IntentKey> for BoundedString128 {
291    fn from(key: IntentKey) -> Self {
292        key.0
293    }
294}
295
296///
297/// IntentReservation
298///
299/// Declarative reservation attached to a call.
300///
301/// An intent expresses *preconditions* for executing a call, such as:
302/// - how much of a resource is required (`quantity`)
303/// - how long the reservation may remain pending (`ttl_secs`)
304/// - optional concurrency caps (`max_in_flight`)
305///
306/// Importantly:
307/// - An intent is **single-shot**
308/// - Failed intents are not reused
309/// - Retrying requires creating a new intent
310///
311/// The reservation itself is enforced by the workflow layer.
312///
313
314pub struct IntentReservation {
315    key: IntentKey,
316    quantity: u64,
317    ttl_secs: Option<u64>,
318    max_in_flight: Option<u64>,
319}
320
321impl IntentReservation {
322    #[must_use]
323    pub const fn new(key: IntentKey, quantity: u64) -> Self {
324        Self {
325            key,
326            quantity,
327            ttl_secs: None,
328            max_in_flight: None,
329        }
330    }
331
332    #[must_use]
333    pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
334        self.ttl_secs = Some(ttl_secs);
335        self
336    }
337
338    #[must_use]
339    pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
340        self.max_in_flight = Some(max_in_flight);
341        self
342    }
343
344    pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
345        WorkflowIntentSpec::new(
346            self.key.into(),
347            self.quantity,
348            self.ttl_secs,
349            self.max_in_flight,
350        )
351    }
352}
353
354///
355/// CallBuilder (api)
356///
357
358pub struct CallBuilder<'a> {
359    inner: WorkflowCallBuilder<'a>,
360}
361
362impl CallBuilder<'_> {
363    // ---------- arguments ----------
364
365    /// Encode a single argument into Candid bytes (fallible).
366    pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
367    where
368        A: CandidType,
369    {
370        Ok(Self {
371            inner: self.inner.with_arg(arg).map_err(Error::from)?,
372        })
373    }
374
375    /// Encode multiple arguments into Candid bytes (fallible).
376    pub fn with_args<A>(self, args: A) -> Result<Self, Error>
377    where
378        A: ArgumentEncoder,
379    {
380        Ok(Self {
381            inner: self.inner.with_args(args).map_err(Error::from)?,
382        })
383    }
384
385    /// Use pre-encoded Candid arguments (no validation performed).
386    #[must_use]
387    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
388        CallBuilder {
389            inner: self.inner.with_raw_args(args),
390        }
391    }
392
393    // ---------- cycles ----------
394
395    #[must_use]
396    pub fn with_cycles(self, cycles: u128) -> Self {
397        Self {
398            inner: self.inner.with_cycles(cycles),
399        }
400    }
401
402    // ---------- intent ----------
403
404    #[must_use]
405    pub fn with_intent(self, intent: IntentReservation) -> Self {
406        Self {
407            inner: self.inner.with_intent(intent.into_spec()),
408        }
409    }
410
411    // ---------- execution ----------
412
413    pub async fn execute(self) -> Result<CallResult, Error> {
414        Ok(CallResult {
415            inner: self.inner.execute().await.map_err(Error::from)?,
416        })
417    }
418}
419
420///
421/// CallResult
422///
423/// Stable wrapper around an inter-canister call response.
424///
425/// This type exists to:
426/// - decouple API consumers from infra response types
427/// - provide uniform decoding helpers
428/// - allow future extension without breaking callers
429///
430
431pub struct CallResult {
432    inner: WorkflowCallResult,
433}
434
435impl CallResult {
436    pub fn candid<R>(&self) -> Result<R, Error>
437    where
438        R: CandidType + DeserializeOwned,
439    {
440        self.inner.candid().map_err(Error::from)
441    }
442
443    pub fn candid_tuple<R>(&self) -> Result<R, Error>
444    where
445        R: for<'de> ArgumentDecoder<'de>,
446    {
447        self.inner.candid_tuple().map_err(Error::from)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
455    use candid::decode_args;
456
457    fn p(id: u8) -> Principal {
458        Principal::from_slice(&[id; 29])
459    }
460
461    fn proof() -> SignedInternalInvocationProofV1 {
462        SignedInternalInvocationProofV1 {
463            payload: InternalInvocationProofPayloadV1 {
464                subject: p(1),
465                role: CanisterRole::new("project_hub"),
466                subnet_id: None,
467                audience: p(2),
468                audience_method: "system_add_project_to_user".to_string(),
469                issued_at: 10,
470                expires_at: 20,
471                epoch: 3,
472            },
473            signature: vec![1, 2, 3],
474            key_id: 1,
475        }
476    }
477
478    #[test]
479    fn canic_call_envelope_binds_target_method_and_original_args() {
480        let args = encode_args((7_u64, "project")).expect("args encode");
481        let envelope =
482            build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
483
484        assert_eq!(envelope.version, 1);
485        assert_eq!(envelope.header.target_canister, p(2));
486        assert_eq!(envelope.header.target_method, "system_add_project_to_user");
487        assert_eq!(
488            envelope.proof.payload.audience_method,
489            "system_add_project_to_user"
490        );
491
492        let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
493        assert_eq!(decoded, (7, "project".to_string()));
494    }
495
496    #[test]
497    fn canic_call_builder_records_role_and_raw_args() {
498        let raw = vec![9_u8, 8, 7];
499        let builder = CanicCall::unbounded_wait(p(3), "target")
500            .with_caller_role(CanisterRole::new("project_hub"))
501            .with_proof_ttl_secs(30)
502            .with_cycles(10)
503            .with_raw_args(raw.clone());
504
505        assert_eq!(builder.wait, WaitMode::Unbounded);
506        assert_eq!(builder.canister_id, p(3));
507        assert_eq!(builder.method, "target");
508        assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
509        assert_eq!(builder.ttl_secs, Some(30));
510        assert_eq!(builder.cycles, 10);
511        assert_eq!(builder.args.as_ref(), raw.as_slice());
512    }
513}