Skip to main content

Inter-canister calls & async code

Beginner
Concept

The ICP allows canisters to seamlessly interact with other canisters by calling their methods, just like external users call canisters. You are likely to need inter-canister calls early on: they are necessary to transfer the ICP token, or to access 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. That means that while an inter-canister call is in progress other calls can still be processed. 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 best-effort or guaranteed response modes. The best-effort mode 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 down to Wasm. That means that 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. When attaching cycles to a call (i.e., not using deposit_cycles), the target canister must explicitly accept part or all of the sent cycles; the remainder is refunded to the caller.

Note that cycles may get dropped when using best-effort response calls. See the section on #guaranteed vs best-effort response calls for more details.

Some endpoints require the caller to attach cycles to the call. For example, the onchain 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.

Guaranteed response vs best-effort response calls

ICP supports two kind of inter-canister calls:

  • Guaranteed response calls provide the caller with the guarantee that 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.

  • Best-effort response calls drop this guarantee. 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 return an error to the caller. The request may or may not be processed by the callee; the system is free to drop the request, but it may also deliver it to the target. 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.

Guaranteed response calls are a legacy call mechanism. While on the surface they make it easier to write correct applications, they also suffer some significant drawbacks:

  • 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), the caller canister is stalled and has no control over when it can resume processing or provide an answer. Furthermore, the caller canister cannot be safely stopped and upgraded.
  • They do not scale well. When the system is under high load, canisters may be unable to issue further inter-canister calls.

Best-effort calls address both of these problems. However, they can require more effort on the part of the caller to handle the additional error case. Here are some guidelines how to choose between the two types of calls:

  • Always prefer best-effort response 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. 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 best-effort responses calls for such endpoints, and handle the additional edge cases.
  • Use guaranteed response calls for endpoints that mutate state and do not enable safe retries, or calls that perform larger cycle transfers. As mentioned, best-effort response calls will currently lose cycles when request/responses are dropped. Be aware of the limitations of guaranteed response 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.