Multicall in Solidity: CALL vs DELEGATECALL Explained

Multicall is a smart contract pattern in Ethereum and other EVM-compatible blockchains that allows bundling multiple function calls into a single transaction. Each function call is therefore performed atomically.

By aggregating several calls in the same transaction, this pattern offers several advantages:

On-chain perspective:

  • Lower gas costs since the base fee is paid only once
  • All transactions are performed in the same block, mitigating frontrunning in certain cases.
  • Allowing custom logic in the function call

Off-chain perspective (Front-end/Backend)

  • Lowering API calls to RPC providers.
  • Faster loading of front-end, because multiple information get fetched in one on-chain read call.

[TOC]

Base concept

How Multicall Works

  1. A user submits multiple function calls to a specific multicall contract or a contract implementing multicall (see OpenZeppelin implementation).
  2. The multicall contract sequentially executes these function calls.
  3. The results of each call are collected and returned in a single response.
  4. If any call fails (depending on implementation), the entire transaction may revert or continue execution.

Different type of calls

There are two types of accounts in Ethereum: Externally Owned Accounts (EOAs) and Contract Accounts. EOAs are controlled by private keys, and Contract Accounts are controlled by code.

When an EOA calls a contract, the msg.sender value during execution of the call provides the address of that EOA. This is also true if the call (call) was executed by a contract.

A smart contract can perform several different type of calls

When a CALL is executed, the context changes. New context means storage operations will be performed on the called contract, with a new value (i.e. msg.value) and a new caller (i.e. msg.sender).

See also RareSkills - Low Level Call vs High Level Call in Solidity

  • staticcall opcode

staticcall is a variant of call, but it does not allow state modifications. If a function tries to modify state, staticcall will revert.

See RareSkills - Staticcall

Contrary to call, perform a delegatecallwon’t change the context of the call. This means the contract being delegatecalled will see the same msg.sender, the same msg.value, and operate on the same storage as the calling contract. This is very powerful, but can also be dangerous.

It’s important to note that you cannot directly delegatecall from an EOA—an EOA can only call a contract, not delegatecall it.

Summary tab

Feature call staticcall delegatecall
State Change ✅ Yes ❌ No ✅ Yes (in caller’s storage)
Execution Context Target contract Target contract Caller’s contract
Uses msg.sender Target contract’s Target contract’s Caller’s
Allows Ether Transfer ✅ Yes ❌ No ✅ Yes

Implementation

There are two main ways to implement and perform a multicall

  • The most recent one, by OpenZeppelin, consists to include a function multicall in your contract which allows a sender to perform a multicall on this contract. This present the advantage to keep msg.senderas the contract caller
  • The first version of multicallallows to perform severall calls on different contracts. For a write call, it means that the context will change apart if the contract multicallis called through a delegatecall which is only possible for a smart contract. EOA can then not performed a write call if the value of msg.senderor msg.valueis important.

Summary

  OpenZeppelin multicall External contract multicall
Opcode used delegateCall call
Write call for EOA
Read call for EOA
Write call from a contract
Read call from a contract
Work with all contract

OpenZeppelin multicall

Code: OpenZeppelin - Multicall.sol doc v5

Abstract contract with a utility to allow batching together multiple calls on the same contract in a single transaction. Useful for allowing EOAs to perform multiple operations at once.

Contrary to multicall3, it works only on a specific smart contract which extends the library.

Provides a function to batch together multiple calls in a single external call.

  • Consider any assumption about calldata validation performed by the sender may be violated if it’s not especially
  • careful about sending transactions invoking {multicall}. For example, a relay address that filters function
  • selectors won’t filter calls nested within a {multicall} operation.

NOTE: Since 5.0.1 and 4.9.4, this contract identifies non-canonical contexts (i.e. msg.sender is not {Context-_msgSender}).

  • If a non-canonical context is identified, the following self delegatecall appends the last bytes of msg.datato the subcall. This makes it safe to use with {ERC2771Context}.
  • Contexts that don’t affect the resolution of {Context-_msgSender} are not propagated to subcalls.

Version vulnerable if used with ERC-2771

function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
        results = new bytes[](data.length);
        for (uint256 i = 0; i < data.length; i++) {
            results[i] = Address.functionDelegateCall(address(this), data[i]);
        }
        return results;
    }

Version 5.2.0

abstract contract Multicall is Context {
    /**
     * @dev Receives and executes a batch of function calls on this contract.
     * @custom:oz-upgrades-unsafe-allow-reachable delegatecall
     */
    function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
        bytes memory context = msg.sender == _msgSender()
            ? new bytes(0)
            : msg.data[msg.data.length - _contextSuffixLength():];

        results = new bytes[](data.length);
        for (uint256 i = 0; i < data.length; i++) {
            results[i] = Address.functionDelegateCall(address(this), bytes.concat(data[i], context));
        }
        return results;
    }
}

Example from OpenZeppelin doc:

// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol";

contract Box is Multicall {
    function foo() public {
        // ...
    }

    function bar() public {
        // ...
    }
}

This is how to call the multicall function using Ethers.js, allowing foo and bar to be called in a single transaction:

// scripts/foobar.js

const instance = await ethers.deployContract("Box");

await instance.multicall([
    instance.interface.encodeFunctionData("foo"),
    instance.interface.encodeFunctionData("bar")
]);
Past vulnerability

Any contract implementing both Multicall and ERC-2771 is vulnerable to address spoofing. In the context of the OpenZeppelin contracts library, this can be done with Multicall and ERC2771Context. An attacker can wrap malicious calldata within a forwarded request and use Multicall’s delegatecall feature to manipulate the _msgSender() resolution in the subcalls.

Schema from OpenZeppelin post-mortem:

