1use crate::{
21 cdk::{
22 candid::{CandidType, encode_one},
23 types::{BoundedString128, Principal},
24 },
25 config::schema::RoleAttestationConfig,
26 dto::{
27 auth::{
28 CanicInternalCallEnvelopeV1, CanicInternalCallHeaderV1, InternalInvocationProofRequest,
29 SignedInternalInvocationProofV1,
30 },
31 error::Error,
32 error::ErrorCode,
33 },
34 ids::CanisterRole,
35 ops::{config::ConfigOps, ic::IcOps, runtime::env::EnvOps},
36 workflow::ic::call::{
37 CallBuilder as WorkflowCallBuilder, CallResult as WorkflowCallResult, CallWorkflow,
38 IntentSpec as WorkflowIntentSpec,
39 },
40};
41use candid::{
42 encode_args,
43 utils::{ArgumentDecoder, ArgumentEncoder},
44};
45use serde::de::DeserializeOwned;
46use std::{borrow::Cow, cell::RefCell, collections::BTreeMap};
47
48const DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS: u64 = 120;
49const INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS: u64 = 30;
50
51thread_local! {
52 static INTERNAL_INVOCATION_PROOF_CACHE:
53 RefCell<BTreeMap<InternalInvocationProofCacheKey, SignedInternalInvocationProofV1>> =
54 const { RefCell::new(BTreeMap::new()) };
55}
56
57pub struct Call;
69
70impl Call {
71 #[must_use]
72 pub fn bounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
73 CallBuilder {
74 inner: CallWorkflow::bounded_wait(canister_id, method),
75 }
76 }
77
78 #[must_use]
79 pub fn unbounded_wait(canister_id: impl Into<Principal>, method: &str) -> CallBuilder<'static> {
80 CallBuilder {
81 inner: CallWorkflow::unbounded_wait(canister_id, method),
82 }
83 }
84}
85
86pub struct CanicCall;
98
99impl CanicCall {
100 #[must_use]
101 pub fn bounded_wait(
102 canister_id: impl Into<Principal>,
103 method: &str,
104 ) -> CanicCallBuilder<'static> {
105 CanicCallBuilder::new(WaitMode::Bounded, canister_id.into(), method)
106 }
107
108 #[must_use]
109 pub fn unbounded_wait(
110 canister_id: impl Into<Principal>,
111 method: &str,
112 ) -> CanicCallBuilder<'static> {
113 CanicCallBuilder::new(WaitMode::Unbounded, canister_id.into(), method)
114 }
115}
116
117pub struct CanicCallBuilder<'a> {
122 wait: WaitMode,
123 canister_id: Principal,
124 method: String,
125 caller_role: Option<CanisterRole>,
126 ttl_secs: Option<u64>,
127 cycles: u128,
128 args: Cow<'a, [u8]>,
129}
130
131impl CanicCallBuilder<'_> {
132 fn new(wait: WaitMode, canister_id: Principal, method: &str) -> Self {
133 Self {
134 wait,
135 canister_id,
136 method: method.to_string(),
137 caller_role: None,
138 ttl_secs: None,
139 cycles: 0,
140 args: Cow::Owned(encode_args(()).expect("empty Candid tuple must encode")),
141 }
142 }
143
144 #[must_use]
145 pub fn with_caller_role(mut self, role: CanisterRole) -> Self {
146 self.caller_role = Some(role);
147 self
148 }
149
150 #[must_use]
151 pub const fn with_proof_ttl_secs(mut self, ttl_secs: u64) -> Self {
152 self.ttl_secs = Some(ttl_secs);
153 self
154 }
155
156 pub fn with_arg<A>(mut self, arg: A) -> Result<Self, Error>
157 where
158 A: CandidType,
159 {
160 self.args = Cow::Owned(encode_one(arg).map_err(|err| Error::invalid(err.to_string()))?);
161 Ok(self)
162 }
163
164 pub fn with_args<A>(mut self, args: A) -> Result<Self, Error>
165 where
166 A: ArgumentEncoder,
167 {
168 self.args = Cow::Owned(encode_args(args).map_err(|err| Error::invalid(err.to_string()))?);
169 Ok(self)
170 }
171
172 #[must_use]
173 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CanicCallBuilder<'b> {
174 CanicCallBuilder {
175 wait: self.wait,
176 canister_id: self.canister_id,
177 method: self.method,
178 caller_role: self.caller_role,
179 ttl_secs: self.ttl_secs,
180 cycles: self.cycles,
181 args: args.into(),
182 }
183 }
184
185 #[must_use]
186 pub const fn with_cycles(mut self, cycles: u128) -> Self {
187 self.cycles = cycles;
188 self
189 }
190
191 pub async fn execute(self) -> Result<CallResult, Error> {
192 let ttl_secs = self.proof_ttl_secs()?;
193 let role = self
194 .caller_role
195 .ok_or_else(|| Error::invalid("CanicCall requires with_caller_role(...)"))?;
196 let request = InternalInvocationProofRequest {
197 subject: IcOps::canister_self(),
198 role,
199 subnet_id: EnvOps::subnet_pid().ok(),
200 audience: self.canister_id,
201 audience_method: self.method.clone(),
202 ttl_secs,
203 metadata: None,
204 };
205 let args = self.args.into_owned();
206 let proof = internal_invocation_proof_for_request(request.clone()).await?;
207
208 let envelope =
209 build_internal_call_envelope(self.canister_id, &self.method, proof, args.clone());
210 let result = execute_internal_call_once(
211 self.wait,
212 self.canister_id,
213 &self.method,
214 self.cycles,
215 envelope,
216 )
217 .await?;
218 if !internal_call_result_is_retryable(&result) {
219 return Ok(result);
220 }
221
222 invalidate_internal_invocation_proof(&request)?;
223 let proof = fresh_internal_invocation_proof_for_request(request).await?;
224 let envelope = build_internal_call_envelope(self.canister_id, &self.method, proof, args);
225 execute_internal_call_once(
226 self.wait,
227 self.canister_id,
228 &self.method,
229 self.cycles,
230 envelope,
231 )
232 .await
233 }
234
235 fn proof_ttl_secs(&self) -> Result<u64, Error> {
236 let requested = self
237 .ttl_secs
238 .unwrap_or(DEFAULT_INTERNAL_CALL_PROOF_TTL_SECS);
239 let max = ConfigOps::role_attestation_config()
240 .map_err(Error::from)?
241 .max_ttl_secs;
242 Ok(requested.min(max))
243 }
244}
245
246async fn execute_internal_call_once(
247 wait: WaitMode,
248 canister_id: Principal,
249 method: &str,
250 cycles: u128,
251 envelope: CanicInternalCallEnvelopeV1,
252) -> Result<CallResult, Error> {
253 let call = match wait {
254 WaitMode::Bounded => Call::bounded_wait(canister_id, method),
255 WaitMode::Unbounded => Call::unbounded_wait(canister_id, method),
256 }
257 .with_cycles(cycles)
258 .with_arg(envelope)?;
259
260 call.execute().await
261}
262
263#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
268struct InternalInvocationProofCacheKey {
269 root_pid: Principal,
270 attestation_key_name: String,
271 subject: Principal,
272 role: CanisterRole,
273 subnet_id: Option<Principal>,
274 audience: Principal,
275 audience_method: String,
276 ttl_secs: u64,
277}
278
279async fn internal_invocation_proof_for_request(
280 request: InternalInvocationProofRequest,
281) -> Result<SignedInternalInvocationProofV1, Error> {
282 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
283 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
284 let now_secs = IcOps::now_secs();
285
286 if let Some(proof) = cached_internal_invocation_proof(&request, &cfg, root_pid, now_secs) {
287 return Ok(proof);
288 }
289
290 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
291}
292
293async fn fresh_internal_invocation_proof_for_request(
294 request: InternalInvocationProofRequest,
295) -> Result<SignedInternalInvocationProofV1, Error> {
296 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
297 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
298 let now_secs = IcOps::now_secs();
299 fresh_internal_invocation_proof_for_request_with_context(request, cfg, root_pid, now_secs).await
300}
301
302async fn fresh_internal_invocation_proof_for_request_with_context(
303 request: InternalInvocationProofRequest,
304 cfg: RoleAttestationConfig,
305 root_pid: Principal,
306 now_secs: u64,
307) -> Result<SignedInternalInvocationProofV1, Error> {
308 let proof =
309 crate::api::auth::AuthApi::request_internal_invocation_proof(request.clone()).await?;
310 cache_internal_invocation_proof(&request, &cfg, root_pid, now_secs, proof.clone());
311 Ok(proof)
312}
313
314fn internal_invocation_proof_cache_key(
315 request: &InternalInvocationProofRequest,
316 cfg: &RoleAttestationConfig,
317 root_pid: Principal,
318) -> InternalInvocationProofCacheKey {
319 InternalInvocationProofCacheKey {
320 root_pid,
321 attestation_key_name: cfg.ecdsa_key_name.clone(),
322 subject: request.subject,
323 role: request.role.clone(),
324 subnet_id: request.subnet_id,
325 audience: request.audience,
326 audience_method: request.audience_method.clone(),
327 ttl_secs: request.ttl_secs,
328 }
329}
330
331fn cached_internal_invocation_proof(
332 request: &InternalInvocationProofRequest,
333 cfg: &RoleAttestationConfig,
334 root_pid: Principal,
335 now_secs: u64,
336) -> Option<SignedInternalInvocationProofV1> {
337 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
338 let min_accepted_epoch = cfg
339 .min_accepted_epoch_by_role
340 .get(request.role.as_str())
341 .copied()
342 .unwrap_or(0);
343
344 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
345 let proof = cache.get(&key)?;
346 if internal_invocation_proof_is_reusable(proof, request, now_secs, min_accepted_epoch) {
347 Some(proof.clone())
348 } else {
349 cache.remove(&key);
350 None
351 }
352 })
353}
354
355fn cache_internal_invocation_proof(
356 request: &InternalInvocationProofRequest,
357 cfg: &RoleAttestationConfig,
358 root_pid: Principal,
359 now_secs: u64,
360 proof: SignedInternalInvocationProofV1,
361) {
362 let min_accepted_epoch = cfg
363 .min_accepted_epoch_by_role
364 .get(request.role.as_str())
365 .copied()
366 .unwrap_or(0);
367 if !internal_invocation_proof_is_reusable(&proof, request, now_secs, min_accepted_epoch) {
368 return;
369 }
370
371 let key = internal_invocation_proof_cache_key(request, cfg, root_pid);
372 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
373 cache.insert(key, proof);
374 });
375}
376
377fn invalidate_internal_invocation_proof(
378 request: &InternalInvocationProofRequest,
379) -> Result<(), Error> {
380 let cfg = ConfigOps::role_attestation_config().map_err(Error::from)?;
381 let root_pid = EnvOps::root_pid().map_err(Error::from)?;
382 let key = internal_invocation_proof_cache_key(request, &cfg, root_pid);
383 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(|cache| {
384 cache.remove(&key);
385 });
386 Ok(())
387}
388
389fn internal_invocation_proof_is_reusable(
390 proof: &SignedInternalInvocationProofV1,
391 request: &InternalInvocationProofRequest,
392 now_secs: u64,
393 min_accepted_epoch: u64,
394) -> bool {
395 let payload = &proof.payload;
396 payload.subject == request.subject
397 && payload.role == request.role
398 && payload.subnet_id == request.subnet_id
399 && payload.audience == request.audience
400 && payload.audience_method == request.audience_method
401 && payload.epoch >= min_accepted_epoch
402 && now_secs.saturating_add(internal_invocation_proof_refresh_margin_secs(proof))
403 < payload.expires_at
404}
405
406fn internal_invocation_proof_refresh_margin_secs(proof: &SignedInternalInvocationProofV1) -> u64 {
407 proof
408 .payload
409 .expires_at
410 .saturating_sub(proof.payload.issued_at)
411 .saturating_div(5)
412 .clamp(1, INTERNAL_CALL_PROOF_REFRESH_MARGIN_MAX_SECS)
413}
414
415fn internal_call_result_is_retryable(result: &CallResult) -> bool {
416 let Ok(Err(err)) = result.candid::<Result<candid::Reserved, Error>>() else {
417 return false;
418 };
419 internal_call_error_is_retryable(&err)
420}
421
422const fn internal_call_error_is_retryable(err: &Error) -> bool {
423 matches!(
424 err.code,
425 ErrorCode::AuthKeyUnknown | ErrorCode::AuthMaterialStale
426 )
427}
428
429#[derive(Clone, Copy, Debug, Eq, PartialEq)]
430enum WaitMode {
431 Bounded,
432 Unbounded,
433}
434
435fn build_internal_call_envelope(
436 target_canister: Principal,
437 target_method: &str,
438 proof: SignedInternalInvocationProofV1,
439 args: Vec<u8>,
440) -> CanicInternalCallEnvelopeV1 {
441 CanicInternalCallEnvelopeV1 {
442 version: 1,
443 header: CanicInternalCallHeaderV1 {
444 target_canister,
445 target_method: target_method.to_string(),
446 },
447 proof,
448 args,
449 }
450}
451
452pub struct IntentKey(BoundedString128);
467
468impl IntentKey {
469 pub fn try_new(value: impl Into<String>) -> Result<Self, Error> {
470 BoundedString128::try_new(value)
471 .map(Self)
472 .map_err(Error::invalid)
473 }
474
475 #[must_use]
476 pub fn as_str(&self) -> &str {
477 self.0.as_str()
478 }
479
480 #[must_use]
481 pub fn into_inner(self) -> BoundedString128 {
482 self.0
483 }
484}
485
486impl AsRef<str> for IntentKey {
487 fn as_ref(&self) -> &str {
488 self.0.as_str()
489 }
490}
491
492impl From<IntentKey> for BoundedString128 {
493 fn from(key: IntentKey) -> Self {
494 key.0
495 }
496}
497
498pub struct IntentReservation {
517 key: IntentKey,
518 quantity: u64,
519 ttl_secs: Option<u64>,
520 max_in_flight: Option<u64>,
521}
522
523impl IntentReservation {
524 #[must_use]
525 pub const fn new(key: IntentKey, quantity: u64) -> Self {
526 Self {
527 key,
528 quantity,
529 ttl_secs: None,
530 max_in_flight: None,
531 }
532 }
533
534 #[must_use]
535 pub const fn with_ttl_secs(mut self, ttl_secs: u64) -> Self {
536 self.ttl_secs = Some(ttl_secs);
537 self
538 }
539
540 #[must_use]
541 pub const fn with_max_in_flight(mut self, max_in_flight: u64) -> Self {
542 self.max_in_flight = Some(max_in_flight);
543 self
544 }
545
546 pub(crate) fn into_spec(self) -> WorkflowIntentSpec {
547 WorkflowIntentSpec::new(
548 self.key.into(),
549 self.quantity,
550 self.ttl_secs,
551 self.max_in_flight,
552 )
553 }
554}
555
556pub struct CallBuilder<'a> {
561 inner: WorkflowCallBuilder<'a>,
562}
563
564impl CallBuilder<'_> {
565 pub fn with_arg<A>(self, arg: A) -> Result<Self, Error>
569 where
570 A: CandidType,
571 {
572 Ok(Self {
573 inner: self.inner.with_arg(arg).map_err(Error::from)?,
574 })
575 }
576
577 pub fn with_args<A>(self, args: A) -> Result<Self, Error>
579 where
580 A: ArgumentEncoder,
581 {
582 Ok(Self {
583 inner: self.inner.with_args(args).map_err(Error::from)?,
584 })
585 }
586
587 #[must_use]
589 pub fn with_raw_args<'b>(self, args: impl Into<Cow<'b, [u8]>>) -> CallBuilder<'b> {
590 CallBuilder {
591 inner: self.inner.with_raw_args(args),
592 }
593 }
594
595 #[must_use]
598 pub fn with_cycles(self, cycles: u128) -> Self {
599 Self {
600 inner: self.inner.with_cycles(cycles),
601 }
602 }
603
604 #[must_use]
607 pub fn with_intent(self, intent: IntentReservation) -> Self {
608 Self {
609 inner: self.inner.with_intent(intent.into_spec()),
610 }
611 }
612
613 pub async fn execute(self) -> Result<CallResult, Error> {
616 Ok(CallResult {
617 inner: self.inner.execute().await.map_err(Error::from)?,
618 })
619 }
620}
621
622pub struct CallResult {
634 inner: WorkflowCallResult,
635}
636
637impl CallResult {
638 pub fn candid<R>(&self) -> Result<R, Error>
639 where
640 R: CandidType + DeserializeOwned,
641 {
642 self.inner.candid().map_err(Error::from)
643 }
644
645 pub fn candid_tuple<R>(&self) -> Result<R, Error>
646 where
647 R: for<'de> ArgumentDecoder<'de>,
648 {
649 self.inner.candid_tuple().map_err(Error::from)
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::config::schema::RoleAttestationConfig;
657 use crate::dto::auth::{InternalInvocationProofPayloadV1, SignedInternalInvocationProofV1};
658 use candid::decode_args;
659 use std::collections::BTreeMap;
660
661 fn p(id: u8) -> Principal {
662 Principal::from_slice(&[id; 29])
663 }
664
665 fn proof() -> SignedInternalInvocationProofV1 {
666 SignedInternalInvocationProofV1 {
667 payload: InternalInvocationProofPayloadV1 {
668 subject: p(1),
669 role: CanisterRole::new("project_hub"),
670 subnet_id: None,
671 audience: p(2),
672 audience_method: "system_add_project_to_user".to_string(),
673 issued_at: 10,
674 expires_at: 20,
675 epoch: 3,
676 },
677 signature: vec![1, 2, 3],
678 key_id: 1,
679 }
680 }
681
682 fn request() -> InternalInvocationProofRequest {
683 InternalInvocationProofRequest {
684 subject: p(1),
685 role: CanisterRole::new("project_hub"),
686 subnet_id: Some(p(9)),
687 audience: p(2),
688 audience_method: "system_add_project_to_user".to_string(),
689 ttl_secs: 120,
690 metadata: None,
691 }
692 }
693
694 fn cfg(min_epoch: u64) -> RoleAttestationConfig {
695 let mut min_accepted_epoch_by_role = BTreeMap::new();
696 min_accepted_epoch_by_role.insert("project_hub".to_string(), min_epoch);
697 RoleAttestationConfig {
698 ecdsa_key_name: "key_1".to_string(),
699 max_ttl_secs: 900,
700 min_accepted_epoch_by_role,
701 }
702 }
703
704 fn clear_internal_invocation_proof_cache() {
705 INTERNAL_INVOCATION_PROOF_CACHE.with_borrow_mut(BTreeMap::clear);
706 }
707
708 #[test]
709 fn canic_call_envelope_binds_target_method_and_original_args() {
710 let args = encode_args((7_u64, "project")).expect("args encode");
711 let envelope =
712 build_internal_call_envelope(p(2), "system_add_project_to_user", proof(), args);
713
714 assert_eq!(envelope.version, 1);
715 assert_eq!(envelope.header.target_canister, p(2));
716 assert_eq!(envelope.header.target_method, "system_add_project_to_user");
717 assert_eq!(
718 envelope.proof.payload.audience_method,
719 "system_add_project_to_user"
720 );
721
722 let decoded: (u64, String) = decode_args(&envelope.args).expect("decode original args");
723 assert_eq!(decoded, (7, "project".to_string()));
724 }
725
726 #[test]
727 fn canic_call_builder_records_role_and_raw_args() {
728 let raw = vec![9_u8, 8, 7];
729 let builder = CanicCall::unbounded_wait(p(3), "target")
730 .with_caller_role(CanisterRole::new("project_hub"))
731 .with_proof_ttl_secs(30)
732 .with_cycles(10)
733 .with_raw_args(raw.clone());
734
735 assert_eq!(builder.wait, WaitMode::Unbounded);
736 assert_eq!(builder.canister_id, p(3));
737 assert_eq!(builder.method, "target");
738 assert_eq!(builder.caller_role, Some(CanisterRole::new("project_hub")));
739 assert_eq!(builder.ttl_secs, Some(30));
740 assert_eq!(builder.cycles, 10);
741 assert_eq!(builder.args.as_ref(), raw.as_slice());
742 }
743
744 #[test]
745 fn internal_invocation_proof_cache_reuses_exact_fresh_edge() {
746 clear_internal_invocation_proof_cache();
747 let request = request();
748 let mut proof = proof();
749 proof.payload.subnet_id = request.subnet_id;
750 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof.clone());
751
752 let cached = cached_internal_invocation_proof(&request, &cfg(0), p(7), 12)
753 .expect("fresh matching proof should cache-hit");
754
755 assert_eq!(cached, proof);
756 }
757
758 #[test]
759 fn internal_invocation_proof_cache_rejects_near_expiry_entry() {
760 clear_internal_invocation_proof_cache();
761 let request = request();
762 let mut proof = proof();
763 proof.payload.subnet_id = request.subnet_id;
764 proof.payload.issued_at = 10;
765 proof.payload.expires_at = 20;
766 cache_internal_invocation_proof(&request, &cfg(0), p(7), 18, proof);
767
768 assert!(cached_internal_invocation_proof(&request, &cfg(0), p(7), 18).is_none());
769 }
770
771 #[test]
772 fn internal_invocation_proof_cache_rejects_epoch_below_local_floor() {
773 clear_internal_invocation_proof_cache();
774 let request = request();
775 let mut proof = proof();
776 proof.payload.subnet_id = request.subnet_id;
777 proof.payload.epoch = 3;
778 cache_internal_invocation_proof(&request, &cfg(0), p(7), 12, proof);
779
780 assert!(cached_internal_invocation_proof(&request, &cfg(4), p(7), 12).is_none());
781 }
782
783 #[test]
784 fn internal_call_retry_classifier_is_limited_to_repairable_auth_material() {
785 assert!(internal_call_error_is_retryable(&Error::new(
786 ErrorCode::AuthKeyUnknown,
787 "unknown key".to_string(),
788 )));
789 assert!(internal_call_error_is_retryable(&Error::new(
790 ErrorCode::AuthMaterialStale,
791 "stale epoch".to_string(),
792 )));
793 assert!(!internal_call_error_is_retryable(&Error::new(
794 ErrorCode::AuthProofExpired,
795 "expired".to_string(),
796 )));
797 assert!(!internal_call_error_is_retryable(&Error::unauthorized(
798 "role mismatch"
799 )));
800 }
801}