Multicall in Solidity


In a recent project, we needed a feature called Multicall, which lets you run several functions of a contract in just one transaction. I knew about this because I’d seen it in Uniswap V3 Periphery code. So, I basically copied their Multicall code, made some tweaks for compatibility, and it worked like a charm.

Here’s the Multicall.sol file from Uniswap V3 Periphery:

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity =0.7.6;
pragma abicoder v2;

import '../interfaces/IMulticall.sol';

/// @title Multicall
/// @notice Enables calling multiple methods in a single call to the contract
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;
        }
    }
}

The multicall function is quite simple. It takes an array of bytes called data and processes each element in the array one after the other. For each element, it uses address(this).delegatecall(data[i]) to carry out the function call within the same contract. It then verifies if each call was successful, and if any of them weren’t, it cancels the entire transaction using revert.

After integrating Multicall into the main contract, I started to revamp the existing functions within the contract. Instead of bundling multiple functions like transferFrom, submit, and sweepToken into one, I separated them into three distinct functions: transferFrom, submit, and sweepToken. Now, you can execute all three of these functions in a single transaction, thanks to Multicall. This change introduced a plethora of new possibilities and enhanced the flexibility of our contract code.

When I started writing tests for each contract, everything went smoothly.

The primary distinction between our contract code and V3 Periphery was the use of ETH to store a proxy fee in our contract each time a user invoked the multicall function. In contrast, V3 Periphery does not employ ETH storage within its contracts. Additionally, we incorporated a withdrawAdmin function to facilitate the retrieval of the stored ETH within our contract.

It’s worth noting that in V3 Periphery, any remaining ETH in the contracts can be reclaimed by anyone through the refundETH function. This ensures transparency and accessibility in handling any residual ETH.

The Problem

This seemingly minor distinction gave rise to a critical vulnerability within our contract. The persistence of msg.value and msg.sender inside each delegatecall turned out to be a significant issue.

Let’s consider a scenario in which a user intends to submit 1 ETH to the Lido contract in exchange for stETH. However, the contract currently holds 5 ETH as a proxy fee, which can only be withdrawn using the withdrawAdmin function.

Here’s the submitLido function:

    /**
     * @notice Transfers ETH to the Lido protocol to get ST_ETH
     * @return stethAmount Amount of ST_ETH token that is being transferred to the recipient
     */
    function submitLido()
        external
        payable
        returns (uint256 stethAmount)
    {
        // Get StETH by paying ETH
        stethAmount = steth.submit{value: msg.value}(msg.sender);
    }

The vulnerability arises when a user repeatedly calls the submitLido function six times, with the same 1 ETH as the transaction value. Due to the persistence of msg.value in each delegatecall, the caller effectively utilizes the 5 ETH stored within the contract to their advantage. Consequently, when the caller subsequently invokes the sweepToken function to sweep stETH tokens from the proxy contract, they end up with stETH tokens equivalent to 6 ETH in value. This exploit takes advantage of the stored proxy fee, allowing the user to amass a greater amount of stETH tokens than initially intended.

The Solution

Indeed, this scenario may not occur frequently, but it highlights a potential vulnerability in the contract. Upon reviewing OpenZeppelin’s Multicall and finding no immediate solution, conducting further research was a prudent step. It became evident that implementing a mechanism akin to OpenZeppelin’s ReentrancyGuard contract would be essential to safeguard against such exploits and ensure the contract’s security. This proactive approach helps fortify the contract and prevents unforeseen vulnerabilities from being exploited in the future.

So this is the final Multicall contract having a guard against ETH reuse:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;

/**
 * @title Handles multicall function
 */
