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: IERC20Dispatcher and IERC20SafeDispatcher
  • Library Dispatchers: IERC20LibraryDispatcher and IERC20SafeLibraryDispatcher

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__,
        );
        ()
    }
}

Listing 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 felt252 array, __calldata__.
  • A low-level contract call using contract_call_syscall with 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 super::{IERC20Dispatcher, IERC20DispatcherTrait};
    use super::ITokenWrapper;
    use starknet::{get_caller_address, ContractAddress};

    #[storage]
    struct Storage {}

    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)
        }
    }
}


Listing 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
        },
    }
}

Listing 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 deploy syscall (e.g., panic in the constructor, deploying to an existing address).
  • Using the deploy syscall with a non-existent class hash.
  • Using the replace_class syscall 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 super::ITokenWrapper;
    use starknet::{ContractAddress, syscalls, SyscallResultTrait, get_caller_address};

    #[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()
        }
    }
}

Listing 16-4: A sample 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!