Inter-canister calls & async code
The ICP allows canisters to seamlessly interact with other canisters by calling their methods, just like external users call canisters. When writing canisters, you will need to issue such inter-canister calls for operations such as transferring tokens or accessing certain system functionality through the management canister.
This remote procedure call (RPC) mechanism, based on a request-response paradigm, will feel familiar if you are coming from Ethereum or other smart contract enabled blockchains, but there are also important differences that you should understand:
On ICP, inter-canister calls are asynchronous. When a canister
C1
calls a canisterC2
,C1
can process other calls while waiting on the response fromC2
. This significantly increases the canisters' scalability. However, it also means that canister authors have to correctly handle calls executing concurrently. In particular, this differs from the transactional nature of Ethereum's smart contract calls.Since the calls are asynchronous, they are based on callbacks. When issuing a request to the callee canister, the caller also specifies a callback that will handle the callee's response. Many programming languages provide syntactic sugar for handling asynchronous calls in the form of
async/await
syntax, which obviates the need for explicit callbacks and allows structuring asynchronous code similar to an ordinary synchronous function. Motoko, the Rust Canister Development Kit (CDK), Azle (Typescript CDK) and Kybra (Python CDK) all support such syntactic sugar.The requests are not guaranteed to be delivered. This allows the system to use its resources more optimally overall, but again differs from Ethereum and many other blockchains, and needs to be handled correctly. Moreover, calls can be made in either bounded-wait or unbounded-wait modes. The former is better suited for a wider range of trust assumptions, supports a higher volume of messages and is much more resilient under high system load, but in this mode the "true" response is not guaranteed to be delivered, and can be masked by a system generated error response instead.
ICP canisters can be written in any language that compiles into Wasm. The caller and callee canisters can be written in different languages and need some common data format to exchange messages. While the ICP doesn't enforce a data format, Candid is the de facto standard.
Service discovery
Before you can call other canisters that are not part of your project, you need to learn the available endpoints (methods) that you can call, as well as their arguments and return values. Candid serves both as a data format and an interface description language. The IC SDK will by default bundle the interface description (if available) in the candid:service
metadata section of the canister code. For example, you can examine the interface of the ICP ledger canister (whose principal is ryjl3-tyaaa-aaaaa-aaaba-cai
) using dfx
.
dfx canister metadata ryjl3-tyaaa-aaaaa-aaaba-cai candid:service --network ic
If you are using Candid, you should also write or generate service description files for your own canisters so that other canisters and external applications can easily call into your canister. See the Candid documentation for more instructions.
When the canister authors support it, remote canisters can also be made "pullable", such that you can easily test against the canisters locally. See the dfx documentation for more details.
Performing inter-canister calls
Once you know the endpoints you want to call, in most cases you will want to use the CDK of your language to perform the calls. The CDK will generally take care of Candid encoding and decoding, and provide an async/await
based syntax for your inter-canister calls. Refer to the language documentation
(Motoko, Rust, Typescript, Python)
for more details.
Low-level API
Under the hood, the CDKs use the low-level Wasm API to perform inter-canister calls and set appropriate callbacks to handle responses. Most users will not need to interact with this API directly, but you can find more details in the Internet Computer interface specification if you need to perform workflows or use functionality that might not be directly exposed by your CDK or if you intend to implement a CDK yourself.
Attaching cycles
Cycles are the currency in which canisters pay for their resource usage. Canisters can send some of the cycles they hold to other canisters. This can be done either by directly attaching cycles to any call to the target canister, or by calling the dedicated deposit_cycles
method of the management canister and attaching the cycles there. In the former way of attaching cycles (not using deposit_cycles
), the target canister must explicitly accept part or all of the sent cycles when processing the call; the remainder is refunded to the caller.
Note that cycles may get dropped when using bounded-wait calls. See the section on bounded- vs unbounded-wait calls for more details.
Some endpoints require the caller to attach cycles to the call. For example, the threshold signature operations of the management canister require cycles to be attached. Requiring cycles can be used to implement a "direct gas" model, as opposed to the default "reverse gas" model of ICP.
Refer to the language documentation (Motoko, Rust, Typescript, Python) for details on how to send and accept cycles.
Bounded- vs unbounded-wait calls
ICP supports two kinds of inter-canister calls:
Unbounded wait calls instruct the system to wait for as long as it takes to get a response to a call. The response might still be a failure, either because the request didn't get delivered to the callee or because the callee rejected the request or produced an error while processing it. The unbounded wait provides the caller with the guarantee that it will learn the exact response to the call, which is why we also sometimes refer to these calls as guaranteed response calls. If the callee produces a response (which may be unsuccessful, i.e., an error during call processing), that exact response will be delivered to the caller. Furthermore, if the request isn't successfully delivered to the callee (which can happen during high load, callee running out of cycles, and other reasons), the response will notify the caller of this.
Bounded-wait calls do not wait forever and may return an "unknown" error response after some time. They allow the caller to specify a timeout (which is capped from above by the system to some maximum value, such as 5 minutes), after which the system will stop waiting. This is why we also refer to these calls as best-effort response calls. When the system stops waiting for a response, the request may or may not have been processed by the callee. The system is free to drop the request, but it may also deliver it to the target, and simply drop the produced response later. The caller must, if necessary, determine whether the call took place or not by some other mechanism. Any cycles associated with a dropped message (request or response) disappear.
Unbounded-wait calls require the caller to handle one error condition (unknown call status) less. But bounded-wait calls also have significant advantages:
- If the callee is unresponsive (which could happen because of high load on the callee, high load or an outage on the callee's subnet, or even because the callee is malicious and delays the response on purpose), a caller that made an unbounded-wait call stalls and has no control over when it can resume processing or provide an answer. That is, a callee that keeps processing the request forever forces the caller to also wait forever for a response. As safe upgrades require stopping the canister and waiting for all outstanding calls to return, canisters that issue unbounded-wait calls may be prevented from safely upgrading, potentially forever.
- Bounded-wait calls scale much better. When the system is under high load, canisters are much more likely to still be able to issue bounded-wait calls than unbounded-wait calls.
Here are some guidelines how to choose between the two types of calls:
- Always prefer bounded-wait calls for calls that don't change the state of the callee, i.e., reads.
- For endpoints that change the state of the callee, the best practice is to make such endpoints amenable to safe retries (e.g., by making them idempotent). Note that making user-facing endpoints amenable to safe retries is a good idea anyway, as it's needed to safely handle external user calls. Use bounded-wait calls for such endpoints, and handle the additional edge cases.
- Use unbounded-wait calls for endpoints that mutate state and do not enable safe retries, or calls that perform larger cycle transfers. As mentioned, bounded-wait calls lose cycles attached to requests/responses that are dropped. Be aware of the limitations of unbounded-wait calls listed above. If safe upgrades are needed, consider using a stateless proxy canister (see the security best practices for more information).
async/await
syntax, concurrency and state changes
The async/await
syntax allows canister methods to issue asynchronous inter-canister calls and still be structured like an ordinary synchronous function. In this syntax, a method foo
on a canister A
could be written something like this: (in a Rust-like syntax):
async fn foo() {
do_work();
call(canister_b, 'bar').await;
do_more_work(res);
}
While this looks like an ordinary function, there are important differences in behavior between synchronous functions and functions that perform inter-canister calls.
First, unlike a synchronous function (used on, say Ethereum), multiple method call executions on the same canister can be executed concurrently in the presence of inter-canister calls. This increases the canister's throughput, but it also means that the code needs to be correct also in the presence of concurrent behaviors. In particular, the developers have to ensure that no re-entrancy issues occur.
Second, canister methods that use inter-canister calls are not atomic. A failure somewhere in the method might not roll back all the changes that the method performed. Under the hood, such methods use the low-level Wasm API calls and are translated into multiple message handler Wasm functions. An initial handler function that handles the method call itself (corresponding to do_work
above), and other handler functions that serve as callbacks to handle the responses for any inter-canister calls that have been issued (do_more_work
in the example above). The execution of each message handler is atomic on its own; an error (more precisely, a Wasm trap) rolls back only the changes performed by the current message handler, but does not affect the changes made by the other handlers.
For these reasons, you should understand how the CDK for your language of choice desugars the async/await
syntax into Wasm code in order to write correct code in the presence of inter-canister calls. In particular, you should understand where the message handler boundaries are, since these are both "commit points" for state changes and also potential interleaving points for concurrent executions.
A separate document contains more details on interleaving/commit points and message execution properties. Refer to the security best practices for advice on how to handle concurrency and state rollback issues correctly. Finally, refer to the CDK documentation for your language to learn more about desugaring of async/await
and interleaving/commit points.