openzeppelin-multicall-erc2771

Reference: Arbitrary Address Spoofing Attack: ERC2771Context Multicall Public Disclosure


Multicall3

Initial project by MakerDAO (archive): makerdao/multicall

Multicall3 (and multicall 1 & 2) mds1/multicall3

Multicall3 is a fork from the library multicall, a project initiated by MakerDAO.

Multicall3 has two main use cases:

  • Aggregate results from multiple contract reads into a single JSON-RPC request.
  • Execute multiple state-changing calls in a single transaction.
Deprecated version

multicall is the original contract, and Multicall2 added support for handling failed calls in a multicall. Multicall3 is recommended over these because it’s backwards-compatible with both, cheaper to use, adds new methods, and is deployed on more chains

  • multicall (original version): this version aggregates results from multiple read-only function calls

  • Multicall2 is the same as Multicall, but provides addition functions that allow calls within the batch to fail.

Call multicall from a smart contract VS EOA

Since an EOA cannot perfor a delegatecall, this significantly reduces the benefit of calling Multicall3 from an EOA—any calls the Multicall3 executes will have the MultiCall3 address as the msg.sender. This means you should only call Multicall3 from an EOA if the msg.sender does not matter.

If you are using a contract wallet or executing a call to Multicall3 from another contract, you can either CALL or DELEGATECALL.

  • CALL will behave the same as described above for the EOA case
  • delegatecalls will preserve the context.

This means if you delegatecall to Multicall3 from a contract, the msg.sender of the calls executed by Multicall3 will be that contract. This can be very useful, and is how the Gnosis Safe Transaction Builder works to batch calls from a Safe.

Similarly, because msg.value does not change with a delegatecall, you must be careful relying on msg.value within a multicall.

To learn more about this, see here and here.

Schema

Made with the help of ChatGPT and plantUML

EOA -> multicall

  • Inside Multicall3msg.sender = EOA
  • Inside TargetContractmsg.sender = Multicall3

emulticall-eoa

Contract -> multicall with delegatecall

Inside Multicall3 (executing as ContractA)msg.sender = EOA

Inside TargetContractmsg.sender = ContractA

multicall-contract-delegatecall

Contract -> multicall with call

  • Inside Multicall3msg.sender = ContractA
  • Inside TargetContractmsg.sender = Multicall3

multicall-contract-call


Uniswap V3 multicall

Code: github.com/Uniswap - Multicall.sol

Reference: docs.uniswap.org/contracts - overview, docs.uniswap.org - periphery/base/Multicall

A multicallcontract is available as a periphery contract. The periphery is a constellation of smart contracts designed to support domain-specific interactions with the core. As the Uniswap protocol is a permissionless system, the contracts described below have no special privileges and are only a small subset of possible periphery-like contracts.

Enables calling multiple methods in a single call to the contract

Difference with openzeppelin multicall:

  • Revert if one call fails
  • Store the result of each call in the array result
  • Code is older than OpenZeppelin and use a very old solidity version (0.7.6)

Note

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity =0.7.6;
pragma abicoder v2;
abstract contract Multicall is IMulticall {
    /// @inheritdoc IMulticall
    function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) {
        results = new bytes[](data.length);
        for (uint256 i = 0; i < data.length; i++) {
            (bool success, bytes memory result) = address(this).delegatecall(data[i]);

            if (!success) {
                // Next 5 lines from https://ethereum.stackexchange.com/a/83577
                if (result.length < 68) revert();
                assembly {
                    result := add(result, 0x04)
                }
                revert(abi.decode(result, (string)));
            }

            results[i] = result;
        }
    }
}

Note:

  • This code has been written before the introduction of custom error(0.8.0). so I am not sure if it sill correct.
  • This code must not be combined with ERC-2271 forwarder since it does not patch the vulnerability contrary to OpenZeppelin multicall
  • Check result length
if (result.length < 68) revert();

If result.length is less than 68, then the transaction failed silently (without a revert message)

  • Slice the signature hash
 assembly {
     result := add(result, 0x04)
 }

From ethereum.stackexchange - How can I get the revert reason of a call in Solidity so that I can use it in the same on-chain transaction? (2020)


ERC-6357: Single-contract Multi-delegatecall

ERC specification, Ethereum magicians

This EIP standardizes an interface containing a single function, multicall, allowing EOAs to call multiple functions of a smart contract in a single transaction, and revert all calls if any call fails.

pragma solidity ^0.8.0;

interface IMulticall {
    /// @notice           Takes an array of abi-encoded call data, delegatecalls itself with each calldata, and returns the abi-encoded result
    /// @dev              Reverts if any delegatecall reverts
    /// @param    data    The abi-encoded data
    /// @returns  results The abi-encoded return values
    function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results);

    /// @notice           OPTIONAL. Takes an array of abi-encoded call data, delegatecalls itself with each calldata, and returns the abi-encoded result
    /// @dev              Reverts if any delegatecall reverts
    /// @param    data    The abi-encoded data
    /// @param    values  The effective msg.values. These must add up to at most msg.value
    /// @returns  results The abi-encoded return values
    function multicallPayable(bytes[] calldata data, uint256[] values) external payable virtual returns (bytes[] memory results);
}

Rationale

multicallPayable is optional because it isn’t always feasible to implement, due to the msg.value splitting.

Reference Implementation

pragma solidity ^0.8.0;
/// Derived from OpenZeppelin's implementation
abstract contract Multicall is IMulticall {
    function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
        results = new bytes[](data.length);
        for (uint256 i = 0; i < data.length; i++) {
            (bool success, bytes memory returndata) = address(this).delegatecall(data);
            require(success);
            results[i] = returndata;
        }
        return results;
    }
}

Reference

You might also enjoy