Skip to main content

ic_testkit/pic/
calls.rs

1use candid::{
2    CandidType, Principal, encode_args,
3    utils::{ArgumentDecoder, ArgumentEncoder},
4};
5use ic_testkit_candid::{DecodeError, DecodeLimits, decode_args, decode_one};
6use pocket_ic::{PocketIc, RejectResponse};
7use serde::de::DeserializeOwned;
8use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
9
10use super::{CandidCallContext, CandidCallError, PocketIcOperationError, transport};
11
12#[derive(Clone, Copy)]
13struct CallContext<'a> {
14    operation: &'static str,
15    canister_id: Principal,
16    caller: Principal,
17    method: &'a str,
18}
19
20impl CallContext<'_> {
21    fn to_error_context(self) -> CandidCallContext {
22        CandidCallContext::new(self.operation, self.canister_id, self.caller, self.method)
23    }
24}
25
26/// Typed Candid calls with caller-selected reply limits and contextual failures.
27///
28/// Every call requires explicit [`DecodeLimits`]; these bound reply decoding,
29/// not PocketIC transport duration or canister execution.
30pub trait CandidCallExt {
31    /// Encode and execute an anonymous update call, then decode its result.
32    fn update_candid<T, A>(
33        &self,
34        canister_id: Principal,
35        method: &str,
36        args: A,
37        limits: DecodeLimits,
38    ) -> Result<T, CandidCallError>
39    where
40        T: CandidType + DeserializeOwned,
41        A: ArgumentEncoder,
42    {
43        self.update_candid_as(canister_id, Principal::anonymous(), method, args, limits)
44    }
45
46    /// Encode and execute an update call as an explicit caller.
47    fn update_candid_as<T, A>(
48        &self,
49        canister_id: Principal,
50        caller: Principal,
51        method: &str,
52        args: A,
53        limits: DecodeLimits,
54    ) -> Result<T, CandidCallError>
55    where
56        T: CandidType + DeserializeOwned,
57        A: ArgumentEncoder;
58
59    /// Encode and execute an anonymous query call, then decode its result.
60    fn query_candid<T, A>(
61        &self,
62        canister_id: Principal,
63        method: &str,
64        args: A,
65        limits: DecodeLimits,
66    ) -> Result<T, CandidCallError>
67    where
68        T: CandidType + DeserializeOwned,
69        A: ArgumentEncoder,
70    {
71        self.query_candid_as(canister_id, Principal::anonymous(), method, args, limits)
72    }
73
74    /// Encode and execute a query call as an explicit caller.
75    fn query_candid_as<T, A>(
76        &self,
77        canister_id: Principal,
78        caller: Principal,
79        method: &str,
80        args: A,
81        limits: DecodeLimits,
82    ) -> Result<T, CandidCallError>
83    where
84        T: CandidType + DeserializeOwned,
85        A: ArgumentEncoder;
86}
87
88impl CandidCallExt for PocketIc {
89    /// Generic update call helper with an explicit caller principal.
90    fn update_candid_as<T, A>(
91        &self,
92        canister_id: Principal,
93        caller: Principal,
94        method: &str,
95        args: A,
96        limits: DecodeLimits,
97    ) -> Result<T, CandidCallError>
98    where
99        T: CandidType + DeserializeOwned,
100        A: ArgumentEncoder,
101    {
102        let context = CallContext {
103            operation: "update_call",
104            canister_id,
105            caller,
106            method,
107        };
108        call_candid(
109            context,
110            args,
111            |bytes| Self::update_call(self, canister_id, caller, method, bytes),
112            |bytes| decode_one(bytes, limits),
113        )
114    }
115
116    /// Generic query call helper with an explicit caller principal.
117    fn query_candid_as<T, A>(
118        &self,
119        canister_id: Principal,
120        caller: Principal,
121        method: &str,
122        args: A,
123        limits: DecodeLimits,
124    ) -> Result<T, CandidCallError>
125    where
126        T: CandidType + DeserializeOwned,
127        A: ArgumentEncoder,
128    {
129        let context = CallContext {
130            operation: "query_call",
131            canister_id,
132            caller,
133            method,
134        };
135        call_candid(
136            context,
137            args,
138            |bytes| Self::query_call(self, canister_id, caller, method, bytes),
139            |bytes| decode_one(bytes, limits),
140        )
141    }
142}
143
144/// Execute an update as `caller`, decoding the top-level Candid reply arguments.
145///
146/// Unlike [`CandidCallExt::update_candid_as`], `(u64, bool)` means two reply
147/// arguments rather than one tuple-valued reply. Use `()` for no replies or
148/// `(T,)` for one reply. Pass [`Principal::anonymous()`] for an anonymous caller.
149/// Application-level `Result<T, E>` values are returned without unwrapping.
150pub fn update_candid_args<T, A>(
151    pic: &PocketIc,
152    canister_id: Principal,
153    caller: Principal,
154    method: &str,
155    args: A,
156    limits: DecodeLimits,
157) -> Result<T, CandidCallError>
158where
159    T: for<'de> ArgumentDecoder<'de>,
160    A: ArgumentEncoder,
161{
162    let context = CallContext {
163        operation: "update_call",
164        canister_id,
165        caller,
166        method,
167    };
168    call_candid(
169        context,
170        args,
171        |bytes| pic.update_call(canister_id, caller, method, bytes),
172        |bytes| decode_args(bytes, limits),
173    )
174}
175
176/// Execute a query as `caller`, decoding the top-level Candid reply arguments.
177///
178/// Unlike [`CandidCallExt::query_candid_as`], `(u64, bool)` means two reply
179/// arguments rather than one tuple-valued reply. Use `()` for no replies or
180/// `(T,)` for one reply. Pass [`Principal::anonymous()`] for an anonymous caller.
181/// Application-level `Result<T, E>` values are returned without unwrapping.
182pub fn query_candid_args<T, A>(
183    pic: &PocketIc,
184    canister_id: Principal,
185    caller: Principal,
186    method: &str,
187    args: A,
188    limits: DecodeLimits,
189) -> Result<T, CandidCallError>
190where
191    T: for<'de> ArgumentDecoder<'de>,
192    A: ArgumentEncoder,
193{
194    let context = CallContext {
195        operation: "query_call",
196        canister_id,
197        caller,
198        method,
199    };
200    call_candid(
201        context,
202        args,
203        |bytes| pic.query_call(canister_id, caller, method, bytes),
204        |bytes| decode_args(bytes, limits),
205    )
206}
207
208fn call_candid<T, A, F, D>(
209    context: CallContext<'_>,
210    args: A,
211    call: F,
212    decode: D,
213) -> Result<T, CandidCallError>
214where
215    A: ArgumentEncoder,
216    F: FnOnce(Vec<u8>) -> Result<Vec<u8>, RejectResponse>,
217    D: FnOnce(&[u8]) -> Result<T, DecodeError>,
218{
219    let bytes = encode_args(args).map_err(|err| CandidCallError::Encode {
220        context: Box::new(context.to_error_context()),
221        message: err.to_string(),
222    })?;
223    let result = run_raw_call(context, || call(bytes))?;
224    decode(&result).map_err(|source| CandidCallError::Decode {
225        context: Box::new(context.to_error_context()),
226        bytes: result.len(),
227        source: Box::new(source),
228    })
229}
230
231fn run_raw_call<F>(context: CallContext<'_>, call: F) -> Result<Vec<u8>, CandidCallError>
232where
233    F: FnOnce() -> Result<Vec<u8>, RejectResponse>,
234{
235    match catch_unwind(AssertUnwindSafe(call)) {
236        Ok(Ok(bytes)) => Ok(bytes),
237        Ok(Err(response)) => Err(CandidCallError::CanisterReject {
238            context: Box::new(context.to_error_context()),
239            response: Box::new(response),
240        }),
241        Err(payload) => {
242            let source =
243                PocketIcOperationError::new(transport::panic_payload_to_string(payload.as_ref()));
244            if source.is_transport() {
245                Err(CandidCallError::Transport {
246                    context: Box::new(context.to_error_context()),
247                    source,
248                })
249            } else {
250                resume_unwind(payload)
251            }
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use candid::Principal;
259    use ic_testkit_candid::{DecodeError, DecodeLimits, decode_args, decode_one};
260
261    use super::{CallContext, CandidCallError, call_candid, run_raw_call};
262
263    const LIMITS: DecodeLimits = DecodeLimits {
264        reply_bytes: 65_536,
265        decoding_quota: 1_000_000,
266        skipping_quota: 100_000,
267        type_entries: 1024,
268        header_bytes: 8192,
269    };
270
271    #[test]
272    fn decode_error_includes_call_context() {
273        let context = CallContext {
274            operation: "query_call",
275            canister_id: Principal::anonymous(),
276            caller: Principal::management_canister(),
277            method: "get",
278        };
279
280        let err = call_candid(
281            context,
282            (),
283            |_| Ok(vec![0xde, 0xad]),
284            |bytes| decode_one::<u64>(bytes, LIMITS),
285        )
286        .expect_err("decode fails");
287
288        assert!(err.to_string().contains("candid decode failed"));
289        assert!(err.to_string().contains("operation=query_call"));
290        assert!(err.to_string().contains("method=get"));
291        assert!(err.to_string().contains("bytes=2"));
292        assert!(std::error::Error::source(&err).unwrap().is::<DecodeError>());
293        let CandidCallError::Decode {
294            context,
295            bytes,
296            source,
297        } = err
298        else {
299            panic!("expected decode failure");
300        };
301        assert_eq!(bytes, 2);
302        assert_eq!(context.method, "get");
303        assert!(matches!(*source, DecodeError::Candid(_)));
304    }
305
306    #[test]
307    fn caller_decode_limits_retain_typed_refusals() {
308        let context = CallContext {
309            operation: "query_call",
310            canister_id: Principal::anonymous(),
311            caller: Principal::anonymous(),
312            method: "get",
313        };
314        let bytes = candid::encode_one(42_u64).unwrap();
315        let length = bytes.len();
316        let error = call_candid(
317            context,
318            (),
319            |_| Ok(bytes),
320            |bytes| {
321                decode_one::<u64>(
322                    bytes,
323                    DecodeLimits {
324                        reply_bytes: 0,
325                        ..LIMITS
326                    },
327                )
328            },
329        )
330        .unwrap_err();
331        let cause = std::error::Error::source(&error)
332            .unwrap()
333            .downcast_ref::<DecodeError>()
334            .unwrap();
335        assert!(
336            matches!(cause, DecodeError::ReplyTooLarge { actual, maximum: 0 } if *actual == length)
337        );
338
339        // Tiny wire data can still describe expensive values: work is bounded
340        // independently of the reply length, including the arguments API.
341        let bytes = candid::encode_args((vec![(); 100_000],)).unwrap();
342        let error = call_candid(
343            context,
344            (),
345            |_| Ok(bytes),
346            |bytes| {
347                decode_args::<(Vec<()>,)>(
348                    bytes,
349                    DecodeLimits {
350                        decoding_quota: 0,
351                        ..LIMITS
352                    },
353                )
354            },
355        )
356        .unwrap_err();
357        let cause = std::error::Error::source(&error)
358            .unwrap()
359            .downcast_ref::<DecodeError>()
360            .unwrap();
361        assert!(matches!(cause, DecodeError::Candid(_)));
362        assert!(std::error::Error::source(cause).is_some());
363    }
364
365    #[test]
366    fn reply_arguments_preserve_top_level_arity_and_wide_values() {
367        let context = CallContext {
368            operation: "update_call",
369            canister_id: Principal::anonymous(),
370            caller: Principal::management_canister(),
371            method: "measure",
372        };
373        let values = (u128::MAX, u128::from(u64::MAX) + 1);
374        let bytes = candid::encode_args(values).unwrap();
375        assert_eq!(candid::decode_args::<(u128, u128)>(&bytes).unwrap(), values);
376        assert!(candid::decode_one::<(u128, u128)>(&bytes).is_err());
377        let tuple = candid::encode_one(values).unwrap();
378        assert!(candid::decode_args::<(u128, u128)>(&tuple).is_err());
379        assert_eq!(candid::decode_one::<(u128, u128)>(&tuple).unwrap(), values);
380        let no_replies = candid::encode_args(()).unwrap();
381        candid::decode_args::<()>(&no_replies).unwrap();
382        let one_reply = candid::encode_args((7_u64,)).unwrap();
383        assert_eq!(candid::decode_args::<(u64,)>(&one_reply).unwrap(), (7,));
384        let result: (u64, bool, String) = call_candid(
385            context,
386            (42_u64, false),
387            |args| {
388                assert_eq!(
389                    candid::decode_args::<(u64, bool)>(&args).unwrap(),
390                    (42, false)
391                );
392                Ok(candid::encode_args((9_u64, true, "ready")).unwrap())
393            },
394            |bytes| decode_args(bytes, LIMITS),
395        )
396        .unwrap();
397        assert_eq!(result, (9, true, "ready".into()));
398    }
399
400    #[test]
401    fn invalid_reply_arguments_keep_decode_context() {
402        let context = CallContext {
403            operation: "query_call",
404            canister_id: Principal::anonymous(),
405            caller: Principal::management_canister(),
406            method: "measure",
407        };
408        for bytes in [vec![0xde, 0xad], candid::encode_args((7_u64,)).unwrap()] {
409            let length = bytes.len();
410            let err = call_candid(
411                context,
412                (),
413                |_| Ok(bytes),
414                |bytes| decode_args::<(u64, bool)>(bytes, LIMITS),
415            )
416            .unwrap_err();
417            assert!(err.to_string().contains(&format!("bytes={length}")));
418            let CandidCallError::Decode {
419                context: observed,
420                bytes,
421                ..
422            } = err
423            else {
424                panic!("expected decode failure");
425            };
426            assert_eq!(bytes, length);
427            assert_eq!(observed.operation, "query_call");
428            assert_eq!(observed.canister_id, context.canister_id);
429            assert_eq!(observed.caller, context.caller);
430            assert_eq!(observed.method, "measure");
431        }
432    }
433
434    #[test]
435    fn dead_instance_panic_is_classified_as_transport() {
436        let context = CallContext {
437            operation: "query_call",
438            canister_id: Principal::anonymous(),
439            caller: Principal::management_canister(),
440            method: "get",
441        };
442        let error = run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
443            panic!("HTTP failure: reqwest::Error {{ kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/execute_ingress_message\", source: ConnectError(\"tcp connect error\", Os {{ code: 111, kind: ConnectionRefused, message: \"Connection refused\" }}) }}");
444        })
445        .unwrap_err();
446
447        let source = std::error::Error::source(&error)
448            .unwrap()
449            .downcast_ref::<super::PocketIcOperationError>()
450            .unwrap();
451        assert!(source.is_transport());
452        assert!(source.message().starts_with("HTTP failure: "));
453        assert!(error.to_string().contains("operation=query_call"));
454        assert!(super::super::is_dead_pocket_ic_transport_error(&error));
455        let CandidCallError::Transport { source: owned, .. } = error else {
456            panic!("expected transport failure");
457        };
458        assert!(owned.is_transport());
459    }
460
461    #[test]
462    fn unrelated_panics_resume_with_the_original_payload() {
463        for message in [
464            "application worker channel closed",
465            "fixture expected ConnectionRefused but observed another result",
466        ] {
467            let context = CallContext {
468                operation: "query_call",
469                canister_id: Principal::anonymous(),
470                caller: Principal::management_canister(),
471                method: "get",
472            };
473            let payload = message.to_owned();
474            let original_allocation = payload.as_ptr() as usize;
475            let result = std::panic::catch_unwind(|| {
476                run_raw_call(context, || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
477                    std::panic::panic_any(payload);
478                })
479            });
480            let resumed = result
481                .expect_err("application panic must resume")
482                .downcast::<String>()
483                .expect("original panic type must be preserved");
484            assert_eq!(resumed.as_str(), message);
485            assert_eq!(resumed.as_ptr() as usize, original_allocation);
486        }
487        let payload = Box::new(7_u8);
488        let original = std::ptr::from_ref(payload.as_ref());
489        let result = std::panic::catch_unwind(|| {
490            run_raw_call(
491                CallContext {
492                    operation: "query_call",
493                    canister_id: Principal::anonymous(),
494                    caller: Principal::anonymous(),
495                    method: "get",
496                },
497                || -> Result<Vec<u8>, pocket_ic::RejectResponse> {
498                    std::panic::panic_any(payload);
499                },
500            )
501        });
502        let resumed = result.unwrap_err().downcast::<Box<u8>>().unwrap();
503        assert_eq!(std::ptr::from_ref(resumed.as_ref().as_ref()), original);
504    }
505}