Interacting with Another Contract
In the previous section, we introduced the dispatcher pattern for contract interactions. This chapter will explore this pattern in depth and demonstrate how to use it.
The dispatcher pattern allows us to call functions on another contract by using a struct that wraps the contract address and implements the dispatcher trait generated by the compiler from the contract class ABI. This leverages Cairo's trait system to provide a clean and type-safe way to interact with other contracts.
When a contract interface is defined, the compiler automatically
generates and exports multiple dispatchers. For instance, for an IERC20
interface, the compiler will generate the following dispatchers:
- Contract Dispatchers:
IERC20DispatcherandIERC20SafeDispatcher - Library Dispatchers:
IERC20LibraryDispatcherandIERC20SafeLibraryDispatcher
These dispatchers serve different purposes:
- Contract dispatchers wrap a contract address and are used to call functions on other contracts.
- Library dispatchers wrap a class hash and are used to call functions on classes. Library dispatchers will be discussed in the next chapter, "Executing code from another class".
- 'Safe' dispatchers allow the caller to handle potential errors during the execution of the call.
Under the hood, these dispatchers use the low-level
contract_call_syscall, which allows us to call functions on other
contracts by passing the contract address, the function selector, and the
function arguments. The dispatcher abstracts away the complexity of this
syscall, providing a clean and type-safe way to interact with other contracts.
To effectively break down the concepts involved, we will use the ERC20
interface as an illustration.
The Dispatcher Pattern
We mentioned that the compiler would automatically generate the dispatcher
struct and the dispatcher trait for a given interface. Listing
16-1 shows an example of the generated items for
an IERC20 interface that exposes a name view function and a transfer
external function:
use starknet::ContractAddress;
trait IERC20DispatcherTrait<T> {
fn name(self: T) -> felt252;
fn transfer(self: T, recipient: ContractAddress, amount: u256);
}
#[derive(Copy, Drop, starknet::Store, Serde)]
struct IERC20Dispatcher {
pub contract_address: starknet::ContractAddress,
}
impl IERC20DispatcherImpl of IERC20DispatcherTrait<IERC20Dispatcher> {
fn name(self: IERC20Dispatcher) -> felt252 {
let mut __calldata__ = core::traits::Default::default();
let mut __dispatcher_return_data__ = starknet::syscalls::call_contract_syscall(
self.contract_address, selector!("name"), core::array::ArrayTrait::span(@__calldata__),
);
let mut __dispatcher_return_data__ = starknet::SyscallResultTrait::unwrap_syscall(
__dispatcher_return_data__,
);
core::option::OptionTrait::expect(
core::serde::Serde::<felt252>::deserialize(ref __dispatcher_return_data__),
'Returned data too short',
)
}
fn transfer(self: IERC20Dispatcher, recipient: ContractAddress, amount: u256) {
let mut __calldata__ = core::traits::Default::default();
core::serde::Serde::<ContractAddress>::serialize(@recipient, ref __calldata__);
core::serde::Serde::<u256>::serialize(@amount, ref __calldata__);
let mut __dispatcher_return_data__ = starknet::syscalls::call_contract_syscall(
self.contract_address,
selector!("transfer"),
core::array::ArrayTrait::span(@__calldata__),
);
let mut __dispatcher_return_data__ = starknet::SyscallResultTrait::unwrap_syscall(
__dispatcher_return_data__,
);
()
}
}
16-1: A simplified example of the
IERC20Dispatcher and its associated trait and impl
As you can see, the contract dispatcher is a simple struct that wraps a contract
address and implements the IERC20DispatcherTrait generated by the compiler.
For each function, the implementation of the trait will contain the following
elements:
- A serialization of the function arguments into a
felt252array,__calldata__. - A low-level contract call using
contract_call_syscallwith the contract address, the function selector, and the__calldata__array. - A deserialization of the returned value into the expected return type.
Calling Contracts Using the Contract Dispatcher
To illustrate the use of the contract dispatcher, let's create a simple contract
that interacts with an ERC20 contract. This wrapper contract will allow us to
call the name and transfer_from functions on the ERC20 contract, as shown in
Listing 16-2:
use starknet::ContractAddress;
#[starknet::interface]
trait IERC20<TContractState> {
fn name(self: @TContractState) -> felt252;
fn symbol(self: @TContractState) -> felt252;
fn decimals(self: @TContractState) -> u8;
fn total_supply(self: @TContractState) -> u256;
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
) -> bool;
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}
#[starknet::interface]
trait ITokenWrapper<TContractState> {
fn token_name(self: @TContractState, contract_address: ContractAddress) -> felt252;
fn transfer_token(
ref self: TContractState,
address: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool;
}
//**** Specify interface here ****//
#[starknet::contract]
mod TokenWrapper {
use starknet::{ContractAddress, get_caller_address};
use super::ITokenWrapper;
use super::{IERC20Dispatcher, IERC20DispatcherTrait};
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl TokenWrapper of ITokenWrapper<ContractState> {
fn token_name(self: @ContractState, contract_address: ContractAddress) -> felt252 {
IERC20Dispatcher { contract_address }.name()
}
fn transfer_token(
ref self: ContractState,
address: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
let erc20_dispatcher = IERC20Dispatcher { contract_address: address };
erc20_dispatcher.transfer_from(get_caller_address(), recipient, amount)
}
}
}
#[cfg(test)]
mod tests;
16-2: A sample contract which uses the dispatcher pattern to call another contract
In this contract, we import the IERC20Dispatcher struct and the
IERC20DispatcherTrait trait. We then wrap the address of the ERC20 contract in
an instance of the IERC20Dispatcher struct. This allows us to call the name
and transfer functions on the ERC20 contract.
Calling transfer_token external function will modify the state of the contract
deployed at contract_address.
Handling Errors with Safe Dispatchers
As mentioned earlier, 'Safe' dispatchers, like IERC20SafeDispatcher, allow the
calling contract to gracefully handle potential errors that occur during the
execution of the called function.
When a function called via a safe dispatcher panics, the execution returns to
the caller contract, and the safe dispatcher returns a Result::Err containing
the panic reason. This allows developers to implement custom error handling
logic within their contracts.
Consider the following example using a hypothetical IFailableContract
interface:
#[starknet::interface]
pub trait IFailableContract<TState> {
fn can_fail(self: @TState) -> u32;
}
#[feature("safe_dispatcher")]
fn interact_with_failable_contract() -> u32 {
let contract_address = 0x123.try_into().unwrap();
// Use the Safe Dispatcher
let faillable_dispatcher = IFailableContractSafeDispatcher { contract_address };
let response: Result<u32, Array<felt252>> = faillable_dispatcher.can_fail();
// Match the result to handle success or failure
match response {
Result::Ok(x) => x, // Return the value on success
Result::Err(_panic_reason) => {
// Handle the error, e.g., log it or return a default value
// The panic_reason is an array of felts detailing the error
0 // Return 0 in case of failure
},
}
}
16-3: Handling errors using a Safe Dispatcher
In this code, we first obtain an instance of IFailableContractSafeDispatcher
for the target contract address. Calling the can_fail() function using this
safe dispatcher returns a Result<u32, Array<felt252>>, which encapsulates
either the successful u32 result or the failure information. We can then
properly handle this result, as seen in Chapter 9: Error
Handling.
It's important to note that some scenarios still lead to an immediate transaction revert, meaning the error cannot be caught by the caller using a safe dispatcher. These include:
- Failure in a Cairo Zero contract call.
- Library call with a non-existent class hash.
- Contract call to a non-existent contract address.
- Failure within the
deploysyscall (e.g., panic in the constructor, deploying to an existing address).- Using the
deploysyscall with a non-existent class hash.- Using the
replace_classsyscall with a non-existent class hash.These cases are expected to be handled in future Starknet versions.
Calling Contracts using Low-Level Calls
Another way to call other contracts is to directly use the
call_contract_syscall. While less convenient than using the dispatcher
pattern, this syscall provides more control over the serialization and
deserialization process and allows for more customized error handling.
Listing 16-4 shows an example demonstrating how to call the
transfer_from function of an ERC20 contract with a low-level
call_contract_sycall syscall:
use starknet::ContractAddress;
#[starknet::interface]
trait ITokenWrapper<TContractState> {
fn transfer_token(
ref self: TContractState,
address: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool;
}
#[starknet::contract]
mod TokenWrapper {
use starknet::{ContractAddress, SyscallResultTrait, get_caller_address, syscalls};
use super::ITokenWrapper;
#[storage]
struct Storage {}
impl TokenWrapper of ITokenWrapper<ContractState> {
fn transfer_token(
ref self: ContractState,
address: ContractAddress,
recipient: ContractAddress,
amount: u256,
) -> bool {
let mut call_data: Array<felt252> = array![];
Serde::serialize(@get_caller_address(), ref call_data);
Serde::serialize(@recipient, ref call_data);
Serde::serialize(@amount, ref call_data);
let mut res = syscalls::call_contract_syscall(
address, selector!("transfer_from"), call_data.span(),
)
.unwrap_syscall();
Serde::<bool>::deserialize(ref res).unwrap()
}
}
}
contract using call_contract_sycall syscall
To use this syscall, we passed in the contract address, the selector of the
function we want to call and the call arguments. The call arguments must be
provided as an array of arguments, serialized to a Span<felt252>. To serialize
the arguments, we can simply use the Serde trait, provided that the types
being serialized implement this trait. The call returns an array of serialized
values, which we'll need to deserialize ourselves!