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:
- The Contract Dispatcher
IERC20Dispatcher
- 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.
Para desglosar efectivamente los conceptos en este capítulo, utilizaremos la interfaz IERC20 del capítulo anterior (consulte la Lista 9-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
}
}
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.
También es digno de mención que todo esto se abstrae detrás de escena, gracias al poder de los complementos de Cairo.
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)
}
}
}
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
.
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()
}
}
}
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_syscall
and 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()
}
}
}
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!