1mod 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
38pub 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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257pub enum CanicInternalWaitMode {
258 Bounded,
259 Unbounded,
260}
261
262pub 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;