Interacting with other contracts and classes using Dispatchers and syscalls

Each time a contract interface is defined, two dispatchers are automatically created and exported by the compiler. Let's consider an interface that we named IERC20, these would be:

  1. The Contract Dispatcher IERC20Dispatcher
  2. The Library Dispatcher IERC20LibraryDispatcher

The compiler also generates a trait IERC20DispatcherTrait, allowing us to call the functions defined in the interface on the dispatcher struct.

In this chapter, we are going to discuss what these are, how they work and how to use them.

To effectively break down the concepts in this chapter, we are going to be using the IERC20 interface from the previous chapter (refer to Listing 99-4):

Contract Dispatcher

As mentioned previously, traits annotated with the #[starknet::interface] attribute automatically generate a dispatcher and a trait on compilation. Our IERC20 interface is expanded into something like this:

Note: The expanded code for our IERC20 interface is a lot longer, but to keep this chapter concise and straight to the point, we focused on one view function name, and one external function transfer.

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 {
    contract_address: ContractAddress,
}

impl IERC20DispatcherImpl of IERC20DispatcherTrait<IERC20Dispatcher> {
    fn name(
        self: IERC20Dispatcher
    ) -> felt252 { // starknet::call_contract_syscall is called in here
    }
    fn transfer(
        self: IERC20Dispatcher, recipient: ContractAddress, amount: u256
    ) { // starknet::call_contract_syscall is called in here
    }
}

Listing 99-5: An expanded form of the IERC20 trait

As you can see, the "classic" dispatcher is just a struct that wraps a contract address and implements the DispatcherTrait generated by the compiler, allowing us to call functions from another contract. This means that we can instantiate a struct with the address of the contract we want to call, and then simply call the functions defined in the interface on the dispatcher struct as if they were methods of that type.

It's also worthy of note that all these are abstracted behind the scenes thanks to the power of Cairo plugins.

Calling Contracts using the Contract Dispatcher

This is an example of a contract named TokenWrapper using a dispatcher to call functions defined on an ERC-20 token. Calling transfer_token will modify the state of the contract deployed at contract_address.

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,
        contract_address: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;
}


//**** Specify interface here ****//
#[starknet::contract]
mod TokenWrapper {
    use super::IERC20DispatcherTrait;
    use super::IERC20Dispatcher;
    use super::ITokenWrapper;
    use starknet::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,
            contract_address: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) -> bool {
            IERC20Dispatcher { contract_address }.transfer(recipient, amount)
        }
    }
}

Listing 99-6: A sample contract which uses the Contract Dispatcher

As you can see, we had to first import IERC20DispatcherTrait and IERC20Dispatcher generated by the compiler, which allows us to make calls to the methods implemented for the IERC20Dispatcher struct (name, transfer, etc), passing in the contract_address of the contract we want to call in the IERC20Dispatcher struct.

Library Dispatcher

The key difference between the contract dispatcher and the library dispatcher lies in the execution context of the logic defined in the class. While regular dispatchers are used to call functions from contracts (with an associated state), library dispatchers are used to call classes (stateless).

Let's consider two contracts A and B.

When A uses IBDispatcher to call functions from the contract B, the execution context of the logic defined in B is that of B. This means that the value returned by get_caller_address() in B will return the address of A, and updating a storage variable in B will update the storage of B.

When A uses IBLibraryDispatcher to call functions from the class of B, the execution context of the logic defined in B's class is that of A. This means that the value returned by get_caller_address() variable in B will return the address of the caller of A, and updating a storage variable in B's class will update the storage of A (remember that the class of B is stateless; there is no state that can be updated!)

The expanded form of the struct and trait generated by the compiler look like:

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 IERC20LibraryDispatcher {
    class_hash: starknet::ClassHash,
}

impl IERC20LibraryDispatcherImpl of IERC20DispatcherTrait<IERC20LibraryDispatcher> {
    fn name(
        self: IERC20LibraryDispatcher
    ) -> felt252 { // starknet::syscalls::library_call_syscall  is called in here
    }
    fn transfer(
        self: IERC20LibraryDispatcher, recipient: ContractAddress, amount: u256
    ) { // starknet::syscalls::library_call_syscall  is called in here
    }
}

Notice that the main difference between the regular contract dispatcher and the library dispatcher is that the former uses call_contract_syscall while the latter uses library_call_syscall.

Listing 99-7: An expanded form of the IERC20 trait

Calling Contracts using the Library Dispatcher

Below's a sample code for calling contracts using the Library Dispatcher.

use starknet::ContractAddress;
#[starknet::interface]
trait IContractB<TContractState> {
    fn set_value(ref self: TContractState, value: u128);

    fn get_value(self: @TContractState) -> u128;
}

#[starknet::contract]
mod ContractA {
    use super::{IContractBDispatcherTrait, IContractBLibraryDispatcher};
    use starknet::ContractAddress;

    #[storage]
    struct Storage {
        value: u128
    }

    #[generate_trait]
    #[external(v0)]
    impl ContractA of IContractA {
        fn set_value(ref self: ContractState, value: u128) {
            IContractBLibraryDispatcher { class_hash: starknet::class_hash_const::<0x1234>() }
                .set_value(value)
        }

        fn get_value(self: @ContractState) -> u128 {
            self.value.read()
        }
    }
}

Listing 99-8: A sample contract using the Library Dispatcher

As you can see, we had to first import in our contract the IContractBDispatcherTrait and IContractBLibraryDispatcher which were generated from our interface by the compiler. Then, we can create an instance of IContractBLibraryDispatcher passing in the class_hash of the class we want to make library calls to. From there, we can call the functions defined in that class, executing its logic in the context of our contract. When we call set_value on ContractA, it will make a library call to the set_value function in ContractB, updating the value of the storage variable value in ContractA.

Using low-level syscalls

Another way to call other contracts and classes is to use the starknet::call_contract_syscalland starknet::library_call_syscall system calls. The dispatchers we described in the previous sections are high-level syntaxes for these low-level system calls.

Using these syscalls can be handy for customized error handling or to get more control over the serialization/deserialization of the call data and the returned data. Here's an example demonstrating how to use a call_contract_sycall to call the transfer function of an ERC20 contract:

use starknet::ContractAddress;
#[starknet::interface]
trait ITokenWrapper<TContractState> {
    fn transfer_token(
        ref self: TContractState,
        address: ContractAddress,
        sender: ContractAddress,
        recipient: ContractAddress,
        amount: u256
    ) -> bool;
}

#[starknet::contract]
mod TokenWrapper {
    use super::ITokenWrapper;
    use serde::Serde;
    use starknet::SyscallResultTrait;
    use starknet::ContractAddress;

    #[storage]
    struct Storage {}

    impl TokenWrapper of ITokenWrapper<ContractState> {
        fn transfer_token(
            ref self: ContractState,
            address: ContractAddress,
            sender: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) -> bool {
            let mut call_data: Array<felt252> = ArrayTrait::new();
            Serde::serialize(@sender, ref call_data);
            Serde::serialize(@recipient, ref call_data);
            Serde::serialize(@amount, ref call_data);
            let mut res = starknet::call_contract_syscall(
                address, selector!("transferFrom"), call_data.span()
            )
                .unwrap_syscall();
            Serde::<bool>::deserialize(ref res).unwrap()
        }
    }
}

Listing 99-9: A sample contract using syscalls

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 felt252. To build this array, we serialize the expected function parameters into an Array<felt252> using the Serde trait, and then pass this array as calldata. At the end, we are returned a serialized value which we'll need to deserialize ourselves!

Last change: 2023-11-04, commit: aa501bc