Skip to main content

canic_core/api/ic/canic/
mod.rs

1//! Protected Canic-to-Canic internal call API.
2//!
3//! This module owns the native Canic internal RPC path: method-scoped root
4//! proofs, protected endpoint descriptors, generated-client transport options,
5//! envelope construction, proof caching, and the narrow auth-material repair
6//! retry.
7
8mod endpoint;
9mod envelope;
10mod proof_cache;
11
12pub use endpoint::ProtectedInternalEndpoint;
13
14use super::call::{Call, CallResult};
15use crate::{
16    cdk::{
17        candid::{CandidType, encode_one},
18        types::Principal,
19    },
20    dto::{
21        auth::{CanicInternalCallEnvelopeV1, InternalInvocationProofRequest},
22        error::{Error, ErrorCode},
23    },
24    ids::CanisterRole,
25    ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
26};
27use candid::{encode_args, utils::ArgumentEncoder};
28use envelope::{build_internal_call_envelope, encode_internal_call_envelope_raw};
29use proof_cache::{
30    fresh_internal_invocation_proof_for_request, internal_invocation_proof_for_request,
31    invalidate_internal_invocation_proof,
32};
33use serde::de::DeserializeOwned;
34use std::borrow::Cow;
35
36const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
37
38///
39/// CanicCall
40///
41/// Low-level protected Canic internal-call primitive.
42///
43/// Unlike `Call`, this API is only for Canic-to-Canic protected internal
44/// endpoints. It obtains a root-signed method-scoped invocation proof, wraps
45/// the original Candid arguments in `CanicInternalCallEnvelopeV1`, encodes that
46/// envelope as raw ingress bytes, and dispatches through the raw call path.
47///
48
49pub struct CanicCall;
50
51impl CanicCall {
52    #[must_use]
53    pub fn bounded_wait(
54        canister_id: impl Into<Principal>,
55        method: &str,
56    ) -> CanicCallBuilder<'static> {
57        CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
58    }
59
60    #[must_use]
61    pub fn unbounded_wait(
62        canister_id: impl Into<Principal>,
63        method: &str,
64    ) -> CanicCallBuilder<'static> {
65        CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
66    }
67}
68
69///
70/// CanicInternalClient
71///
72/// Generic protected internal client over generated endpoint descriptors.
73///
74
75#[derive(Clone, Copy, Debug)]
76pub struct CanicInternalClient {
77    canister_id: Principal,
78    options: CanicInternalCallOptions,
79}
80
81impl CanicInternalClient {
82    #[must_use]
83    pub const fn new(canister_id: Principal) -> Self {
84        Self {
85            canister_id,
86            options: CanicInternalCallOptions::new(),
87        }
88    }
89
90    #[must_use]
91    pub const fn with_options(mut self, options: CanicInternalCallOptions) -> Self {
92        self.options = options;
93        self
94    }
95
96    #[must_use]
97    pub const fn with_bounded_wait(mut self) -> Self {
98        self.options = self.options.with_bounded_wait();
99        self
100    }
101
102    #[must_use]
103    pub const fn with_unbounded_wait(mut self) -> Self {
104        self.options = self.options.with_unbounded_wait();
105        self
106    }
107
108    #[must_use]
109    pub const fn with_cycles(mut self, cycles: u128) -> Self {
110        self.options = self.options.with_cycles(cycles);
111        self
112    }
113
114    #[must_use]
115    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
116        self.options = self.options.with_proof_ttl_secs(ttl_secs);
117        self
118    }
119
120    pub async fn call_update<A>(
121        &self,
122        endpoint: &ProtectedInternalEndpoint,
123        caller_role: CanisterRole,
124        args: A,
125    ) -> Result<CallResult, Error>
126    where
127        A: ArgumentEncoder,
128    {
129        if !endpoint.accepts_role(&caller_role) {
130            return Err(Error::invalid(format!(
131                "caller role '{caller_role}' is not accepted by protected internal endpoint '{}'; accepted caller roles: [{}]. Use the generated endpoint descriptor with call_update(..., accepted_role, args).",
132                endpoint.method(),
133                endpoint.accepted_roles_label()
134            )));
135        }
136
137        let builder = match self.options.wait {
138            CanicInternalWaitMode::Bounded => {
139                CanicCall::bounded_wait(self.canister_id, endpoint.method())
140            }
141            CanicInternalWaitMode::Unbounded => {
142                CanicCall::unbounded_wait(self.canister_id, endpoint.method())
143            }
144        };
145        let builder = builder
146            .with_caller_role(caller_role)
147            .with_cycles(self.options.cycles);
148        let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
149            builder.with_proof_ttl_secs(ttl_secs)
150        } else {
151            builder
152        };
153
154        builder.with_args(args)?.execute().await
155    }
156
157    pub async fn call_update_with_single_role<A>(
158        &self,
159        endpoint: &ProtectedInternalEndpoint,
160        args: A,
161    ) -> Result<CallResult, Error>
162    where
163        A: ArgumentEncoder,
164    {
165        let role = endpoint.required_single_role()?;
166        self.call_update(endpoint, role, args).await
167    }
168
169    pub async fn call_update_result<T, A>(
170        &self,
171        endpoint: &ProtectedInternalEndpoint,
172        caller_role: CanisterRole,
173        args: A,
174    ) -> Result<T, Error>
175    where
176        T: CandidType + DeserializeOwned,
177        A: ArgumentEncoder,
178    {
179        let call = self.call_update(endpoint, caller_role, args).await?;
180        let result: Result<T, Error> = call.candid()?;
181        result
182    }
183
184    pub async fn call_update_result_with_single_role<T, A>(
185        &self,
186        endpoint: &ProtectedInternalEndpoint,
187        args: A,
188    ) -> Result<T, Error>
189    where
190        T: CandidType + DeserializeOwned,
191        A: ArgumentEncoder,
192    {
193        let role = endpoint.required_single_role()?;
194        self.call_update_result(endpoint, role, args).await
195    }
196}
197
198///
199/// CanicInternalCallOptions
200///
201/// Transport options shared by generated protected internal clients.
202///
203
204#[derive(Clone, Copy, Debug, Eq, PartialEq)]
205pub struct CanicInternalCallOptions {
206    wait: CanicInternalWaitMode,
207    cycles: u128,
208    proof_ttl_secs: Option<u64>,
209}
210
211impl CanicInternalCallOptions {
212    #[must_use]
213    pub const fn new() -> Self {
214        Self {
215            wait: CanicInternalWaitMode::Unbounded,
216            cycles: 0,
217            proof_ttl_secs: None,
218        }
219    }
220
221    #[must_use]
222    pub const fn with_bounded_wait(mut self) -> Self {
223        self.wait = CanicInternalWaitMode::Bounded;
224        self
225    }
226
227    #[must_use]
228    pub const fn with_unbounded_wait(mut self) -> Self {
229        self.wait = CanicInternalWaitMode::Unbounded;
230        self
231    }
232
233    #[must_use]
234    pub const fn with_cycles(mut self, cycles: u128) -> Self {
235        self.cycles = cycles;
236        self
237    }
238
239    #[must_use]
240    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
241        self.proof_ttl_secs = Some(ttl_secs);
242        self
243    }
244}
245
246impl Default for CanicInternalCallOptions {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252///
253/// CanicInternalWaitMode
254///
255
256#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257pub enum CanicInternalWaitMode {
258    Bounded,
259    Unbounded,
260}
261
262///
263/// CanicCallBuilder
264///
265
266pub struct CanicCallBuilder<'a> {
267    wait: WaitMode,
268    canister_id: Principal,
269    method: String,
270    caller_role: Option<CanisterRole>,
271    ttl_secs: Option<u64>,
272    cycles: u128,
273    args: Cow<'a, [u8]>,
274}
275
276impl CanicCallBuilder<'_> {
277    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
278        Self {
279            wait,
280            canister_id,
281            method: method.to_string(),
282            caller_role: None,
283            ttl_secs: None,
284            cycles: 0,
285            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
286        }
287    }
288
289    #[must_use]
290    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
291        self.caller_role = Some(role);
292        self
293    }
294
295    #[must_use]
296    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
297        self.ttl_secs = Some(ttl_secs);
298        self
299    }
300
301    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
302    where
303        A: CandidType,
304    {
305        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
306        Ok(self)
307    }
308
309    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
310    where
311        A: ArgumentEncoder,
312    {
313        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
314        Ok(self)
315    }
316
317    #[must_use]
318    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
319        CanicCallBuilder {
320            wait: self.wait,
321            canister_id: self.canister_id,
322            method: self.method,
323            caller_role: self.caller_role,
324            ttl_secs: self.ttl_secs,
325            cycles: self.cycles,
326            args: args.into(),
327        }
328    }
329
330    #[must_use]
331    pub const fn with_cycles(mut self, cycles: u128) -> Self {
332        self.cycles = cycles;
333        self
334    }
335
336    pub async fn execute(self) -> Result<CallResult, Error> {
337        validate_internal_call_target_method(&self.method)?;
338        let ttl_secs = self.proof_ttl_secs()?;
339        let role = self
340            .caller_role
341            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
342        validate_internal_call_caller_role(&role)?;
343        let request = InternalInvocationProofRequest {
344            subject: IcOps::canister_self(),
345            role,
346            subnet_id: EnvOps::subnet_pid().ok(),
347            audience: self.canister_id,
348            audience_method: self.method.clone(),
349            ttl_secs,
350            metadata: None,
351        };
352        let args = self.args.into_owned();
353        let proof = internal_invocation_proof_for_request(request.clone()).await?;
354
355        let envelope =
356            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
357        let result = execute_internal_call_once(
358            self.wait,
359            self.canister_id,
360            &self.method,
361            self.cycles,
362            envelope,
363        )
364        .await?;
365        if !internal_call_result_is_retryable(&result) {
366            return Ok(result);
367        }
368
369        invalidate_internal_invocation_proof(&request)?;
370        let proof = fresh_internal_invocation_proof_for_request(request).await?;
371        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
372        execute_internal_call_once(
373            self.wait,
374            self.canister_id,
375            &self.method,
376            self.cycles,
377            envelope,
378        )
379        .await
380    }
381
382    fn proof_ttl_secs(&self) -> Result<u64, Error> {
383        let requested = self
384            .ttl_secs
385            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
386        let max = ConfigOps::role_attestation_config()
387            .map_err(Error::from)?
388            .max_ttl_secs;
389        effective_internal_call_proof_ttl_secs(requested, max)
390    }
391}
392
393fn validate_internal_call_target_method(method: &str) -> Result<(), Error> {
394    if method.trim().is_empty() {
395        return Err(Error::invalid(
396            "CanicCall requires a non-empty target method",
397        ));
398    }
399    Ok(())
400}
401
402fn validate_internal_call_caller_role(role: &CanisterRole) -> Result<(), Error> {
403    if role.as_str().trim().is_empty() {
404        return Err(Error::invalid("CanicCall requires a non-empty caller role"));
405    }
406    Ok(())
407}
408
409fn effective_internal_call_proof_ttl_secs(requested: u64, max: u64) -> Result<u64, Error> {
410    if requested == 0 {
411        return Err(Error::invalid(
412            "CanicCall proof TTL must be greater than zero",
413        ));
414    }
415    let effective = requested.min(max);
416    if effective == 0 {
417        return Err(Error::invalid(
418            "CanicCall proof TTL maximum must be greater than zero",
419        ));
420    }
421    Ok(effective)
422}
423
424async fn execute_internal_call_once(
425    wait: WaitMode,
426    canister_id: Principal,
427    method: &str,
428    cycles: u128,
429    envelope: CanicInternalCallEnvelopeV1,
430) -> Result<CallResult, Error> {
431    let call = match wait {
432        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
433        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
434    }
435    .with_cycles(cycles)
436    .with_raw_args(encode_internal_call_envelope_raw(envelope)?);
437
438    call.execute().await
439}
440
441fn internal_call_result_is_retryable(result: &CallResult) -> bool {
442    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
443        return false;
444    };
445    internal_call_error_is_retryable(&err)
446}
447
448const fn internal_call_error_is_retryable(err: &Error) -> bool {
449    matches!(
450        err.code,
451        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
452    )
453}
454
455#[derive(Clone, Copy, Debug, Eq, PartialEq)]
456enum WaitMode {
457    Bounded,
458    Unbounded,
459}
460
461#[cfg(test)]
462mod tests;