Guards and async code
Summary
This example canister shows some advanced behavior between guards and asynchronous code. This example is meant for Rust canister developers that are already familiar with asynchronous code and the security best-practices related to inter-canister calls and rollbacks.
Guard to maintain invariants
This example canister stores a bunch of items on the heap, where each item is simply modelled as a String
. One
invariant that the canister aims at maintaining is that each item is processed at most once, where processing an
item involves some asynchronous code. A concrete example of such a setting would be a minter canister processing minting
requests by contacting a ledger canister, where crucially double minting should be avoided.
One tricky part in this scenario is that an item can therefore only be marked as processed after the asynchronous code has completed, meaning in the callback. As mentioned in the security best-practices, it's not always feasible to guarantee that the callback will not trap, which in that case would break the invariant due to the state being rolled back.
The standard solution to maintain such an invariant, despite a potential panic in the callback is to use a guard.
However, this example canister shows that it's also crucially important that the declaration of the guard happens in
another message than the callback, which is the case for true asynchronous code (e.g. inter-canister calls, raw_rand
,
etc.). It's in particular not enough to await
a function that's declared to be async
, since if the future can polled
until completion directly, everything will be executed in a single message.
Automated integration tests
To run the integration tests under tests/
install PocketIC server and then run:
cargo build --target wasm32-unknown-unknown --release && cargo test
Manual testing with dfx
Setup
Start dfx
:
dfx start --background
Deploy the canister:
dfx deploy
You should now be able to query the canister, e.g., to check if an item is processed:
dfx canister call guards is_item_processed 'mint'
This should return (null)
since the canister currently has an empty state.
Test
As an example, we show how the behavior tested in should_process_single_item_and_mark_it_as_processed
can be tested
manually.
Set the item "mint"
to be processed:
dfx canister call guards set_non_processed_items 'vec { "mint" }'
As a sanity check, ensure that the item is not yet processed:
dfx canister call guards is_item_processed 'mint'
should return (opt false)
.
Process the item by calling the panicking callback:
dfx canister call guards process_single_item_with_panicking_callback '("mint", variant { TrueAsyncCall })'
Since the queried endpoint panics on purpose, expect some error message similar to:
2024-05-29 11:54:39.817800 UTC: [Canister bkyz2-fmaaa-aaaaa-qaaaq-cai] Panicked at 'panicking callback!', src/lib.rs:47:5
Error: Failed update call.
Caused by: Failed update call.
The replica returned a rejection error: reject code CanisterError, reject message Canister bkyz2-fmaaa-aaaaa-qaaaq-cai trapped explicitly: Panicked at 'panicking callback!', src/lib.rs:47:5, error code None
Ensure that the guard was executed to ensure that the item is marked as processed despite the previous panic:
dfx canister call guards is_item_processed 'mint'
This should return (opt true)
.