Multicall: The Key to Gas Optimization

Multicall: The Key to Gas Optimization

A multicall contract 📞 is a smart contract that accepts multiple function calls as inputs and executes them together. A developer can use the multicall contract as a proxy to call functions in other contracts.

A proxy in this case, refers to the multicall contract itself. It acts as an intermediary between the user and the target contracts they want to interact with.

How Multicall Contracts Work 🛠️

  • Single Transaction Efficiency: Instead of initiating individual calls to different contracts, users submit a single transaction to the multicall contract, which contains details about the functions to be executed.

  • Batched Execution and Aggregation: The multicall contract then efficiently executes these functions on the respective target contracts and aggregates the results into a unified response.

Benefits of Multicall Contracts💡

  • Gas Cost Reduction: Bundling multiple function calls into a single transaction significantly reduces gas costs, enhancing cost-effectiveness for users.

  • Network Optimization: Multicall contracts minimize network overhead by consolidating multiple requests into one, leading to improved efficiency and scalability.

  • Data Consistency: By executing all functions within a single transaction, multicall contracts ensure atomicity, thereby maintaining data consistency across involved contracts.

Types of multicall contracts: 🔄

There have been reviews and improvements on the multicall contract over the years, below are the three we have at the time of writing this article.

MultiCall: This contract made use of theaggregate function, it executes each call sequentially and returns the block number and an array of return data.

MultiCall 2: Adds a tryAggregate function that provides an option to handle failed calls without reverting the entire transaction.

MultiCall 3: Introduces the aggregate3 function for aggregating calls with the option to revert if any call fails, similar to tryAggregate but with more control.

Deep Dive

In this article, we will be focusing on the codebase for Multicall3, which encompasses an improved version of Multicall and Multicall2.
Check out a link to the official Multicall GitHub repository: here

Aggregate()

    struct Call {
        address target;
        bytes callData;
    }
    struct Result {
        bool success;
        bytes returnData;
    }

    function aggregate(Call[] calldata calls) public payable returns (uint256 blockNumber, bytes[] memory returnData) {
        blockNumber = block.number;
        uint256 length = calls.length;
        returnData = new bytes[](length);
        Call calldata call;
        for (uint256 i = 0; i < length;) {
            bool success;
            call = calls[i];
            (success, returnData[i]) = call.target.call(call.callData);
            require(success, "Multicall3: call failed");
            unchecked { ++i; }
        }
    }

Code Explanation

  • blockNumber = block.number; : Record the current block number

  • uint256 length = calls.length;: This line retrieves the length of the calls array, which indicates the number of function calls to be aggregated.

  • returnData = new bytes[](length);: This line initializes a dynamic array called returnData with a length equal to the number of function calls. This array will store the return data from each function call.

  • for (uint256 i = 0; i < length;) { ... }: This loop iterates over each call in the calls array.

  • (success, returnData[i]) = call.target.call(call.callData);: Inside the loop, this line executes the function call specified by the target and callData fields of the current call struct. It captures the success status of the call in the success variable and the return data in the returnData array.

  • require(success, "Multicall3: call failed");: This line ensures that the function call was successful. If the call fails (i.e., success is false), the function reverts with an error message.

  • unchecked { ++i; }: This line increments the loop counter i to move to the next call in the calls array.

Usage of aggregate() in BalanceChecker()

In the below contract, we are going to use aggregate function to retrieve the balances of multiple addresses in a single transaction:

contract BalanceChecker is IStruct {
    IMulticall3 public multicall;
    address public tokenAddress;

    event BalanceChecked(address indexed user, uint256 balance);

    constructor(address _multicallAddress, address _tokenAddress) {
        multicall = IMulticall3(_multicallAddress);
        tokenAddress = _tokenAddress;
    }

    function getTokenBalances(
        address[] memory addresses
    ) external returns (uint256[] memory) {
        Call[] memory calls = new Call[](addresses.length);
        for (uint256 i = 0; i < addresses.length; i++) {
            calls[i] = Call(
                tokenAddress,
                abi.encodeWithSignature("balanceOf(address)", addresses[i])
            );
        }

        (, bytes[] memory returnData) = multicall.aggregate(calls);

        uint256[] memory balances = new uint256[](returnData.length);
        for (uint256 i = 0; i < returnData.length; i++) {
            balances[i] = abi.decode(returnData[i], (uint256));
            emit BalanceChecked(addresses[i], balances[i]);
        }

        return balances;
    }
}

