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 '{}'",
132                endpoint.method()
133            )));
134        }
135
136        let builder = match self.options.wait {
137            CanicInternalWaitMode::Bounded => {
138                CanicCall::bounded_wait(self.canister_id, endpoint.method())
139            }
140            CanicInternalWaitMode::Unbounded => {
141                CanicCall::unbounded_wait(self.canister_id, endpoint.method())
142            }
143        };
144        let builder = builder
145            .with_caller_role(caller_role)
146            .with_cycles(self.options.cycles);
147        let builder = if let Some(ttl_secs) = self.options.proof_ttl_secs {
148            builder.with_proof_ttl_secs(ttl_secs)
149        } else {
150            builder
151        };
152
153        builder.with_args(args)?.execute().await
154    }
155
156    pub async fn call_update_with_single_role<A>(
157        &self,
158        endpoint: &ProtectedInternalEndpoint,
159        args: A,
160    ) -> Result<CallResult, Error>
161    where
162        A: ArgumentEncoder,
163    {
164        let role = endpoint.required_single_role()?;
165        self.call_update(endpoint, role, args).await
166    }
167
168    pub async fn call_update_result<T, A>(
169        &self,
170        endpoint: &ProtectedInternalEndpoint,
171        caller_role: CanisterRole,
172        args: A,
173    ) -> Result<T, Error>
174    where
175        T: CandidType + DeserializeOwned,
176        A: ArgumentEncoder,
177    {
178        let call = self.call_update(endpoint, caller_role, args).await?;
179        let result: Result<T, Error> = call.candid()?;
180        result
181    }
182
183    pub async fn call_update_result_with_single_role<T, A>(
184        &self,
185        endpoint: &ProtectedInternalEndpoint,
186        args: A,
187    ) -> Result<T, Error>
188    where
189        T: CandidType + DeserializeOwned,
190        A: ArgumentEncoder,
191    {
192        let role = endpoint.required_single_role()?;
193        self.call_update_result(endpoint, role, args).await
194    }
195}
196
197///
198/// CanicInternalCallOptions
199///
200/// Transport options shared by generated protected internal clients.
201///
202
203#[derive(Clone, Copy, Debug, Eq, PartialEq)]
204pub struct CanicInternalCallOptions {
205    wait: CanicInternalWaitMode,
206    cycles: u128,
207    proof_ttl_secs: Option<u64>,
208}
209
210impl CanicInternalCallOptions {
211    #[must_use]
212    pub const fn new() -> Self {
213        Self {
214            wait: CanicInternalWaitMode::Unbounded,
215            cycles: 0,
216            proof_ttl_secs: None,
217        }
218    }
219
220    #[must_use]
221    pub const fn with_bounded_wait(mut self) -> Self {
222        self.wait = CanicInternalWaitMode::Bounded;
223        self
224    }
225
226    #[must_use]
227    pub const fn with_unbounded_wait(mut self) -> Self {
228        self.wait = CanicInternalWaitMode::Unbounded;
229        self
230    }
231
232    #[must_use]
233    pub const fn with_cycles(mut self, cycles: u128) -> Self {
234        self.cycles = cycles;
235        self
236    }
237
238    #[must_use]
239    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
240        self.proof_ttl_secs = Some(ttl_secs);
241        self
242    }
243}
244
245impl Default for CanicInternalCallOptions {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251///
252/// CanicInternalWaitMode
253///
254
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum CanicInternalWaitMode {
257    Bounded,
258    Unbounded,
259}
260
261///
262/// CanicCallBuilder
263///
264
265pub struct CanicCallBuilder<'a> {
266    wait: WaitMode,
267    canister_id: Principal,
268    method: String,
269    caller_role: Option<CanisterRole>,
270    ttl_secs: Option<u64>,
271    cycles: u128,
272    args: Cow<'a, [u8]>,
273}
274
275impl CanicCallBuilder<'_> {
276    fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
277        Self {
278            wait,
279            canister_id,
280            method: method.to_string(),
281            caller_role: None,
282            ttl_secs: None,
283            cycles: 0,
284            args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
285        }
286    }
287
288    #[must_use]
289    pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
290        self.caller_role = Some(role);
291        self
292    }
293
294    #[must_use]
295    pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
296        self.ttl_secs = Some(ttl_secs);
297        self
298    }
299
300    pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
301    where
302        A: CandidType,
303    {
304        self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
305        Ok(self)
306    }
307
308    pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
309    where
310        A: ArgumentEncoder,
311    {
312        self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
313        Ok(self)
314    }
315
316    #[must_use]
317    pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
318        CanicCallBuilder {
319            wait: self.wait,
320            canister_id: self.canister_id,
321            method: self.method,
322            caller_role: self.caller_role,
323            ttl_secs: self.ttl_secs,
324            cycles: self.cycles,
325            args: args.into(),
326        }
327    }
328
329    #[must_use]
330    pub const fn with_cycles(mut self, cycles: u128) -> Self {
331        self.cycles = cycles;
332        self
333    }
334
335    pub async fn execute(self) -> Result<CallResult, Error> {
336        validate_internal_call_target_method(&self.method)?;
337        let ttl_secs = self.proof_ttl_secs()?;
338        let role = self
339            .caller_role
340            .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
341        validate_internal_call_caller_role(&role)?;
342        let request = InternalInvocationProofRequest {
343            subject: IcOps::canister_self(),
344            role,
345            subnet_id: EnvOps::subnet_pid().ok(),
346            audience: self.canister_id,
347            audience_method: self.method.clone(),
348            ttl_secs,
349            metadata: None,
350        };
351        let args = self.args.into_owned();
352        let proof = internal_invocation_proof_for_request(request.clone()).await?;
353
354        let envelope =
355            build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
356        let result = execute_internal_call_once(
357            self.wait,
358            self.canister_id,
359            &self.method,
360            self.cycles,
361            envelope,
362        )
363        .await?;
364        if !internal_call_result_is_retryable(&result) {
365            return Ok(result);
366        }
367
368        invalidate_internal_invocation_proof(&request)?;
369        let proof = fresh_internal_invocation_proof_for_request(request).await?;
370        let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
371        execute_internal_call_once(
372            self.wait,
373            self.canister_id,
374            &self.method,
375            self.cycles,
376            envelope,
377        )
378        .await
379    }
380
381    fn proof_ttl_secs(&self) -> Result<u64, Error> {
382        let requested = self
383            .ttl_secs
384            .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
385        let max = ConfigOps::role_attestation_config()
386            .map_err(Error::from)?
387            .max_ttl_secs;
388        effective_internal_call_proof_ttl_secs(requested, max)
389    }
390}
391
392fn validate_internal_call_target_method(method: &str) -> Result<(), Error> {
393    if method.trim().is_empty() {
394        return Err(Error::invalid(
395            "CanicCall requires a non-empty target method",
396        ));
397    }
398    Ok(())
399}
400
401fn validate_internal_call_caller_role(role: &CanisterRole) -> Result<(), Error> {
402    if role.as_str().trim().is_empty() {
403        return Err(Error::invalid("CanicCall requires a non-empty caller role"));
404    }
405    Ok(())
406}
407
408fn effective_internal_call_proof_ttl_secs(requested: u64, max: u64) -> Result<u64, Error> {
409    if requested == 0 {
410        return Err(Error::invalid(
411            "CanicCall proof TTL must be greater than zero",
412        ));
413    }
414    let effective = requested.min(max);
415    if effective == 0 {
416        return Err(Error::invalid(
417            "CanicCall proof TTL maximum must be greater than zero",
418        ));
419    }
420    Ok(effective)
421}
422
423async fn execute_internal_call_once(
424    wait: WaitMode,
425    canister_id: Principal,
426    method: &str,
427    cycles: u128,
428    envelope: CanicInternalCallEnvelopeV1,
429) -> Result<CallResult, Error> {
430    let call = match wait {
431        WaitMode::Bounded => Call::bounded_wait(canister_id, method),
432        WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
433    }
434    .with_cycles(cycles)
435    .with_raw_args(encode_internal_call_envelope_raw(envelope)?);
436
437    call.execute().await
438}
439
440fn internal_call_result_is_retryable(result: &CallResult) -> bool {
441    let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
442        return false;
443    };
444    internal_call_error_is_retryable(&err)
445}
446
447const fn internal_call_error_is_retryable(err: &Error) -> bool {
448    matches!(
449        err.code,
450        ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
451    )
452}
453
454#[derive(Clone, Copy, Debug, Eq, PartialEq)]
455enum WaitMode {
456    Bounded,
457    Unbounded,
458}
459
460#[cfg(test)]
461mod tests;