contract Multicall {
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status;

    /**
     * @notice Thrown when the nonETHReuse modifier is called twice in the multicall
     */
    error EtherReuseGuardCall();

    /**
     * @dev Prevents a caller from calling multiple functions that work with ETH in a transaction
     */
    modifier nonETHReuse() {
        _nonReuseBefore();
        _;
    }

    /**
     * @notice Sets status to NOT_ENTERED
     */
    constructor() payable {
        _status = NOT_ENTERED;
    }

    /**
     * @notice Multiple calls on proxy functions
     * @param _calldata An array of calldata that is called one by one
     */
    function multicall(bytes[] calldata _calldata) external payable {
        // Unlock ether locker just in case if it was locked before
        unlockETHReuse();

        // Loop through each calldata and execute them
        for (uint256 i = 0; i < _calldata.length;) {
            (bool success, bytes memory result) = address(this).delegatecall(_calldata[i]);

            // Check if the call was successful or not
            if (!success) {
                // Next 7 lines from https://ethereum.stackexchange.com/a/83577
                if (result.length < 68) revert();

                assembly {
                    result := add(result, 0x04)
                }

                revert(abi.decode(result, (string)));
            }

            // Increment variable i more efficiently
            unchecked {
                ++i;
            }
        }

        /*
         * To ensure proper execution, unlock reusability for future use.
         * In some cases, the caller might invoke a function with the 'nonETHReuse'
         * modifier directly, bypassing the 'unlockETHReuse' step at the beginning of the
         * multicall. This would render the function unusable if not unlocked here.
         */
        unlockETHReuse();
    }

    /**
     * @notice Unlocks the reentrancy
     * @dev Should be used before and after all the calls
     */
    function unlockETHReuse() internal {
        _status = NOT_ENTERED;
    }

    function _nonReuseBefore() private {
        // On the first call to nonETHReuse, _status will be NOT_ENTERED
        if (_status == ENTERED) {
            revert EtherReuseGuardCall();
        }

        // Any calls to nonETHReuse after this point will fail
        _status = ENTERED;
    }
}

To address the vulnerability, a crucial step involves initializing the _status variable to NOT_ENTERED before the execution of the multicall’s for loop. This state indicates that no ETH function has been invoked yet.

Furthermore, the submitLido function should be equipped with the nonETHReuse modifier, which verifies whether the _status is set to ENTERED or not. With this modifier in place, any subsequent calls to submitLido will result in a revert, as the _status is already marked as ENTERED. This modification effectively prevents the exploitation of the contract by disallowing multiple calls to submitLido when the status is already in use, ensuring the contract’s security.

Indeed, to mitigate the vulnerability effectively, it’s essential to consistently apply the nonETHReuse modifier to functions that interact with msg.value.

This precautionary measure helps safeguard the contract’s integrity and security. Failing to apply this modifier to such functions could potentially lead to severe consequences, emphasizing the critical importance of thorough and diligent code review and testing to ensure the modifier is consistently used where necessary.

The introduction of the nonETHReuse modifier to protect against reentrancy vulnerabilities, as described, is indeed a robust security measure. However, this approach may limit the usability of functions outside the context of the multicall. Since it’s the Multicall contract that unlocks the _status before the actual call, other functions may not be accessible unless invoked through the multicall.

While this restriction enhances security, it’s crucial to carefully consider the trade-offs between security and usability in your specific application. You may want to assess whether the additional protection provided by the modifier is worth the inconvenience of limited usability in certain scenarios. Finding the right balance between security and functionality is a key aspect of designing robust smart contracts.

The downside

There is a trade-off with this approach: users will need to call the multicall for every transaction associated with functions featuring the nonETHReuse modifier. This necessity arises from the fact that after a direct call to these functions, the _status is set to ENTERED, causing subsequent direct calls to revert. It’s important to note that checking the _status before executing a transaction is not a practical solution, as it can be vulnerable to front-running, where malicious actors exploit transaction order to their advantage.

Conclusion

In conclusion, addressing vulnerabilities in smart contract development is a nuanced and evolving process. While there may not be a one-size-fits-all solution, comprehending the intricacies of a vulnerability opens the door to innovative ideas tailored to the unique codebase and context of your project.

By recognizing and comprehending vulnerabilities, you empower yourself to explore various strategies that may lead to even more effective solutions than those initially conceived. The key is to remain proactive, adaptable, and collaborative, leveraging your understanding of the vulnerability as a springboard for developing robust and secure smart contracts.

Furthermore, I highly recommend checking out Samczsun’s insightful article titled “Two Rights Might Make a Wrong” In this article, Samczsun shares their experiences grappling with a similar issue and offers valuable insights into how they successfully addressed it. Exploring their approach can provide additional perspective and guidance on handling vulnerabilities in smart contract development, reinforcing the importance of sharing knowledge and experiences within our community.

I’d like to express my heartfelt gratitude to Pashov for their invaluable assistance in improving and refining this contract. Their insights and contributions have played a pivotal role in enhancing the contract’s functionality and security.