getTokenBalances():

  • This function takes an array of addresses as input parameters. These addresses represent the users for whom the token balances will be retrieved.

  • Inside the function, a dynamic array of Call structs named calls is created. Each Call struct contains information about the function call to be executed. In this case, it's the balanceOf function of the ERC20 token contract for each user address.

  • A loop iterates over each address in the addresses array.
    For each address, a Call struct is created and added to the calls array. The abi.encodeWithSignature function is used to encode the function call data for the balanceOf function.

  • After all the function calls are prepared, the aggregate function is called with the array of calls. This function aggregates the results of all the function calls into a single transaction.

  • The aggregate function returns two values: the block number (which is ignored in this case) and an array of bytes containing the return data from each function call.

  • Another loop iterates over the returnData array, decoding each entry to extract the token balances of the corresponding user address.

  • For each user’s balance, an BalanceChecked event is emitted, containing the user's address and their token balance.

  • Finally, the function returns an array (balances) containing the token balances corresponding to the input addresses.

tryAggregate()


    struct Call {
        address target;
        bytes callData;
    }
    struct Result {
        bool success;
        bytes returnData;
    }

    function tryAggregate(bool requireSuccess, Call[] calldata calls) public payable returns (Result[] memory returnData) {
        uint256 length = calls.length;
        returnData = new Result[](length);
        Call calldata call;
        for (uint256 i = 0; i < length;) {
            Result memory result = returnData[i];
            call = calls[i];
            (result.success, result.returnData) = call.target.call(call.callData);
            if (requireSuccess) require(result.success, "Multicall3: call failed");
            unchecked { ++i; }
        }
    }

The tryAggregate function in the Multicall3 contract provides flexibility in handling the success of individual function calls. Here's an explanation of how it works and how it's used in the contract:

  1. Function Overview: The tryAggregate function is designed to aggregate the results of multiple function calls, similar to the aggregate function. However, it includes an additional parameter requireSuccess, which allows callers to specify whether all calls must succeed for the function to proceed.

  2. Input Parameters:

  • requireSuccess: A boolean parameter indicating whether all function calls must succeed (true) or not (false).

  • calls: An array of Call structs containing the target addresses and call data for each function call.

3. returnData: An array of Result structs, each containing the success status and return data of the corresponding function call.

4. Function Execution:

  • The function iterates through each call in the calls array.
    For each call, it executes the call using the target address and callData.

  • If requireSuccess is true, it checks whether the call was successful. If not, it reverts with an error message.
    It then populates the returnData array with the success status and returns data for each call.

5. Usage in the Contract:

  • In the Multicall3 contract, the tryAggregate function is primarily used when callers need to aggregate function calls but don't necessarily require all calls to succeed.

  • This function provides more flexibility compared to aggregate, as it allows callers to handle individual call failures according to their specific requirements.

Usage of tryAggregate() in BalanceChecker2()

In the below contract, we have also used tryAggregate toretrieve the balances of multiple addresses:


contract BalanceChecker2 is IStruct {
    IMulticall3 public multicall;
    address public tokenAddress;

    event BalanceChecked(address indexed user, uint256 balance);

    constructor(address _multicallAddress, address _tokenAddress) {
        multicall = IMulticall3(_multicallAddress);
        tokenAddress = _tokenAddress;
    }

    function getTokenBalancesWithTryAggregate(
        address[] memory addresses
    ) public returns (uint256[] memory) {
        Call[] memory calls = new Call[](addresses.length);
        for (uint256 i = 0; i < addresses.length; i++) {
            calls[i] = Call(
                tokenAddress,
                abi.encodeWithSignature("balanceOf(address)", addresses[i])
            );
        }

        IMulticall3.Result[] memory results = multicall.tryAggregate(
            true,
            calls
        );

        uint256[] memory balances = new uint256[](results.length);
        for (uint256 i = 0; i < results.length; i++) {
            require(results[i].success, "BalanceChecker2: Call failed");
            balances[i] = abi.decode(results[i].returnData, (uint256));
            emit BalanceChecked(addresses[i], balances[i]);
        }

        return balances;
    }
}

1. getTokenBalancesWithTryAggregate Function:

  • Users provide an array of wallet addresses (addresses) they want to check.

  • The function returns an array of balances corresponding to the provided addresses.

2. Using tryAggregate:

  • We passed an array of calls and a boolean parameter true into tryAggregate to indicate that all calls must succeed.

  • tryAggregate() allows handling of individual call failures without reverting the entire transaction.

3. Processing Results:

  • After calling tryAggregate(), the contract receives an array of Result structs.

  • For each result, the contract checks if the call was successful. If a call fails, it reverts with an error message.

  • If the call succeeds, the contract decodes the return data to extract the wallet balance.

