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