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