aggregate3()

    struct Call3 {
        address target;
        bool allowFailure;
        bytes callData;
    }

    struct Call3Value {
        address target;
        bool allowFailure;
        uint256 value;
        bytes callData;
    }

    struct Result {
        bool success;
        bytes returnData;
    }

    function aggregate3(Call3[] calldata calls) public payable returns (Result[] memory returnData) {
        uint256 length = calls.length;
        returnData = new Result[](length);
        Call3 calldata calli;
        for (uint256 i = 0; i < length;) {
            Result memory result = returnData[i];
            calli = calls[i];
            (result.success, result.returnData) = calli.target.call(calli.callData);
            assembly {
                if iszero(or(calldataload(add(calli, 0x20)), mload(result))) {
                    mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000)
                    mstore(0x04, 0x0000000000000000000000000000000000000000000000000000000000000020)
                    mstore(0x24, 0x0000000000000000000000000000000000000000000000000000000000000017)
                    mstore(0x44, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000)
                    revert(0x00, 0x64)
                }
            }
            unchecked { ++i; }
        }
    }

The aggregate3 function in the Multicall3 contract is designed to aggregate multiple function calls while ensuring that each call returns success. This function is considered both efficient and safe because it handles call failures appropriately and reverts the entire transaction if any call fails when failure is not allowed.

  1. The aggregate3 function takes an array of Call3 structs as input, and it's payable since it might need to transfer ether to some of the called contracts. It returns an array of Result structs.

  2. The function initializes some local variables:

  • length: This variable stores the length of the input array calls, representing the number of function calls to be aggregated.

  • returnData: This array will store the results of each function call. It's initialized with a size equal to the length of the input array calls.

3. The function enters a for loop that iterates over each call in the input array calls. The loop variable i is used to index the array.

4. Inside the loop, the function retrieves the i-th call from the input array and assigns it to the calli variable. Then, it invokes the call function on the target address with the provided call data (calli.target.call(calli.callData)).

5. The result of the function call is stored in a Result struct. The success field indicates whether the call was successful, and the returnData field contains the return value or error message.

6. After each function call, the function checks whether the call was successful. If the call fails and failure is not allowed, the function reverts the entire transaction using assembly code. It constructs an error message and reverts with it.

7. Finally, the loop variable i is incremented using unchecked { ++i; }, and the loop continues until all function calls have been processed.

This aggregate3 function ensures that all function calls are executed, and if any call fails unexpectedly, it reverts the entire transaction to maintain the integrity of the contract's state. It's considered safe because it handles call failures appropriately and prevents partial execution

Usage of aggregate3() in BalanceChecker3()

contract BalanceChecker3 is IStruct {
    IMulticall3 public multicall;
    address public tokenAddress;

    event BalanceChecked(address indexed user, uint256 balance);

    constructor(address _multicallAddress, address _tokenAddress) {
        multicall = IMulticall3(_multicallAddress);
        tokenAddress = _tokenAddress;
    }

    function getTokenBalancesWithAggregate3(
        address[] memory addresses
    ) public returns (uint256[] memory) {
        IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](
            addresses.length
        );
        for (uint256 i = 0; i < addresses.length; i++) {
            calls[i] = Call3(
                tokenAddress,
                true,
                abi.encodeWithSignature("balanceOf(address)", addresses[i])
            );
        }

        IMulticall3.Result[] memory results = multicall.aggregate3(calls);

        uint256[] memory balances = new uint256[](results.length);
        for (uint256 i = 0; i < results.length; i++) {
            require(results[i].success, "BalanceChecker3: Call failed");
            balances[i] = abi.decode(results[i].returnData, (uint256));
            emit BalanceChecked(addresses[i], balances[i]);
        }

        return balances;
    }
}
  1. getTokenBalancesWithAggregate3 Function: Users provide an array of wallet addresses they want to check.
    The function returns an array of balances corresponding to the provided addresses.

2. Preparing Calls:

  • Inside getTokenBalancesWithAggregate3, the contract prepares an array of Call3 structs.

  • Each Call3 struct represents a call to the balanceOf function of the ERC20 token contract for a specific wallet address.

  • The allowFailure field is set to true, allowing individual calls to fail without reverting the entire transaction.

3. Aggregate3 Call: The contract invokes the aggregate3() function from the Multicall contract, passing the array of Call3 structs.

4. Processing Results:

  • After calling aggregate3(), the contract receives an array of Result structs (results), each representing the outcome of a call.

  • For each result, the contract checks if the call was successful. If a call fails, it reverts with an error message.

  • If the call succeeds, the contract decodes the return data to extract the wallet balance.

In summary, BalanceChecker3 uses the aggregate3() function to efficiently retrieve balances for multiple wallet addresses. It ensures that all calls succeed and handles individual call failures gracefully.

