Cross contract calls
In smart contract development, cross contract calls refer to the ability of one smart contract to invoke functions or methods of another smart contract. This feature is essential for building complex decentralized applications (dApps) that require interaction between multiple contracts.
To perform a cross-contract call, you must define an Interface—a Move module that describes the target contract’s signatures without implementing their logic.
Defining the Interface
Using the ERC-20 standard as an example, we define an interface module. This module acts as a “header file” that tells the compiler how to encode arguments and decode results for the target contract.
module erc20call::erc20call;
use stylus::contract_calls::{ContractCallResult, CrossContractCall};
#[ext(external_call)]
public struct ERC20(CrossContractCall) has drop;
public fun new(configuration: CrossContractCall): ERC20 {
ERC20(configuration)
}
#[ext(external_call(view))]
public native fun total_supply(self: &ERC20): ContractCallResult<u256>;
#[ext(external_call(view))]
public native fun balance_of(self: &ERC20, account: address): ContractCallResult<u256>;
#[ext(external_call)]
public native fun transfer(self: &ERC20, account: address, amount: u256): ContractCallResult<bool>;
#[ext(external_call(view))]
public native fun allowance(self: &ERC20, owner: address, spender: address): ContractCallResult<u256>;
#[ext(external_call)]
public native fun approve(self: &ERC20, spender: address, amount: u256): ContractCallResult<bool>;
#[ext(external_call)]
public native fun transfer_from(self: &ERC20, sender: address, recipient: address, amount: u256): ContractCallResult<bool>;
The CrossContractCall Struct
The CrossContractCall struct is a key component that facilitates cross contract calls. It encapsulates the necessary information and configuration required to perform these calls. When you create an instance of the ERC20 struct, you pass in a CrossContractCall configuration that specifies how to interact with the target contract.
The struct used to represent the external contract (e.g., ERC20) must follow these rules:
-
It must be annotated with the
#[ext(external_call)]attribute. -
It must be a tuple struct containing only a field of type
CrossContractCall. -
It must have the
dropability.
Creating a New Instance
To create a new instance of the cross contract call struct, you need to provide a CrossContractCall configuration. This configuration specifies the address of the target contract and any additional settings required for the cross contract calls.
-
new(address): This function creates a newCrossContractCallinstance with the specified contract address. It initializes the configuration with default values for gas and value. -
gas(u64): Amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one. -
value(u256): Value in WEI to send to the account. -
delegate(): This function configures the cross contract call to be a delegate call.
let erc20 = erc20call::new(
ccc::new(erc20_address)
.gas(100000)
.value(0)
);
Defining the Functions
Since the actual code lives on another contract, your interface functions are declared as native. The compiler automatically generates the underlying WASM code to perform the call based on these signatures. Each cross contract call function must follow the following requirements:
-
It must take a reference to your interface struct (e.g.,
&ERC20) as first argument, followed by the actual function arguments. -
It must return one of the following types:
ContractCallResult<T>whereTis the return type of the target function in the called contract. This wrapper type is used to handle potential errors that may occur during the cross contract call.ContractCallEmptyResultif the target function in the called contract does not return any value.
-
It must be annotated with the
#[ext(external_call)]attribute. You can also add function modifiers such asvieworpureto indicate that the function does not modify the state. This will hint the compiler to optimize the call accordingly by using a static call instead of a common one.
Note
The cross contact calls follow the same ABI rules as the regular functions. i.e: If the target function contains a
IDparameter, the type for that parameter will bebytes32.
ContractCallResult<T> struct
The ContractCallResult<T> struct is a generic wrapper type used to encapsulate the result of a cross contract call. It provides methods to handle the result and potential errors that may occur during the call:
-
succeded: Returnstrueif the cross contract call was successful, otherwise returnsfalse.let result: ContractCallResult<u256> = erc20.balance_of(address); if (result.succeded()) { let balance: u256 = result.get_result(); // Use the balance } else { // Handle the error } -
get_result: Returns the actual result of the cross contract call if it was successful. If the call failed, this method will abort the transaction.let result: ContractCallResult<u256> = erc20.total_supply(); let total_supply: u256 = result.get_result();
ContractCallEmptyResult struct
The ContractCallEmptyResult struct is used for cross contract calls that do not return any value. It provides a method to check if the call was successful:
-
succeded: Returnstrueif the cross contract call was successful, otherwise returnsfalse.let result: ContractCallEmptyResult = cross_contract_call.call_some_function(123); if (result.succeded()) { // Approval succeeded } else { // Handle the error }
Using Cross Contract Calls
To use the cross contract calls defined in the erc20call module, you need to create an instance of the ERC20 struct with the appropriate CrossContractCall configuration. Then, you can invoke the methods defined in the struct to interact with the target ERC-20 contract.
module book::erc20user;
use erc20call::erc20call::{Self};
use stylus::contract_calls as ccc;
entry fun balance_of_erc20(erc20_address: address, balance_address: address): u256 {
let erc20 = erc20call::new(ccc::new(erc20_address));
erc20.balance_of(balance_address).get_result()
}
entry fun total_supply(erc20_address: address): u256 {
let erc20 = erc20call::new(ccc::new(erc20_address));
erc20.total_supply().get_result()
}
Delegated Calls
In addition to direct cross contract calls, Move also supports delegated calls. A delegated call allows a contract to execute code in the context of another contract, effectively allowing it to “borrow” the functionality of that contract. This is useful for scenarios where you want to extend the functionality of an existing contract without modifying its code.
To illustrate how delegated calls work, let’s define four moodules:
-
delegated_counter: A simple contract that maintains a counter and provides functions to increment and get the counter value via delegate calls. -
delegated_counter_interface: An interface module that defines the cross contract call structure for thedelegated_countercontract (just like we did with the ERC-20 at the begginning of this chapter). -
counter_logic_a: A contract that contains logic to increment the counter by 1. -
counter_logic_b: A contract that contains logic to increment the counter by 2.
counter’s functions will be just proxy function using the delegate calls to call the logic defined in counter_logic_a and counter_logic_b.
Warning
Once a
CrossContractCallobject is configure to peform delegate calls, it cannot be undone and all the calls will be delegated.
Counter Module
module book::delegated_counter;
use stylus::{
tx_context::TxContext,
object::{Self, UID},
transfer::{Self},
contract_calls::{Self}
};
use book::delegated_counter_interface as dci;
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Create a new counter.
entry fun create(contract_logic: address, ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: ctx.sender(),
value: 25,
contract_address: contract_logic,
});
}
/// Increment a counter.
entry fun increment(counter: &mut Counter) {
let delegated_counter = dci::new(
contract_calls::new(counter.contract_address)
.delegate()
);
let res = delegated_counter.increment(&mut counter.id);
assert!(res.succeded(), 33);
}
/// Read counter.
entry fun read(counter: &Counter): u64 {
counter.value
}
/// Change the address where the delegated calls are made.
entry fun change_logic(counter: &mut Counter, logic_address: address) {
counter.contract_address = logic_address;
}
In the increment function, we create a CrossContractCall object configured for delegate calls using the delegate() method. We then call the increment function defined in the logic contract.
Delegated Counter Interface Module
module book::delegated_counter_interface;
use stylus::{
contract_calls::{ContractCallEmptyResult, CrossContractCall},
object::UID
};
#[ext(external_call)]
public struct CounterCall(CrossContractCall) has drop;
public fun new(configuration: CrossContractCall): CounterCall {
CounterCall(configuration)
}
#[ext(external_call)]
public native fun increment(self: &CounterCall, counter: &mut UID): ContractCallEmptyResult;
Counter Logic A Module
module book::delegated_counter_logic_a;
use stylus::{
tx_context::TxContext,
object::UID
};
#[ext(external_struct(module_name = b"delegated_counter", address = @0x0))]
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Increment a counter by 1.
entry fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}
Counter Logic B Module
module book::delegated_counter_logic_b;
use stylus::{
tx_context::TxContext,
object::UID
};
#[ext(external_struct(module_name = b"delegated_counter", address = @0x0))]
public struct Counter has key {
id: UID,
owner: address,
value: u64,
contract_address: address,
}
/// Increment a counter by 2.
entry fun increment(counter: &mut Counter) {
counter.value = counter.value + 2;
}
Once you have defined these modules, you should deploy the delegated_counter_logic_a and delegated_counter_logic_b modules. Then, when creating a new counter using the create function in the delegated_counter module, you can specify which logic contract to use for incrementing the counter.
Later, you can change the logic contract by calling the change_logic function, allowing you to switch between different incrementing behaviors dynamically.
Warning
Since delegated calls execute code in the context of the calling contract, that means that the calling contract’s storage is the one that is modified.
When interacting with objects from a caller conntrct, you must specify the module name and address where the object is defined using the
#[ext(external_struct(module_name = ..., address = ...))]attribute. That is because objects with the same name can be defined in different modules. Those objects are different by definition and the Move compiler needs to know which one to use.If you don’t specify the module name and address, the object you are trying to interact will not be found and a runtime error will be thrown.