Deeper Into Multicall


contract DoubleCallBalanceChecker {
    IMulticall3 public multicall;
    address public vickishTKN;
    address public seyiTKN;


    event TokenBalanceChecked(
        address indexed user,
        uint256 indexed balanceVickishTKN,
        uint256 indexed balanceSeyiTKN
    );

    constructor(address _multicallAddress, address _vickishTKN, address _seyiTKN) {
        multicall = IMulticall3(_multicallAddress);
        vickishTKN = _vickishTKN;
        seyiTKN = _seyiTKN;
    }

    function getTokenBalances(address[] calldata users) external returns (uint256[][] memory) {
        IMulticall3.Call[] memory callsA = new IMulticall3.Call[](users.length);
        IMulticall3.Call[] memory callsB = new IMulticall3.Call[](users.length);

        for (uint256 i = 0; i < users.length; i++) {
            callsA[i] = IMulticall3.Call(
                vickishTKN,
                abi.encodeWithSignature("balanceOf(address)", users[i])
            );

            callsB[i] = IMulticall3.Call(
                seyiTKN,
                abi.encodeWithSignature("balanceOf(address)", users[i])
            );
        }

        IMulticall3.Result[] memory resultsA = multicall.tryAggregate(true, callsA);
        IMulticall3.Result[] memory resultsB = multicall.tryAggregate(true, callsB);

        // Extract the balances from the results
        uint256[][] memory balances = new uint256[][](2);
        balances[0] = new uint256[](users.length);
        balances[1] = new uint256[](users.length);

        for (uint256 i = 0; i < users.length; i++) {
            require(resultsA[i].success && resultsB[i].success, "DoubleCallBalanceChecker: Call failed");

            balances[0][i] = abi.decode(resultsA[i].returnData, (uint256));
            balances[1][i] = abi.decode(resultsB[i].returnData, (uint256));
            emit TokenBalanceChecked(users[i], balances[0][i], balances[1][i]);
        }

        return balances;
    }
}

Get Token Balances Function:

The getTokenBalances function serves as the core functionality of the contract, enabling users to retrieve token balances for multiple addresses efficiently. Here’s how it works:

  • Users provide an array of addresses (users) for which they want to retrieve token balances.

  • The function prepares two arrays of calls, one for each token contract, by encoding the balanceOf function calls for each user.

  • It executes tryAggregate twice, once for each token contract, to aggregate the function calls into a single transaction.

  • The function extracts the balance results from the returned data of the tryAggregate calls and populates a 2D array (balances) with the token balances for each user and each token contract.

  • For each user, the function emits the TokenBalanceChecked event, providing the user’s address along with their token balances for vickishTKN and seyiTKN.

  • Finally, the function returns the balances array, containing the token balances for all users across both token contracts.

In the above DoubleCallBalanceChecker contract, we are making two separate calls to the tryAggregate function, one for each token contract. Each call aggregates the function calls for its respective token contract.

While calling tryAggregate twice might seem counterintuitive at first glance, it still results in gas savings compared to making individual calls to each token contract separately. This is because aggregating multiple function calls into a single transaction generally reduces gas costs, even if we make multiple calls to the tryAggregate function. Here’s why it can still save gas:

  • Reduced Overhead: Aggregating multiple calls into one transaction reduces the overhead associated with sending multiple transactions. This includes the base cost of sending a transaction and the overhead of accessing the Ethereum network.

  • Batching Efficiency: Although we are making two separate calls to tryAggregate, each call still aggregates multiple function calls into one transaction. This batching of function calls within each tryAggregate call can lead to gas savings compared to making individual calls for each user and each token contract separately.

Conclusion

The Multicall contract provides an efficient solution for aggregating multiple function calls in Ethereum smart contracts. 🚀 By allowing batch processing of function calls, these contracts reduce gas costs, minimize blockchain congestion, and optimize on-chain data retrieval. 📉

Whether it’s retrieving token balances, querying blockchain data, or executing complex logic involving multiple contract interactions, Multicall contracts offer a streamlined approach that significantly improves the efficiency and cost-effectiveness of smart contract operations. 🎯

As the blockchain continues to evolve and scale, tools and techniques like Multicall contracts will play an increasingly vital role in optimizing blockchain applications, making them more scalable, affordable, and user-friendly for developers and end-users alike. 🌐

Additional Resources 📚

Thanks for reading!

📖 I hope this article has provided valuable insights into the power and efficiency of Multicall contracts in Ethereum smart contract development. 🛠️ If you have any questions or want to continue the conversation, feel free to connect with me on my socials:

Let’s stay connected and explore more about blockchain, smart contracts, and decentralized applications together! 🌐