Maia DAO Ecosystem - Koolex's results

Efficient liquidity renting and management across chains with Curvenized Uniswap V3.

General Information

Platform: Code4rena

Start Date: 30/05/2023

Pot Size: $300,500 USDC

Total HM: 79

Participants: 101

Period: about 1 month

Judge: Trust

Total Solo HM: 36

Id: 242

League: ETH

Maia DAO Ecosystem

Findings Distribution

Researcher Performance

Rank: 2/101

Findings: 8

Award: $19,221.86

Analysis:
grade-b

🌟 Selected for report: 5

πŸš€ Solo Findings: 3

Findings Information

🌟 Selected for report: Koolex

Labels

bug
3 (High Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
H-04

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1061-L1085

Vulnerability details

Impact

Context:

anyFallback method is called by Anycall Executor on the source chain in case of a failure of anyExecute on the root chain. The user has to pay for the execution gas cost for this, this is done at the end of the call. However, if there is not enough depositedGas, the anyFallback will be reverted due to a revert caused by the Anycall Executor. This shouldn't happen since the depositor in the first place deposited at least MIN_FALLBACK_RESERVE (185_000).

Here is the calculation for the gas used when anyFallback is called

	//Save gas
	uint256 gasLeft = gasleft();

	//Get Branch Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

	//Check if sufficient balance
	if (minExecCost > getDeposit[_depositNonce].depositedGas) {
		_forceRevert();
		return;
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1063-L1072

_forceRevert will withdraw all execution budget.

// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget" if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}

So Anycall Executor will revert if there is not enough budget. This is done at

	uint256 budget = executionBudget[_from];
	require(budget > totalCost, "no enough budget");
	executionBudget[_from] = budget - totalCost;

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L206C42-L206C58

(1) Gas Calculation in our anyFallback & in AnyCall contracts:

To calculate how much the user has to pay, the following formula is used:

	//Get Branch Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

Gas units are calculated as follows:

  • Store gasleft() at initialGas at the beginning of anyFallback method
	//Get Initial Gas Checkpoint
	uint256 initialGas = gasleft();

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1233-L1234

  • Nearly at the end of the method, deduct gasleft() from initialGas. This covers everything between initial gas checkpoint and end gas checkpoint.
        //Save gas
        uint256 gasLeft = gasleft();

        //Get Branch Environment Execution Cost
        uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1063-L1066

  • Add MIN_FALLBACK_RESERVE which is 185_000.

This overhead is supposed to cover:

  • 100_000 for anycall. This is extra cost required by Anycall
Line:38	
uint256 constant EXECUTION_OVERHEAD = 100000;
	.
	.
Line:203	
uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

  • 85_000 for our fallback execution. For example, to cover the modifier requiresExecutor and to cover everthing after the end gas checkpoint.

If we check how much this would actually cost, we can find it nearly 70_000. So, 85_000 is safe enough. A PoC is also provided to prove this. However, there is an overhead gas usage in the Anycall contracts that's not considered which is different than 100_000 extra that's required by AnyCall anyway (see above).

This means that the user is paying less than the actual cost. According to the sponsor, Bridge Agent deployer deposits first time into anycallConfig where the goal is to replenish the execution budget after use every time. The issue leads to:

  1. execution budget is decreasing over time (slow draining) in case it has funds already.

  2. anyExecute call will fail since the calculation of the gas used in the Anycall contracts is bigger than the minimum reserve. In Anycall, this is done by the modifier chargeDestFee

    • modifier chargeDestFee
    	modifier chargeDestFee(address _from, uint256 _flags) {
    	if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
    		uint256 _prevGasLeft = gasleft();
    		_;
    		IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
    	} else {
    		_;
    	}
    }

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L163-L171

    • function chargeFeeOnDestChain
    	function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft)
    		external
    		onlyAnycallContract
    	{
    		if (!_isSet(mode, FREE_MODE)) {
    			uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
    			uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium);
    			uint256 budget = executionBudget[_from];
    			require(budget > totalCost, "no enough budget");
    			executionBudget[_from] = budget - totalCost;
    			_feeData.accruedFees += uint128(totalCost);
    		}
    	}

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

The gas consumption of anyExec method called by the MPC (in AnyCall) here

    function anyExec(
        address _to,
        bytes calldata _data,
        string calldata _appID,
        RequestContext calldata _ctx,
        bytes calldata _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags) // <= starting from here
        onlyMPC
    {
		.
		.
		.
		bool success = _execute(_to, _data, _ctx, _extdata);
		.
		.
   }

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L276

The gas is nearly 110_000. It is not taken into account. (proven in the PoCs)

(2) Base Fee & Input Data Fee:

From Ethereum yellow paper:

Gtransaction 21000 Paid for every transaction Gtxdatazero 4 Paid for every zero byte of data or code for a transaction. Gtxdatanonzero 16 Paid for every non-zero byte of data or code for a transaction.

So

  1. We have 21_000 as a base fee. This should be taken into account. However, it is paid by AnyCall, since the TX is sent by MPC. So, we are fine here. Probably this explains the overhead (100_000) added by anycall

  2. Because anyFallback method has bytes data to be passed, we have extra gas consumption which is not taken into account. For every zero byte => 4 For every non-zero byte => 16

So generally speaking, the bigger the data is, the bigger the gas becomes. you can simply prove this by adding arbitrary data to anyFallback method in PoC#1 test below. and you will see the gas spent increases.

Summary
  1. MIN_FALLBACK_RESERVE is safe enough (without considering anyExec method. check next point).
  2. The gas consumed by anyExec method called by the MPC is not considered.
  3. Input data fee isn't taken into account.

There are two PoCs proving the first two points above. The third point can be proven by simply adding arbitrary data to anyFallback method in PoC#1 test.

Note: this is also applicable for RootBridgeAgent which I avoided writing a separate issue for it since the code of _payFallbackGas is almost the same. However. those 3 statements don’t exist in RootBridgeAgent._payFallbackGas

	//Withdraw Gas
	IPort(localPortAddress).withdraw(address(this), address(wrappedNativeToken), minExecCost);

	//Unwrap Gas
	wrappedNativeToken.withdraw(minExecCost);

	//Replenish Gas
	_replenishGas(minExecCost);

So, the gas spent is even less. and 55_000 (from 155_000 MIN_FALLBACK_RESERVE of RootBridgeAgent) is safe enough. but, the second two points are still not taken into account in RootBridgeAgent as well (see above).

Proof of Concept

PoC#1 MIN_FALLBACK_RESERVE is safe enough

Note: (estimation doesn't consider anyExec method's actual cost).

Overview

This PoC is independent from the codebase (but uses the same code). There are two contracts simulating BranchBridgeAgent.anyFallback.

  1. BranchBridgeAgent which has the code of pre 1st gas checkpoint and post last gas checkpoint.
  2. BranchBridgeAgentEmpty which has the code of pre 1st gas checkpoint and post last gas checkpoint commented out.

We run the same test for both, the difference in gas is what’s at least nearly the minimum required to cover pre 1st gas checkpoint and post last gas checkpoint. In this case here it is 70090 which is smaller than 85_000. So, we are fine.

Here is the output of the test:

[PASS] test_calcgas() (gas: 143835)
Logs:
  branchBridgeAgent.anyFallback Gas Spent => 71993

[PASS] test_calcgasEmpty() (gas: 73734)
Logs:
  branchBridgeAgentEmpty.anyFallback Gas Spent => 1903

Test result: ok. 2 passed; 0 failed; finished in 2.08ms

71993-1903 = 70090

Explanation

BranchBridgeAgent.anyFallback method depends on the following external calls:

  1. AnycallExecutor.context()
  2. AnycallProxy.config()
  3. AnycallConfig.executionBudget()
  4. AnycallConfig.withdraw()
  5. AnycallConfig.deposit()
  6. WETH9.withdraw()
  7. BranchPort.withdraw()

For this reason, I've copied the same code from multichain-smart-contracts. For WETH9, I've used the contract from the codebase which has minimal code. For BranchPort, copied from the codebase. For libraries, unused methods were removed, this is because I couldn't submit the report, it gave error too long body. However, it doesn't effect the gas spent

Please note that:

  • tx.gasprice is replaced with a fixed value in _payFallbackGas method as it is not available in Foundry.
  • In _replenishGas, reading the config via IAnycallProxy(localAnyCallAddress).config() is replaced with Immediate call for simplicity. In other words, avoiding proxy to make the PoC simpler and shorter. However, if done with proxy the gas used would increase. So in both ways, it is in favor of the PoC.
The coded PoC
  • Foundry.toml
	[profile.default]
	solc = '0.8.17'
	src = 'solidity'
	test = 'solidity/test'
	out = 'out'
	libs = ['lib']
	fuzz_runs = 1000
	optimizer_runs = 10_000
  • .gitmodules
	[submodule "lib/ds-test"]
		path = lib/ds-test
		url = https://github.com/dapphub/ds-test
		branch = master
	[submodule "lib/forge-std"]
		path = lib/forge-std
		url = https://github.com/brockelmore/forge-std
		branch = master
  • remappings.txt
	ds-test/=lib/ds-test/src
	forge-std/=lib/forge-std/src
  • Test File
// PoC => Maia OmniChain: gasCalculation for anyFallback in BranchBridgeAgent
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";

// copied from https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC20.sol
// only decimals is used
abstract contract ERC20 {
    string public name;

    string public symbol;

    uint8 public immutable decimals;
    constructor(string memory _name, string memory _symbol, uint8 _decimals) {
        name = _name;
        symbol = _symbol;
        decimals = _decimals;
    }


}

// copied from Solady 
// removed unused methods, because I couldn't submit the report with too long code
library SafeTransferLib {
    /// @dev The ETH transfer has failed.
    error ETHTransferFailed();

    /// @dev The ERC20 `transferFrom` has failed.
    error TransferFromFailed();

    /// @dev The ERC20 `transfer` has failed.
    error TransferFailed();

    /// @dev The ERC20 `approve` has failed.
    error ApproveFailed();

    /// @dev Suggested gas stipend for contract receiving ETH
    /// that disallows any storage writes.
    uint256 internal constant _GAS_STIPEND_NO_STORAGE_WRITES = 2300;

    /// @dev Suggested gas stipend for contract receiving ETH to perform a few
    /// storage reads and writes, but low enough to prevent griefing.
    /// Multiply by a small constant (e.g. 2), if needed.
    uint256 internal constant _GAS_STIPEND_NO_GRIEF = 100000;


    /// @dev Sends `amount` (in wei) ETH to `to`.
    /// Reverts upon failure.
    ///
    /// Note: This implementation does NOT protect against gas griefing.
    /// Please use `forceSafeTransferETH` for gas griefing protection.
    function safeTransferETH(address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(gas(), to, amount, 0, 0, 0, 0)) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
        }
    }

    

    function safeTransferFrom(
        address token,
        address from,
        address to,
        uint256 amount
    ) internal {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.

            mstore(0x60, amount) // Store the `amount` argument.
            mstore(0x40, to) // Store the `to` argument.
            mstore(0x2c, shl(96, from)) // Store the `from` argument.
            // Store the function selector of `transferFrom(address,address,uint256)`.
            mstore(0x0c, 0x23b872dd000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x60, 0) // Restore the zero slot to zero.
            mstore(0x40, m) // Restore the free memory pointer.
        }
    }

  

    /// @dev Sends `amount` of ERC20 `token` from the current contract to `to`.
    /// Reverts upon failure.
    function safeTransfer(address token, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, to) // Store the `to` argument.
            mstore(0x34, amount) // Store the `amount` argument.
            // Store the function selector of `transfer(address,uint256)`.
            mstore(0x00, 0xa9059cbb000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }


}

/// copied from (https://github.com/vectorized/solady/blob/main/src/utils/SafeCastLib.sol)
library SafeCastLib {

    error Overflow();


    function toUint128(uint256 x) internal pure returns (uint128) {
        if (x >= 1 << 128) _revertOverflow();
        return uint128(x);
    }


    function toInt8(int256 x) internal pure returns (int8) {
        int8 y = int8(x);
        if (x != y) _revertOverflow();
        return y;
    }

    

    function toInt128(int256 x) internal pure returns (int128) {
        int128 y = int128(x);
        if (x != y) _revertOverflow();
        return y;
    }


    function toInt256(uint256 x) internal pure returns (int256) {
        if (x >= 1 << 255) _revertOverflow();
        return int256(x);
    }

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                      PRIVATE HELPERS                       */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    function _revertOverflow() private pure {
        /// @solidity memory-safe-assembly
        assembly {
            // Store the function selector of `Overflow()`.
            mstore(0x00, 0x35278d12)
            // Revert with (offset, size).
            revert(0x1c, 0x04)
        }
    }
}

interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

interface IAnycallConfig {
    function calcSrcFees(
        address _app,
        uint256 _toChainID,
        uint256 _dataLength
    ) external view returns (uint256);

    function executionBudget(address _app) external view returns (uint256);

    function deposit(address _account) external payable;

    function withdraw(uint256 _amount) external;
}

interface IAnycallProxy {
    function executor() external view returns (address);

    function config() external view returns (address);

    function anyCall(
        address _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;

    function anyCall(
        string calldata _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;
}

contract WETH9 {
    string public name = "Wrapped Ether";
    string public symbol = "WETH";
    uint8 public decimals = 18;

    event Approval(address indexed src, address indexed guy, uint256 wad);
    event Transfer(address indexed src, address indexed dst, uint256 wad);
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // function receive() external payable {
    //   deposit();
    // }

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(
        address src,
        address dst,
        uint256 wad
    ) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != 255) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

contract AnycallExecutor {
    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    constructor() {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;
    }
}

contract AnycallV7Config {
    event Deposit(address indexed account, uint256 amount);

    mapping(address => uint256) public executionBudget;

    /// @notice Deposit native currency crediting `_account` for execution costs on this chain
    /// @param _account The account to deposit and credit for
    function deposit(address _account) external payable {
        executionBudget[_account] += msg.value;
        emit Deposit(_account, msg.value);
    }
}

// IBranchPort interface
interface IPort {
    /*///////////////////////////////////////////////////////////////
                            VIEW FUNCTIONS
    //////////////////////////////////////////////////////////////*/
    /**
     * @notice Returns true if the address is a Bridge Agent.
     *   @param _bridgeAgent Bridge Agent address.
     *   @return bool.
     */
    function isBridgeAgent(address _bridgeAgent) external view returns (bool);

    /**
     * @notice Returns true if the address is a Strategy Token.
     *   @param _token token address.
     *   @return bool.
     */
    function isStrategyToken(address _token) external view returns (bool);

    /**
     * @notice Returns true if the address is a Port Strategy.
     *   @param _strategy strategy address.
     *   @param _token token address.
     *   @return bool.
     */
    function isPortStrategy(
        address _strategy,
        address _token
    ) external view returns (bool);

    /**
     * @notice Returns true if the address is a Bridge Agent Factory.
     *   @param _bridgeAgentFactory Bridge Agent Factory address.
     *   @return bool.
     */
    function isBridgeAgentFactory(
        address _bridgeAgentFactory
    ) external view returns (bool);

    /*///////////////////////////////////////////////////////////////
                          PORT STRATEGY MANAGEMENT
    //////////////////////////////////////////////////////////////*/

    /**
     * @notice Allows active Port Strategy addresses to withdraw assets.
     *     @param _token token address.
     *     @param _amount amount of tokens.
     */
    function manage(address _token, uint256 _amount) external;

    /**
     * @notice allow approved address to repay borrowed reserves with reserves
     *     @param _amount uint
     *     @param _token address
     */
    function replenishReserves(
        address _strategy,
        address _token,
        uint256 _amount
    ) external;

    /*///////////////////////////////////////////////////////////////
                          hTOKEN MANAGEMENT
    //////////////////////////////////////////////////////////////*/

    /**
     * @notice Function to withdraw underlying / native token amount into Port in exchange for Local hToken.
     *   @param _recipient hToken receiver.
     *   @param _underlyingAddress underlying / native token address.
     *   @param _amount amount of tokens.
     *
     */
    function withdraw(
        address _recipient,
        address _underlyingAddress,
        uint256 _amount
    ) external;

    /**
     * @notice Setter function to increase local hToken supply.
     *   @param _recipient hToken receiver.
     *   @param _localAddress token address.
     *   @param _amount amount of tokens.
     *
     */
    function bridgeIn(
        address _recipient,
        address _localAddress,
        uint256 _amount
    ) external;

    /**
     * @notice Setter function to increase local hToken supply.
     *   @param _recipient hToken receiver.
     *   @param _localAddresses token addresses.
     *   @param _amounts amount of tokens.
     *
     */
    function bridgeInMultiple(
        address _recipient,
        address[] memory _localAddresses,
        uint256[] memory _amounts
    ) external;

    /**
     * @notice Setter function to decrease local hToken supply.
     *   @param _localAddress token address.
     *   @param _amount amount of tokens.
     *
     */
    function bridgeOut(
        address _depositor,
        address _localAddress,
        address _underlyingAddress,
        uint256 _amount,
        uint256 _deposit
    ) external;

    /**
     * @notice Setter function to decrease local hToken supply.
     *   @param _depositor user to deduct balance from.
     *   @param _localAddresses local token addresses.
     *   @param _underlyingAddresses local token address.
     *   @param _amounts amount of local tokens.
     *   @param _deposits amount of underlying tokens.
     *
     */
    function bridgeOutMultiple(
        address _depositor,
        address[] memory _localAddresses,
        address[] memory _underlyingAddresses,
        uint256[] memory _amounts,
        uint256[] memory _deposits
    ) external;

    /*///////////////////////////////////////////////////////////////
                        ADMIN FUNCTIONS
    //////////////////////////////////////////////////////////////*/

    /**
     * @notice Adds a new bridge agent address to the branch port.
     *   @param _bridgeAgent address of the bridge agent to add to the Port
     */
    function addBridgeAgent(address _bridgeAgent) external;

    /**
     * @notice Sets the core router address for the branch port.
     *   @param _newCoreRouter address of the new core router
     */
    function setCoreRouter(address _newCoreRouter) external;

    /**
     * @notice Adds a new bridge agent factory address to the branch port.
     *   @param _bridgeAgentFactory address of the bridge agent factory to add to the Port
     */
    function addBridgeAgentFactory(address _bridgeAgentFactory) external;

    /**
     * @notice Reverts the toggle on the given bridge agent factory. If it's active, it will de-activate it and vice-versa.
     *   @param _newBridgeAgentFactory address of the bridge agent factory to add to the Port
     */
    function toggleBridgeAgentFactory(address _newBridgeAgentFactory) external;

    /**
     * @notice Reverts thfe toggle on the given bridge agent  If it's active, it will de-activate it and vice-versa.
     *   @param _bridgeAgent address of the bridge agent to add to the Port
     */
    function toggleBridgeAgent(address _bridgeAgent) external;

    /**
     * @notice Adds a new strategy token.
     * @param _token address of the token to add to the Strategy Tokens
     */
    function addStrategyToken(
        address _token,
        uint256 _minimumReservesRatio
    ) external;

    /**
     * @notice Reverts the toggle on the given strategy token. If it's active, it will de-activate it and vice-versa.
     * @param _token address of the token to add to the Strategy Tokens
     */
    function toggleStrategyToken(address _token) external;

    /**
     * @notice Adds a new Port strategy to the given port
     * @param _portStrategy address of the bridge agent factory to add to the Port
     */
    function addPortStrategy(
        address _portStrategy,
        address _token,
        uint256 _dailyManagementLimit
    ) external;

    /**
     * @notice Reverts the toggle on the given port strategy. If it's active, it will de-activate it and vice-versa.
     * @param _portStrategy address of the bridge agent factory to add to the Port
     */
    function togglePortStrategy(address _portStrategy, address _token) external;

    /**
     * @notice Updates the daily management limit for the given port strategy.
     * @param _portStrategy address of the bridge agent factory to add to the Port
     * @param _token address of the token to update the limit for
     * @param _dailyManagementLimit new daily management limit
     */
    function updatePortStrategy(
        address _portStrategy,
        address _token,
        uint256 _dailyManagementLimit
    ) external;

    /*///////////////////////////////////////////////////////////////
                            EVENTS
    //////////////////////////////////////////////////////////////*/

    event DebtCreated(
        address indexed _strategy,
        address indexed _token,
        uint256 _amount
    );
    event DebtRepaid(
        address indexed _strategy,
        address indexed _token,
        uint256 _amount
    );

    event StrategyTokenAdded(
        address indexed _token,
        uint256 _minimumReservesRatio
    );
    event StrategyTokenToggled(address indexed _token);

    event PortStrategyAdded(
        address indexed _portStrategy,
        address indexed _token,
        uint256 _dailyManagementLimit
    );
    event PortStrategyToggled(
        address indexed _portStrategy,
        address indexed _token
    );
    event PortStrategyUpdated(
        address indexed _portStrategy,
        address indexed _token,
        uint256 _dailyManagementLimit
    );

    event BridgeAgentFactoryAdded(address indexed _bridgeAgentFactory);
    event BridgeAgentFactoryToggled(address indexed _bridgeAgentFactory);

    event BridgeAgentToggled(address indexed _bridgeAgent);

    /*///////////////////////////////////////////////////////////////
                            ERRORS
    //////////////////////////////////////////////////////////////*/

    error InvalidMinimumReservesRatio();
    error InsufficientReserves();
    error UnrecognizedCore();
    error UnrecognizedBridgeAgent();
    error UnrecognizedBridgeAgentFactory();
    error UnrecognizedPortStrategy();
    error UnrecognizedStrategyToken();
}

contract BranchPort {
    using SafeTransferLib for address;

    error UnrecognizedBridgeAgent();

    /// @notice Mapping from Underlying Address to isUnderlying (bool).
    mapping(address => bool) public isBridgeAgent;

    constructor(address bridgeAgent) {
        isBridgeAgent[bridgeAgent] = true;
    }

    /// @notice Modifier that verifies msg sender is an active Bridge Agent.
    modifier requiresBridgeAgent() {
        if (!isBridgeAgent[msg.sender]) revert UnrecognizedBridgeAgent();
        _;
    }

    function withdraw(
        address _recipient,
        address _underlyingAddress,
        uint256 _deposit
    ) external virtual requiresBridgeAgent {
        _underlyingAddress.safeTransfer(
            _recipient,
            _denormalizeDecimals(_deposit, ERC20(_underlyingAddress).decimals())
        );
    }

    function _denormalizeDecimals(
        uint256 _amount,
        uint8 _decimals
    ) internal pure returns (uint256) {
        return
            _decimals == 18 ? _amount : (_amount * 1 ether) / (10 ** _decimals);
    }
}

contract BranchBridgeAgent {
    using SafeCastLib for uint256;

    enum DepositStatus {
        Success,
        Failed
    }

    struct Deposit {
        uint128 depositedGas;
        address owner;
        DepositStatus status;
        address[] hTokens;
        address[] tokens;
        uint256[] amounts;
        uint256[] deposits;
    }

    error AnycallUnauthorizedCaller();
    error GasErrorOrRepeatedTx();

    uint256 public remoteCallDepositedGas;

    uint256 internal constant MIN_FALLBACK_RESERVE = 185_000; // 100_000 for anycall + 85_000 fallback execution overhead
    // uint256 internal constant MIN_EXECUTION_OVERHEAD = 160_000; // 100_000 for anycall + 35_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Executions
    uint256 internal constant TRANSFER_OVERHEAD = 24_000;

    WETH9 public immutable wrappedNativeToken;

    AnycallV7Config public anycallV7Config;

    uint256 public accumulatedFees;

    /// @notice Local Chain Id
    uint24 public immutable localChainId;

    /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address where cross-chain requests are executed in the Root Chain.
    address public immutable rootBridgeAgentAddress;
    /// @notice Local Anyexec Address
    address public immutable localAnyCallExecutorAddress;

    /// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
    address public immutable localAnyCallAddress;

    /// @notice Address for Local Port Address where funds deposited from this chain are kept, managed and supplied to different Port Strategies.
    address public immutable localPortAddress;

    /// @notice Deposit nonce used for identifying transaction.
    uint32 public depositNonce;

    /// @notice Mapping from Pending deposits hash to Deposit Struct.
    mapping(uint32 => Deposit) public getDeposit;

    constructor() {
        AnycallExecutor anycallExecutor = new AnycallExecutor();
        localAnyCallExecutorAddress = address(anycallExecutor);

        localChainId = 1;

        wrappedNativeToken = new WETH9();

        localAnyCallAddress = address(3);

        rootBridgeAgentAddress = address(2);

        anycallV7Config = new AnycallV7Config();

        localPortAddress = address(new BranchPort(address(this)));

        getDeposit[1].depositedGas = 1 ether; // just for testing below
    }

    modifier requiresExecutor() {
        _requiresExecutor();
        _;
    }

    function _requiresExecutor() internal view {
        if (msg.sender != localAnyCallExecutorAddress)
            revert AnycallUnauthorizedCaller();
        (address from, , ) = IAnycallExecutor(localAnyCallExecutorAddress)
            .context();
        if (from != rootBridgeAgentAddress) revert AnycallUnauthorizedCaller();
    }

    function _replenishGas(uint256 _executionGasSpent) internal virtual {
        //Deposit Gas
        anycallV7Config.deposit{value: _executionGasSpent}(address(this));
        // IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
    }

    function _forceRevert() internal virtual {
        IAnycallConfig anycallConfig = IAnycallConfig(
            IAnycallProxy(localAnyCallAddress).config()
        );
        uint256 executionBudget = anycallConfig.executionBudget(address(this));

        // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
        if (executionBudget > 0)
            try anycallConfig.withdraw(executionBudget) {} catch {}
    }

    /**
     * @notice Internal function repays gas used by Branch Bridge Agent to fulfill remote initiated interaction.
     *   @param _depositNonce Identifier for user deposit attatched to interaction being fallback.
     *   @param _initialGas gas used by Branch Bridge Agent.
     */
    function _payFallbackGas(
        uint32 _depositNonce,
        uint256 _initialGas
    ) internal virtual {
        //Save gas
        uint256 gasLeft = gasleft();

        //Get Branch Environment Execution Cost
        // 1e9 for tx.gasPrice since it is zero in Foundry
        uint256 minExecCost = 1e9 *
            (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

        //Check if sufficient balance
        if (minExecCost > getDeposit[_depositNonce].depositedGas) {
            // getDeposit[1].depositedGas => 1 ether . set in the constructer above
            _forceRevert();
            return;
        }

        //Update user deposit reverts if not enough gas => user must boost deposit with gas
        getDeposit[_depositNonce].depositedGas -= minExecCost.toUint128();

        //Withdraw Gas
        IPort(localPortAddress).withdraw(
            address(this),
            address(wrappedNativeToken),
            minExecCost
        );

        //Unwrap Gas
        wrappedNativeToken.withdraw(minExecCost);

        //Replenish Gas
        _replenishGas(minExecCost);
    }

    function anyFallback(
        bytes calldata data
    )
        external
        virtual
        requiresExecutor
        returns (bool success, bytes memory result)
    {
        //Get Initial Gas Checkpoint
        uint256 initialGas = gasleft();

        /*
         *
         * Other code here
         *
         */

        // we assume that the flag was 0x01 for simplicity and since it is also irrelevant anyway
        // passing deposit nonce as 1 since it is also irrelevant
        //Deduct gas costs from deposit and replenish this bridge agent's execution budget.
        _payFallbackGas(1, initialGas);

        return (true, "");
    }

    function depositIntoWeth(uint256 amt) external {
        wrappedNativeToken.deposit{value: amt * 2}();
        // transfer half to the port
        wrappedNativeToken.transfer(localPortAddress, amt);
    }

    fallback() external payable {}
}

contract BranchBridgeAgentEmpty {
    using SafeCastLib for uint256;

    enum DepositStatus {
        Success,
        Failed
    }

    struct Deposit {
        uint128 depositedGas;
        address owner;
        DepositStatus status;
        address[] hTokens;
        address[] tokens;
        uint256[] amounts;
        uint256[] deposits;
    }

    error AnycallUnauthorizedCaller();
    error GasErrorOrRepeatedTx();

    uint256 public remoteCallDepositedGas;

    uint256 internal constant MIN_FALLBACK_RESERVE = 185_000; // 100_000 for anycall + 85_000 fallback execution overhead

    WETH9 public immutable wrappedNativeToken;

    AnycallV7Config public anycallV7Config;

    uint256 public accumulatedFees;

    /// @notice Local Chain Id
    uint24 public immutable localChainId;

    /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address where cross-chain requests are executed in the Root Chain.
    address public immutable rootBridgeAgentAddress;
    /// @notice Local Anyexec Address
    address public immutable localAnyCallExecutorAddress;

    /// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
    address public immutable localAnyCallAddress;

    /// @notice Address for Local Port Address where funds deposited from this chain are kept, managed and supplied to different Port Strategies.
    address public immutable localPortAddress;

    /// @notice Deposit nonce used for identifying transaction.
    uint32 public depositNonce;

    /// @notice Mapping from Pending deposits hash to Deposit Struct.
    mapping(uint32 => Deposit) public getDeposit;

    constructor() {
        AnycallExecutor anycallExecutor = new AnycallExecutor();
        localAnyCallExecutorAddress = address(anycallExecutor);

        localChainId = 1;

        wrappedNativeToken = new WETH9();

        localAnyCallAddress = address(3);

        rootBridgeAgentAddress = address(2);

        anycallV7Config = new AnycallV7Config();

        localPortAddress = address(new BranchPort(address(this)));

        getDeposit[1].depositedGas = 1 ether; // just for testing below
    }

    modifier requiresExecutor() {
        _requiresExecutor();
        _;
    }

    function _requiresExecutor() internal view {
        if (msg.sender != localAnyCallExecutorAddress)
            revert AnycallUnauthorizedCaller();
        (address from, , ) = IAnycallExecutor(localAnyCallExecutorAddress)
            .context();
        if (from != rootBridgeAgentAddress) revert AnycallUnauthorizedCaller();
    }

    function _replenishGas(uint256 _executionGasSpent) internal virtual {
        //Deposit Gas
        anycallV7Config.deposit{value: _executionGasSpent}(address(this));
        // IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
    }

    function _forceRevert() internal virtual {
        IAnycallConfig anycallConfig = IAnycallConfig(
            IAnycallProxy(localAnyCallAddress).config()
        );
        uint256 executionBudget = anycallConfig.executionBudget(address(this));

        // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
        if (executionBudget > 0)
            try anycallConfig.withdraw(executionBudget) {} catch {}
    }

    /**
     * @notice Internal function repays gas used by Branch Bridge Agent to fulfill remote initiated interaction.
     *   @param _depositNonce Identifier for user deposit attatched to interaction being fallback.
     *   @param _initialGas gas used by Branch Bridge Agent.
     */
    function _payFallbackGas(
        uint32 _depositNonce,
        uint256 _initialGas
    ) internal virtual {
        //Save gas
        uint256 gasLeft = gasleft();

        // comment out all the lines after end gas checkpoint for gas calc purpose

        // //Get Branch Environment Execution Cost
        // // 1e9 for tx.gasPrice since it is zero in Foundry
        // uint256 minExecCost = 1e9 * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

        // //Check if sufficient balance
        // if (minExecCost > getDeposit[_depositNonce].depositedGas) { // getDeposit[1].depositedGas => 1 ether . set in the constructer above
        //     _forceRevert();
        //     return;
        // }

        // //Update user deposit reverts if not enough gas => user must boost deposit with gas
        // getDeposit[_depositNonce].depositedGas -= minExecCost.toUint128();

        // //Withdraw Gas
        // IPort(localPortAddress).withdraw(address(this), address(wrappedNativeToken), minExecCost);

        // //Unwrap Gas
        // wrappedNativeToken.withdraw(minExecCost);

        // //Replenish Gas
        // _replenishGas(minExecCost);
    }

    function anyFallback(
        bytes calldata data
    )
        external
        virtual
        returns (
            // requiresExecutor comment out this for gas calc purpose
            bool success,
            bytes memory result
        )
    {
        //Get Initial Gas Checkpoint
        uint256 initialGas = gasleft();

        /*
         *
         * Other code here
         *
         */

        // we assume that the flag was 0x01 for simplicity and since it is also irrelevant anyway
        // passing deposit nonce as 1 since it is also irrelevant
        //Deduct gas costs from deposit and replenish this bridge agent's execution budget.
        _payFallbackGas(1, initialGas);

        // return (true, ""); // comment out this also for gas calc purpose
    }

    function depositIntoWeth(uint256 amt) external {
        wrappedNativeToken.deposit{value: amt * 2}();
        // transfer half to the port
        wrappedNativeToken.transfer(localPortAddress, amt);
    }

    fallback() external payable {}
}

contract GasCalc is DSTest, Test {
    BranchBridgeAgent branchBridgeAgent;
    BranchBridgeAgentEmpty branchBridgeAgentEmpty;

    function setUp() public {
        branchBridgeAgentEmpty = new BranchBridgeAgentEmpty();
        vm.deal(
            address(branchBridgeAgentEmpty.localAnyCallExecutorAddress()),
            100 ether
        ); // executer pays gas
        vm.deal(address(branchBridgeAgentEmpty), 200 ether);

        branchBridgeAgent = new BranchBridgeAgent();
        vm.deal(
            address(branchBridgeAgent.localAnyCallExecutorAddress()),
            100 ether
        ); // executer pays gas
        vm.deal(address(branchBridgeAgent), 200 ether);
    }

    // code after end checkpoint gasLeft not included
    function test_calcgasEmpty() public {
        // add weth balance to the agent and the port // 100 WETH for each
        branchBridgeAgentEmpty.depositIntoWeth(100 ether);

        vm.prank(address(branchBridgeAgentEmpty.localAnyCallExecutorAddress()));
        uint256 gasStart = gasleft();
        branchBridgeAgentEmpty.anyFallback(bytes(""));
        uint256 gasEnd = gasleft();
        vm.stopPrank();
        uint256 gasSpent = gasStart - gasEnd;
        console.log(
            "branchBridgeAgentEmpty.anyFallback Gas Spent => %d",
            gasSpent
        );
    }

    // code after end checkpoint gasLeft included
    function test_calcgas() public {
        // add weth balance to the agent and the port // 100 WETH for each
        branchBridgeAgent.depositIntoWeth(100 ether);

        vm.prank(address(branchBridgeAgent.localAnyCallExecutorAddress()));
        uint256 gasStart = gasleft();
        branchBridgeAgent.anyFallback(bytes(""));
        uint256 gasEnd = gasleft();
        vm.stopPrank();
        uint256 gasSpent = gasStart - gasEnd;
        console.log("branchBridgeAgent.anyFallback Gas Spent => %d", gasSpent);
    }
}

PoC#2 (The gas consumed by anyExec method in AnyCall)

Overview

We have contracts that simulating Anycall contracts:

  1. AnycallV7Config
  2. AnycallExecutor
  3. AnycallV7

The flow like this: MPC => AnycallV7 => AnycallExecutor => IApp

In the code, IApp(_to).anyFallback is commented out because we don't want to calculate its gas since it is done in PoC#1. We also set isFallback to true, but the increased gas for this is negligible anyway.

Here is the output of the test:

[PASS] test_gasInanycallv7() (gas: 102640)
Logs:
  anycallV7.anyExec Gas Spent => 110920

Test result: ok. 1 passed; 0 failed; finished in 1.58ms
coded PoC
// PoC => Maia OmniChain: gasCalculation for anyFallback in AnyCall v7  contracts
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";

/// IAnycallConfig interface of the anycall config
interface IAnycallConfig {
    function checkCall(
        address _sender,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags
    ) external view returns (string memory _appID, uint256 _srcFees);

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view;

    function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft) external;
}

/// IAnycallExecutor interface of the anycall executor
interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

/// IApp interface of the application
interface IApp {
    /// (required) call on the destination chain to exec the interaction
    function anyExecute(bytes calldata _data)
        external
        returns (bool success, bytes memory result);

    /// (optional,advised) call back on the originating chain if the cross chain interaction fails
    /// `_data` is the orignal interaction arguments exec on the destination chain
    function anyFallback(bytes calldata _data)
        external
        returns (bool success, bytes memory result);
}

library AnycallFlags {
    // call flags which can be specified by user
    uint256 public constant FLAG_NONE = 0x0;
    uint256 public constant FLAG_MERGE_CONFIG_FLAGS = 0x1;
    uint256 public constant FLAG_PAY_FEE_ON_DEST = 0x1 << 1;
    uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;

    // exec flags used internally
    uint256 public constant FLAG_EXEC_START_VALUE = 0x1 << 16;
    uint256 public constant FLAG_EXEC_FALLBACK = 0x1 << 16;
}

contract AnycallV7Config {
    uint256 public constant PERMISSIONLESS_MODE = 0x1;
    uint256 public constant FREE_MODE = 0x1 << 1;

    mapping(string => mapping(address => bool)) public appExecWhitelist;
    mapping(string => bool) public appBlacklist;

    uint256 public mode;

    uint256 public minReserveBudget;

    mapping(address => uint256) public executionBudget;

    constructor() {
         mode = PERMISSIONLESS_MODE;
    }

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view {
        require(!appBlacklist[_appID], "blacklist");

        if (!_isSet(mode, PERMISSIONLESS_MODE)) {
            require(appExecWhitelist[_appID][_to], "no permission");
        }

        if (!_isSet(mode, FREE_MODE)) {
            require(
                executionBudget[_from] >= minReserveBudget,
                "less than min budget"
            );
        }
    }

    function _isSet(
        uint256 _value,
        uint256 _testBits
    ) internal pure returns (bool) {
        return (_value & _testBits) == _testBits;
    }
}

contract AnycallExecutor {
    bytes32 public constant PAUSE_ALL_ROLE = 0x00;

    event Paused(bytes32 role);
    event Unpaused(bytes32 role);

    modifier whenNotPaused(bytes32 role) {
        require(
            !paused(role) && !paused(PAUSE_ALL_ROLE),
            "PausableControl: paused"
        );
        _;
    }
    mapping(bytes32 => bool) private _pausedRoles;
    mapping(address => bool) public isSupportedCaller;


    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    function paused(bytes32 role) public view virtual returns (bool) {
        return _pausedRoles[role];
    }

    modifier onlyAuth() {
        require(isSupportedCaller[msg.sender], "not supported caller");
        _;
    }

    constructor(address anycall) {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;

        isSupportedCaller[anycall] = true;
    }

function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }

    // @dev `_extdata` content is implementation based in each version
    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata /*_extdata*/
    )
        external
        virtual
        onlyAuth
        whenNotPaused(PAUSE_ALL_ROLE)
        returns (bool success, bytes memory result)
    {
        bool isFallback = _isSet(_flags, AnycallFlags.FLAG_EXEC_FALLBACK) || true; // let it fallback

        context = Context({
            from: _from,
            fromChainID: _fromChainID,
            nonce: _nonce
        });

        if (!isFallback) { 
            // we skip calling anyExecute since it is irrelevant for this PoC
            (success, result) = IApp(_to).anyExecute(_data);
        } else {
             // we skip calling anyExecute since it is irrelevant for this PoC
            // (success, result) = IApp(_to).anyFallback(_data);
        }

        context = Context({from: address(0), fromChainID: 0, nonce: 0});
    }
}

contract AnycallV7 {

    event LogAnyCall(
        address indexed from,
        address to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );
    event LogAnyCall(
        address indexed from,
        string to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );

    event LogAnyExec(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bool success,
        bytes result
    );


    event StoreRetryExecRecord(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bytes data
    );

    // Context of the request on originating chain
    struct RequestContext {
        bytes32 txhash;
        address from;
        uint256 fromChainID;
        uint256 nonce;
        uint256 flags;
    }
    address public mpc;

    bool public paused;

    // applications should give permission to this executor
    address public executor;

    // anycall config contract
    address public config;

    mapping(bytes32 => bytes32) public retryExecRecords;
    bool public retryWithPermit;

    mapping(bytes32 => bool) public execCompleted;
    uint256 nonce;

    uint256 private unlocked;

    modifier lock() {
        require(unlocked == 1, "locked");
        unlocked = 0;
        _;
        unlocked = 1;
    }
    /// @dev Access control function
    modifier onlyMPC() {
        require(msg.sender == mpc, "only MPC");
        _;
    }

    /// @dev pausable control function
    modifier whenNotPaused() {
        require(!paused, "paused");
        _;
    }

    function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }


    /// @dev Charge an account for execution costs on this chain
    /// @param _from The account to charge for execution costs
    modifier chargeDestFee(address _from, uint256 _flags) {
        if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
            uint256 _prevGasLeft = gasleft();
            _;
            IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
        } else {
            _;
        }
    }

    constructor(address _mpc) {
        unlocked = 1; // needs to be unlocked initially
        mpc = _mpc;
        config = address(new AnycallV7Config());
        executor = address(new AnycallExecutor(address(this)));
    }

    /// @notice Calc unique ID
    function calcUniqID(
        bytes32 _txhash,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encode(_txhash, _from, _fromChainID, _nonce));
    }

    function _execute(
        address _to,
        bytes memory _data,
        RequestContext memory _ctx,
        bytes memory _extdata
    ) internal returns (bool success) {
        bytes memory result;

        try
            IAnycallExecutor(executor).execute(
                _to,
                _data,
                _ctx.from,
                _ctx.fromChainID,
                _ctx.nonce,
                _ctx.flags,
                _extdata
            )
        returns (bool succ, bytes memory res) {
            (success, result) = (succ, res);
        } catch Error(string memory reason) {
            result = bytes(reason);
        } catch (bytes memory reason) {
            result = reason;
        }

        emit LogAnyExec(
            _ctx.txhash,
            _ctx.from,
            _to,
            _ctx.fromChainID,
            _ctx.nonce,
            success,
            result
        );
    }

    /**
        @notice Execute a cross chain interaction
        @dev Only callable by the MPC
        @param _to The cross chain interaction target
        @param _data The calldata supplied for interacting with target
        @param _appID The app identifier to check whitelist
        @param _ctx The context of the request on originating chain
        @param _extdata The extension data for execute context
    */
   // Note: changed from callback to memory so we can call it from the test contract
    function anyExec(
        address _to,
        bytes memory _data,
        string memory _appID,
        RequestContext memory _ctx, 
        bytes memory _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags)
        onlyMPC
    {
        IAnycallConfig(config).checkExec(_appID, _ctx.from, _to);
        bytes32 uniqID = calcUniqID(
            _ctx.txhash,
            _ctx.from,
            _ctx.fromChainID,
            _ctx.nonce
        );
        require(!execCompleted[uniqID], "exec completed");

        bool success = _execute(_to, _data, _ctx, _extdata);
        // success = false on purpose, because when it is true, it consumes less gas. so we are considering worse case here

        // set exec completed (dont care success status)
        execCompleted[uniqID] = true;

        if (!success) {
            if (_isSet(_ctx.flags, AnycallFlags.FLAG_ALLOW_FALLBACK)) { 
                // this will be executed here since the call failed
                // Call the fallback on the originating chain
                nonce++;
                string memory appID = _appID; // fix Stack too deep
                emit LogAnyCall(
                    _to,
                    _ctx.from,
                    _data,
                    _ctx.fromChainID,
                    AnycallFlags.FLAG_EXEC_FALLBACK |
                        AnycallFlags.FLAG_PAY_FEE_ON_DEST, // pay fee on dest chain
                    appID,
                    nonce,
                    ""
                );
            } else {
                // Store retry record and emit a log
                bytes memory data = _data; // fix Stack too deep
                retryExecRecords[uniqID] = keccak256(abi.encode(_to, data));
                emit StoreRetryExecRecord(
                    _ctx.txhash,
                    _ctx.from,
                    _to,
                    _ctx.fromChainID,
                    _ctx.nonce,
                    data
                );
            }
        }
    }
}

contract GasCalcAnyCallv7 is DSTest, Test {


    AnycallV7 anycallV7;
    address mpc = vm.addr(7);
    function setUp() public {
        anycallV7 = new AnycallV7(mpc);
    }

    function test_gasInanycallv7() public {

        vm.prank(mpc);
        AnycallV7.RequestContext memory ctx = AnycallV7.RequestContext({
            txhash:keccak256(""),
            from:address(0),
            fromChainID:1,
            nonce:1,
            flags:AnycallFlags.FLAG_ALLOW_FALLBACK
        });
        uint256 gasStart_ = gasleft();
        anycallV7.anyExec(address(0),bytes(""),"1",ctx,bytes(""));
        uint256 gasEnd_ = gasleft();
        vm.stopPrank();
        uint256 gasSpent_ = gasStart_ - gasEnd_;
        console.log("anycallV7.anyExec Gas Spent => %d", gasSpent_);
    }
}

Tools Used

Manual analysis

Increase the MIN_FALLBACK_RESERVE by 115_000 to consider anyExec method in AnyCall. So MIN_FALLBACK_RESERVE becomes 300_000 instead of 185_000.

Additionally, calculate the gas consumption of the input data passed, add it to the cost. This should be done when the call was made in the first place.

Note: I suggest that the MIN_FALLBACK_RESERVE should be configurable/changeable. After launching OmniChain for some time, collect stats about the actual gas used for AnyCall on chain then adjust it accordingly. This also keeps you in the safe side in case any changes are applied on AnyCall contracts in future since it is upgradable.

Assessed type

Other

#0 - c4-judge

2023-07-10T09:29:48Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T09:29:52Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T16:37:10Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-sponsor

2023-07-12T16:37:16Z

0xBugsy marked the issue as disagree with severity

#4 - 0xBugsy

2023-07-12T16:38:57Z

We should add premium() uint256 to match their gas cost calculation totalCost = gasUsed * (tx.gasprice + _feeData.premium) and abide by it since these are the calculation under which we will be charged in execution budget

#5 - trust1995

2023-07-24T13:20:23Z

Unless there is additional reasoning to why impact is reduced, HIGH seems appropriate.

#6 - c4-judge

2023-07-25T11:26:23Z

trust1995 marked the issue as selected for report

#7 - c4-sponsor

2023-07-27T19:05:10Z

0xBugsy marked the issue as sponsor acknowledged

#8 - 0xBugsy

2023-07-28T13:16:20Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

#9 - c4-sponsor

2023-07-28T13:19:23Z

0xBugsy marked the issue as sponsor confirmed

Findings Information

🌟 Selected for report: Koolex

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-15

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1029-L1054

Vulnerability details

Impact

In _payExecutionGas , there is the following code:

///Save gas left
	uint256 gasLeft = gasleft();
	.
	.
	. 
	.
//Transfer gas remaining to recipient
	SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost);
	//Save Gas
	uint256 gasAfterTransfer = gasleft();
	//Check if sufficient balance
	if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) {
		_forceRevert();
		return;
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1029-L1054

It checks if the difference between gasLeft and gasAfterTransfer is greater than TRANSFER_OVERHEAD then it calls _forceRevert(). So that Anycall Executor reverts the call. This check has been Introduced to prevent any arbitrary code executed in the _recipient's fallback (This was confirmed by the sponsor). However, the condition gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD is always true. TRANSFER_OVERHEAD is 24_000

uint256 internal constant TRANSFER_OVERHEAD = 24_000;

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L139

And the gas spent between gasLeft and gasAfterTransfer is nearly 70_000 which is higher than 24_000. Thus, causing the function to revert always. _payExecutionGas is called by anyExecute which is called by Anycall Executor. This means anyExecute will also fail. This happens because gasLeft value is stored before even replenishing gas and not before transfer.

Proof of Concept

Overview

This PoC is independent from the codebase (but uses the same code). There are one contract simulating BranchBridgeAgent.anyExecute.

We run the test, then anyExecute will revert because gasLeft - gasAfterTransfer is always greater than TRANSFER_OVERHEAD (24_000).

Here is the output of the test:

[PASS] test_anyexecute_always_revert_bc_transfer_overhead() (gas: 124174)
Logs:
  (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) => true
  gasLeft - gasAfterTransfer = 999999999999979606 - 999999999999909238 = 70368

Test result: ok. 1 passed; 0 failed; finished in 1.88ms
Explanation

BranchBridgeAgent.anyExecute method depends on the following external calls:

  1. AnycallExecutor.context()
  2. AnycallProxy.config()
  3. AnycallConfig.executionBudget()
  4. AnycallConfig.withdraw()
  5. AnycallConfig.deposit()
  6. WETH9.withdraw()

For this reason, I've copied the same code from multichain-smart-contracts. For WETH9, I've used the contract from the codebase which has minimal code.

Please note that:

  • tx.gasprice is replaced with a fixed value in _payExecutionGas method as it is not available in Foundry.
  • In _replenishGas, reading the config via IAnycallProxy(localAnyCallAddress).config() is replaced with Immediate call for simplicity. In other words, avoiding proxy to make the PoC simpler and shorter. However, if done with proxy the gas used would increase. So in both ways, it is in favor of the PoC.
  • In _forceRevert, we call anycallConfig Immediately skippping the returned value from AnycallProxy. This is anyway irrelevant for this PoC.
The coded PoC
  • Foundry.toml
	[profile.default]
	solc = '0.8.17'
	src = 'solidity'
	test = 'solidity/test'
	out = 'out'
	libs = ['lib']
	fuzz_runs = 1000
	optimizer_runs = 10_000
  • .gitmodules
	[submodule "lib/ds-test"]
		path = lib/ds-test
		url = https://github.com/dapphub/ds-test
		branch = master
	[submodule "lib/forge-std"]
		path = lib/forge-std
		url = https://github.com/brockelmore/forge-std
		branch = master
  • remappings.txt
	ds-test/=lib/ds-test/src
	forge-std/=lib/forge-std/src
  • Test File
// PoC => Maia OmniChain: anyExecute always revert in BranchBridgeAgent
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";

library SafeTransferLib {
    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                       CUSTOM ERRORS                        */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev The ETH transfer has failed.
    error ETHTransferFailed();

    /// @dev The ERC20 `transferFrom` has failed.
    error TransferFromFailed();

    /// @dev The ERC20 `transfer` has failed.
    error TransferFailed();

    /// @dev The ERC20 `approve` has failed.
    error ApproveFailed();

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                         CONSTANTS                          */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Suggested gas stipend for contract receiving ETH
    /// that disallows any storage writes.
    uint256 internal constant _GAS_STIPEND_NO_STORAGE_WRITES = 2300;

    /// @dev Suggested gas stipend for contract receiving ETH to perform a few
    /// storage reads and writes, but low enough to prevent griefing.
    /// Multiply by a small constant (e.g. 2), if needed.
    uint256 internal constant _GAS_STIPEND_NO_GRIEF = 100000;

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                       ETH OPERATIONS                       */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Sends `amount` (in wei) ETH to `to`.
    /// Reverts upon failure.
    ///
    /// Note: This implementation does NOT protect against gas griefing.
    /// Please use `forceSafeTransferETH` for gas griefing protection.
    function safeTransferETH(address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(gas(), to, amount, 0, 0, 0, 0)) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
        }
    }

    /// @dev Force sends `amount` (in wei) ETH to `to`, with a `gasStipend`.
    /// The `gasStipend` can be set to a low enough value to prevent
    /// storage writes or gas griefing.
    ///
    /// If sending via the normal procedure fails, force sends the ETH by
    /// creating a temporary contract which uses `SELFDESTRUCT` to force send the ETH.
    ///
    /// Reverts if the current contract has insufficient balance.
    function forceSafeTransferETH(
        address to,
        uint256 amount,
        uint256 gasStipend
    ) internal {
        /// @solidity memory-safe-assembly
        assembly {
            // If insufficient balance, revert.
            if lt(selfbalance(), amount) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(gasStipend, to, amount, 0, 0, 0, 0)) {
                mstore(0x00, to) // Store the address in scratch space.
                mstore8(0x0b, 0x73) // Opcode `PUSH20`.
                mstore8(0x20, 0xff) // Opcode `SELFDESTRUCT`.
                // We can directly use `SELFDESTRUCT` in the contract creation.
                // Compatible with `SENDALL`: https://eips.ethereum.org/EIPS/eip-4758
                if iszero(create(amount, 0x0b, 0x16)) {
                    // To coerce gas estimation to provide enough gas for the `create` above.
                    if iszero(gt(gas(), 1000000)) {
                        revert(0, 0)
                    }
                }
            }
        }
    }

    /// @dev Force sends `amount` (in wei) ETH to `to`, with a gas stipend
    /// equal to `_GAS_STIPEND_NO_GRIEF`. This gas stipend is a reasonable default
    /// for 99% of cases and can be overridden with the three-argument version of this
    /// function if necessary.
    ///
    /// If sending via the normal procedure fails, force sends the ETH by
    /// creating a temporary contract which uses `SELFDESTRUCT` to force send the ETH.
    ///
    /// Reverts if the current contract has insufficient balance.
    function forceSafeTransferETH(address to, uint256 amount) internal {
        // Manually inlined because the compiler doesn't inline functions with branches.
        /// @solidity memory-safe-assembly
        assembly {
            // If insufficient balance, revert.
            if lt(selfbalance(), amount) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(_GAS_STIPEND_NO_GRIEF, to, amount, 0, 0, 0, 0)) {
                mstore(0x00, to) // Store the address in scratch space.
                mstore8(0x0b, 0x73) // Opcode `PUSH20`.
                mstore8(0x20, 0xff) // Opcode `SELFDESTRUCT`.
                // We can directly use `SELFDESTRUCT` in the contract creation.
                // Compatible with `SENDALL`: https://eips.ethereum.org/EIPS/eip-4758
                if iszero(create(amount, 0x0b, 0x16)) {
                    // To coerce gas estimation to provide enough gas for the `create` above.
                    if iszero(gt(gas(), 1000000)) {
                        revert(0, 0)
                    }
                }
            }
        }
    }

    /// @dev Sends `amount` (in wei) ETH to `to`, with a `gasStipend`.
    /// The `gasStipend` can be set to a low enough value to prevent
    /// storage writes or gas griefing.
    ///
    /// Simply use `gasleft()` for `gasStipend` if you don't need a gas stipend.
    ///
    /// Note: Does NOT revert upon failure.
    /// Returns whether the transfer of ETH is successful instead.
    function trySafeTransferETH(
        address to,
        uint256 amount,
        uint256 gasStipend
    ) internal returns (bool success) {
        /// @solidity memory-safe-assembly
        assembly {
            // Transfer the ETH and check if it succeeded or not.
            success := call(gasStipend, to, amount, 0, 0, 0, 0)
        }
    }

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                      ERC20 OPERATIONS                      */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Sends `amount` of ERC20 `token` from `from` to `to`.
    /// Reverts upon failure.
    ///
    /// The `from` account must have at least `amount` approved for
    /// the current contract to manage.
    function safeTransferFrom(
        address token,
        address from,
        address to,
        uint256 amount
    ) internal {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.

            mstore(0x60, amount) // Store the `amount` argument.
            mstore(0x40, to) // Store the `to` argument.
            mstore(0x2c, shl(96, from)) // Store the `from` argument.
            // Store the function selector of `transferFrom(address,address,uint256)`.
            mstore(0x0c, 0x23b872dd000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x60, 0) // Restore the zero slot to zero.
            mstore(0x40, m) // Restore the free memory pointer.
        }
    }

    /// @dev Sends all of ERC20 `token` from `from` to `to`.
    /// Reverts upon failure.
    ///
    /// The `from` account must have their entire balance approved for
    /// the current contract to manage.
    function safeTransferAllFrom(
        address token,
        address from,
        address to
    ) internal returns (uint256 amount) {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.

            mstore(0x40, to) // Store the `to` argument.
            mstore(0x2c, shl(96, from)) // Store the `from` argument.
            // Store the function selector of `balanceOf(address)`.
            mstore(0x0c, 0x70a08231000000000000000000000000)
            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                    staticcall(gas(), token, 0x1c, 0x24, 0x60, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            // Store the function selector of `transferFrom(address,address,uint256)`.
            mstore(0x00, 0x23b872dd)
            // The `amount` argument is already written to the memory word at 0x60.
            amount := mload(0x60)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x60, 0) // Restore the zero slot to zero.
            mstore(0x40, m) // Restore the free memory pointer.
        }
    }

    /// @dev Sends `amount` of ERC20 `token` from the current contract to `to`.
    /// Reverts upon failure.
    function safeTransfer(address token, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, to) // Store the `to` argument.
            mstore(0x34, amount) // Store the `amount` argument.
            // Store the function selector of `transfer(address,uint256)`.
            mstore(0x00, 0xa9059cbb000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Sends all of ERC20 `token` from the current contract to `to`.
    /// Reverts upon failure.
    function safeTransferAll(
        address token,
        address to
    ) internal returns (uint256 amount) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x00, 0x70a08231) // Store the function selector of `balanceOf(address)`.
            mstore(0x20, address()) // Store the address of the current contract.
            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                    staticcall(gas(), token, 0x1c, 0x24, 0x34, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x14, to) // Store the `to` argument.
            // The `amount` argument is already written to the memory word at 0x34.
            amount := mload(0x34)
            // Store the function selector of `transfer(address,uint256)`.
            mstore(0x00, 0xa9059cbb000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Sets `amount` of ERC20 `token` for `to` to manage on behalf of the current contract.
    /// Reverts upon failure.
    function safeApprove(address token, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, to) // Store the `to` argument.
            mstore(0x34, amount) // Store the `amount` argument.
            // Store the function selector of `approve(address,uint256)`.
            mstore(0x00, 0x095ea7b3000000000000000000000000)

            if iszero(
                and(
                    // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `ApproveFailed()`.
                mstore(0x00, 0x3e3f8f73)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Returns the amount of ERC20 `token` owned by `account`.
    /// Returns zero if the `token` does not exist.
    function balanceOf(
        address token,
        address account
    ) internal view returns (uint256 amount) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, account) // Store the `account` argument.
            // Store the function selector of `balanceOf(address)`.
            mstore(0x00, 0x70a08231000000000000000000000000)
            amount := mul(
                mload(0x20),
                and(
                    // The arguments of `and` are evaluated from right to left.
                    gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                    staticcall(gas(), token, 0x10, 0x24, 0x20, 0x20)
                )
            )
        }
    }
}

interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

interface IAnycallConfig {
    function calcSrcFees(
        address _app,
        uint256 _toChainID,
        uint256 _dataLength
    ) external view returns (uint256);

    function executionBudget(address _app) external view returns (uint256);

    function deposit(address _account) external payable;

    function withdraw(uint256 _amount) external;
}

interface IAnycallProxy {
    function executor() external view returns (address);

    function config() external view returns (address);

    function anyCall(
        address _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;

    function anyCall(
        string calldata _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;
}

contract WETH9 {
    string public name = "Wrapped Ether";
    string public symbol = "WETH";
    uint8 public decimals = 18;

    event Approval(address indexed src, address indexed guy, uint256 wad);
    event Transfer(address indexed src, address indexed dst, uint256 wad);
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // function receive() external payable {
    //   deposit();
    // }

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(
        address src,
        address dst,
        uint256 wad
    ) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != 255) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

contract AnycallExecutor {
    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    constructor() {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;
    }
}

contract AnycallV7Config {
    event Deposit(address indexed account, uint256 amount);

    mapping(address => uint256) public executionBudget;

    /// @notice Deposit native currency crediting `_account` for execution costs on this chain
    /// @param _account The account to deposit and credit for
    function deposit(address _account) external payable {
        executionBudget[_account] += msg.value;
        emit Deposit(_account, msg.value);
    }
}

contract BranchBridgeAgent {
    error AnycallUnauthorizedCaller();
    error GasErrorOrRepeatedTx();

    uint256 public remoteCallDepositedGas;

    uint256 internal constant MIN_EXECUTION_OVERHEAD = 160_000; // 100_000 for anycall + 35_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Executions
    uint256 internal constant TRANSFER_OVERHEAD = 24_000;

    WETH9 public immutable wrappedNativeToken;

    AnycallV7Config public anycallV7Config;

    uint256 public accumulatedFees;

    /// @notice Local Chain Id
    uint24 public immutable localChainId;

    /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address where cross-chain requests are executed in the Root Chain.
    address public immutable rootBridgeAgentAddress;
    /// @notice Local Anyexec Address
    address public immutable localAnyCallExecutorAddress;

    /// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
    address public immutable localAnyCallAddress;

    constructor() {
        AnycallExecutor anycallExecutor = new AnycallExecutor();
        localAnyCallExecutorAddress = address(anycallExecutor);

        localChainId = 1;

        wrappedNativeToken = new WETH9();

        localAnyCallAddress = address(3);

        rootBridgeAgentAddress = address(2);

        anycallV7Config = new AnycallV7Config();
    }

    modifier requiresExecutor() {
        _requiresExecutor();
        _;
    }

    function _requiresExecutor() internal view {
        if (msg.sender != localAnyCallExecutorAddress)
            revert AnycallUnauthorizedCaller();
        (address from, , ) = IAnycallExecutor(localAnyCallExecutorAddress)
            .context();
        if (from != rootBridgeAgentAddress) revert AnycallUnauthorizedCaller();
    }

    function _replenishGas(uint256 _executionGasSpent) internal virtual {
        //Deposit Gas
        anycallV7Config.deposit{value: _executionGasSpent}(address(this));
        // IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
    }

    function _forceRevert() internal virtual {
        IAnycallConfig anycallConfig = IAnycallConfig(
            IAnycallProxy(localAnyCallAddress).config()
        );

        // uint256 executionBudget = anycallConfig.executionBudget(address(this));
        uint256 executionBudget = anycallV7Config.executionBudget(
            address(this)
        );

        // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
        if (executionBudget > 0)
            try anycallConfig.withdraw(executionBudget) {} catch {}
    }

    /**
     * @notice Internal function repays gas used by Branch Bridge Agent to fulfill remote initiated interaction.
     *   @param _recipient address to send excess gas to.
     *   @param _initialGas gas used by Branch Bridge Agent.
     */
    function _payExecutionGas(
        address _recipient,
        uint256 _initialGas
    ) internal virtual {
        //Gas remaining
        uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this));

        //Unwrap Gas
        wrappedNativeToken.withdraw(gasRemaining);

        //Delete Remote Initiated Action State
        delete (remoteCallDepositedGas);

        ///Save gas left
        uint256 gasLeft = gasleft();

        //Get Branch Environment Execution Cost
        // Assume tx.gasPrice 1e9
        uint256 minExecCost = 1e9 *
            (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

        //Check if sufficient balance
        if (minExecCost > gasRemaining) {
            _forceRevert();
            return;
        }

        //Replenish Gas
        _replenishGas(minExecCost);

        //Transfer gas remaining to recipient
        SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost);

        //Save Gas
        uint256 gasAfterTransfer = gasleft();

        //Check if sufficient balance // This condition is always true
        if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) {
            console.log(
                "(gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) => true"
            );
            console.log(
                "gasLeft - gasAfterTransfer = %d - %d = %d",
                gasLeft,
                gasAfterTransfer,
                gasLeft - gasAfterTransfer
            );
            _forceRevert();
            return;
        }
    }

    function anyExecute(
        bytes memory data
    )
        public
        virtual
        requiresExecutor
        returns (bool success, bytes memory result)
    {
        //Get Initial Gas Checkpoint
        uint256 initialGas = gasleft();

        //Action Recipient
        address recipient = address(0x0); // for simplicity and since it is irrelevant //address(uint160(bytes20(data[PARAMS_START:PARAMS_START_SIGNED])));

        // Other Code Here

        //Deduct gas costs from deposit and replenish this bridge agent's execution budget.
        _payExecutionGas(recipient, initialGas);
    }

    function depositIntoWeth(uint256 amt) external {
        wrappedNativeToken.deposit{value: amt}();
    }

    fallback() external payable {}
}

contract GasCalcTransferOverHead is DSTest, Test {
    BranchBridgeAgent branchBridgeAgent;

    function setUp() public {
        branchBridgeAgent = new BranchBridgeAgent();
        vm.deal(
            address(branchBridgeAgent.localAnyCallExecutorAddress()),
            100 ether
        ); // executer pays gas
        vm.deal(address(branchBridgeAgent), 100 ether);
    }

    function test_anyexecute_always_revert_bc_transfer_overhead() public {
        // add weth balance
        branchBridgeAgent.depositIntoWeth(100 ether);
        vm.prank(address(branchBridgeAgent.localAnyCallExecutorAddress()));
        vm.expectRevert();
        branchBridgeAgent.anyExecute{gas: 1 ether}(bytes(""));
        vm.stopPrank();
    }
}

Tools Used

Manual analysis

Increase the TRANSFER_OVERHEAD to cover the actual gas spent. You could also add a gas check point immediately before the transfer to make the naming makes sense (i.e. TRANSFER_OVERHEAD). However, the gas will be nearly 34_378 which is still be higher than TRANSFER_OVERHEAD (24_000).

You can simply comment out the code after gasLeft till the transfer, remove - minExecCost from the value to transfer since it is commented out. Now run the test again, you will see an output like this (with failed test but we are not interested in it anyway):

[FAIL. Reason: Call did not revert as expected] test_anyexecute_always_revert_bc_transfer_overhead() (gas: 111185)
Logs:
  (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) => true
  gasLeft - gasAfterTransfer = 999999999999979606 - 999999999999945228 = 34378

Test result: FAILED. 0 passed; 1 failed; finished in 1.26ms

gasLeft - gasAfterTransfer = 34378

Please note that I have tested a simple function in Remix as well and it gave the same gas spent (i.e. 34378)

// copy the library code from Solady and paste it here // https://github.com/Vectorized/solady/blob/main/src/utils/SafeTransferLib.sol contract Test { function testGas() payable public returns (uint256){ ///Save gas left uint256 gasLeft = gasleft(); //Transfer gas remaining to recipient SafeTransferLib.safeTransferETH(address(0), 1 ether); //Save Gas uint256 gasAfterTransfer = gasleft(); return gasLeft-gasAfterTransfer; } }

The returned value will be 34378

Assessed type

Other

#0 - c4-judge

2023-07-11T08:56:33Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-11T08:56:37Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T15:55:43Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-25T13:13:49Z

trust1995 marked the issue as selected for report

#4 - c4-sponsor

2023-07-27T19:08:09Z

0xBugsy marked the issue as sponsor acknowledged

#5 - c4-sponsor

2023-07-28T13:21:06Z

0xBugsy marked the issue as sponsor confirmed

#6 - 0xBugsy

2023-07-28T13:21:24Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: Koolex

Also found by: Koolex

Labels

bug
3 (High Risk)
disagree with severity
satisfactory
sponsor confirmed
duplicate-607

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L798-L824

Vulnerability details

Impact

Context:

anyExecute method is called by Anycall Executor on the destination chain to execute interaction. The user has to pay for the remote call execution gas, this is done at the end of the call. However, if there is not enough available gas, the anyExecute will be reverted due to a revert caused by the Anycall Executor.

Here is the calculation for the gas used

	//Get Available Gas
	uint256 availableGas = _depositedGas - _gasToBridgeOut;

	//Get Root Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());

	//Check if sufficient balance
	if (minExecCost > availableGas) {
		_forceRevert();
		return;
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L798-L824

_forceRevert will withdraw all execution budget.

// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget" if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}

So Anycall Executor will revert if there is not enough budget. This is done at

	uint256 budget = executionBudget[_from];
	require(budget > totalCost, "no enough budget");
	executionBudget[_from] = budget - totalCost;

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L206C42-L206C58

(1) Gas Calculation:

To calculate how much the user has to pay, the following formula is used:

	//Get Root Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());

Gas units are calculated as follows:

  • Store gasleft() at initialGas at the beginning of anyExecute method
	//Get Initial Gas Checkpoint
	uint256 _initialGas = gasleft();

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L867

  • Nearly at the end of the method, deduct gasleft() from initialGas. This covers everything between initial gas checkpoint and end gas checkpoint.
	//Get Root Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());
  • Add MIN_EXECUTION_OVERHEAD which is 155_000.
uint256 internal constant MIN_EXECUTION_OVERHEAD = 155_000; // 100_000 for anycall + 30_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Execution

This overhead is supposed to cover:

  • 100_000 for anycall. This is extra cost required by Anycall
Line:38	
uint256 constant EXECUTION_OVERHEAD = 100000;
	.
	.
Line:203	
uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

  • 30_000 Pre 1st Gas Checkpoint Execution. For example, to cover the modifier requiresExecutor

  • 25_000 Post Last Gas Checkpoint Execution. To cover everthing after the end gas checkpoint.

	//Check if sufficient balance
	if (minExecCost > availableGas) {
		_forceRevert();
		return;
	}

	//Replenish Gas
	_replenishGas(minExecCost);

	//Account for excess gas
	accumulatedFees += availableGas - minExecCost;

The issue is that 55_000 is not enough to cover pre 1st gas checkpoint and post last gas checkpoint. This means that the user paying less than the actual gas cost. According to the sponsor, Bridge Agent deployer deposits first time into anycallConfig where the goal is to replenish the execution budget after use every time. The issue could possibly lead to:

  1. execution budget is decreasing over time (slow draining) in case it has funds already.

  2. anyExecute calls will fail since the calculation of the gas used in the Anycall contracts is way bigger. In Anycall, this is done by the modifier chargeDestFee

    • modifier chargeDestFee
    	modifier chargeDestFee(address _from, uint256 _flags) {
    	if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
    		uint256 _prevGasLeft = gasleft();
    		_;
    		IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
    	} else {
    		_;
    	}
    }

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L163-L171

    • function chargeFeeOnDestChain
    	function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft)
    		external
    		onlyAnycallContract
    	{
    		if (!_isSet(mode, FREE_MODE)) {
    			uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
    			uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium);
    			uint256 budget = executionBudget[_from];
    			require(budget > totalCost, "no enough budget");
    			executionBudget[_from] = budget - totalCost;
    			_feeData.accruedFees += uint128(totalCost);
    		}
    	}

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

(3) Gas Calculation in AnyCall:

There is also a gas consumption at anyExec method called by the MPC (in AnyCall) here

    function anyExec(
        address _to,
        bytes calldata _data,
        string calldata _appID,
        RequestContext calldata _ctx,
        bytes calldata _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags) // <= starting from here
        onlyMPC
    {
		.
		.
		.
		bool success = _execute(_to, _data, _ctx, _extdata);
		.
		.
   }

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L276

The gas is nearly 110_000. It is not taken into account.

(3) Base Fee & Input Data Fee:

From Ethereum yellow paper:

Gtransaction 21000 Paid for every transaction Gtxdatazero 4 Paid for every zero byte of data or code for a transaction. Gtxdatanonzero 16 Paid for every non-zero byte of data or code for a transaction.

So

  1. We have 21_000 as a base fee. This should be taken into account. However, it is paid by AnyCall, since the TX is sent by MPC. So, we are fine here. Probably this explains the overhead (100_000) added by anycall

  2. Because anyExecute method has bytes data to be passed, we have extra gas consumption which is not taken into account. For every zero byte => 4 For every non-zero byte => 16

So generally speaking, the bigger the data is, the bigger the gas becomes. you can simply prove this by adding arbitrary data to anyExecute method in PoC#1 test below. and you will see the gas spent increases.

Summary
  1. MIN_EXECUTION_OVERHEAD is underestimated.
  2. The gas consumed by anyExec method called by the MPC is not considered.
  3. Input data fee isn't taken into account.

There are two PoCs proving the first two points above. The third point can be proven by simply adding arbitrary data to anyExecute method in PoC#1 test.

Proof of Concept

PoC#1 (MIN_EXECUTION_OVERHEAD is underestimated)

Overview

This PoC is independent from the codebase (but uses the same code). There are two contracts simulating RootBridgeAgent.anyExecute.

  1. BridgeAgent which has the code of pre 1st gas checkpoint and post last gas checkpoint.
  2. BridgeAgentEmpty which has the code of pre 1st gas checkpoint and post last gas checkpoint commented out.

We run the same test for both, the difference in gas is what’s at least nearly the minimum required to cover pre 1st gas checkpoint and post last gas checkpoint. In this case here it is 76987 which is bigger than 55_000.

Here is the output of the test:

[PASS] test_calcgas() (gas: 213990)
Logs:
  bridgeAgentEmpty.anyExecute Gas Spent => 24572
  bridgeAgent.anyExecute Gas Spent => 101559
  The gas difference 101559 - 24572 = 76987
Explanation

RootBridgeAgent.anyExecute method depends on the following external calls:

  1. AnycallExecutor.context()
  2. AnycallProxy.config()
  3. AnycallConfig.executionBudget()
  4. AnycallConfig.withdraw()
  5. AnycallConfig.deposit()
  6. WETH9.withdraw()

For this reason, I've copied the same code from multichain-smart-contracts. For WETH9, I've used the contract from the codebase which has minimal code.

Please note that:

  • tx.gasprice is replaced with a fixed value in _payExecutionGas method as it is not available in Foundry.
  • In _replenishGas, reading the config via IAnycallProxy(localAnyCallAddress).config() is replaced with Immediate call for simplicity. In other words, avoiding proxy to make the PoC simpler and shorter. However, if done with proxy the gas used would increase. So in both ways, it is in favor of the PoC.
The coded PoC
  • Foundry.toml
	[profile.default]
	solc = '0.8.17'
	src = 'solidity'
	test = 'solidity/test'
	out = 'out'
	libs = ['lib']
	fuzz_runs = 1000
	optimizer_runs = 10_000
  • .gitmodules
	[submodule "lib/ds-test"]
		path = lib/ds-test
		url = https://github.com/dapphub/ds-test
		branch = master
	[submodule "lib/forge-std"]
		path = lib/forge-std
		url = https://github.com/brockelmore/forge-std
		branch = master
  • remappings.txt
	ds-test/=lib/ds-test/src
	forge-std/=lib/forge-std/src
  • Test File
// PoC => Maia OmniChain: gasCalculation in RootBridgeAgent 
	pragma solidity >=0.8.4 <0.9.0;

	import {Test} from "forge-std/Test.sol";
	import "forge-std/console.sol";

	import {DSTest} from "ds-test/test.sol";

	interface IAnycallExecutor {
		function context()
			external
			view
			returns (address from, uint256 fromChainID, uint256 nonce);

		function execute(
			address _to,
			bytes calldata _data,
			address _from,
			uint256 _fromChainID,
			uint256 _nonce,
			uint256 _flags,
			bytes calldata _extdata
		) external returns (bool success, bytes memory result);
	}

	interface IAnycallConfig {
		function calcSrcFees(
			address _app,
			uint256 _toChainID,
			uint256 _dataLength
		) external view returns (uint256);

		function executionBudget(address _app) external view returns (uint256);

		function deposit(address _account) external payable;

		function withdraw(uint256 _amount) external;
	}

	interface IAnycallProxy {
		function executor() external view returns (address);

		function config() external view returns (address);

		function anyCall(
			address _to,
			bytes calldata _data,
			uint256 _toChainID,
			uint256 _flags,
			bytes calldata _extdata
		) external payable;

		function anyCall(
			string calldata _to,
			bytes calldata _data,
			uint256 _toChainID,
			uint256 _flags,
			bytes calldata _extdata
		) external payable;
	}

	contract WETH9 {
		string public name = "Wrapped Ether";
		string public symbol = "WETH";
		uint8 public decimals = 18;

		event Approval(address indexed src, address indexed guy, uint256 wad);
		event Transfer(address indexed src, address indexed dst, uint256 wad);
		event Deposit(address indexed dst, uint256 wad);
		event Withdrawal(address indexed src, uint256 wad);

		mapping(address => uint256) public balanceOf;
		mapping(address => mapping(address => uint256)) public allowance;

		// function receive() external payable {
		//   deposit();
		// }

		function deposit() public payable {
			balanceOf[msg.sender] += msg.value;
			emit Deposit(msg.sender, msg.value);
		}

		function withdraw(uint256 wad) public {
			require(balanceOf[msg.sender] >= wad);
			balanceOf[msg.sender] -= wad;
			payable(msg.sender).transfer(wad);
			emit Withdrawal(msg.sender, wad);
		}

		function totalSupply() public view returns (uint256) {
			return address(this).balance;
		}

		function approve(address guy, uint256 wad) public returns (bool) {
			allowance[msg.sender][guy] = wad;
			emit Approval(msg.sender, guy, wad);
			return true;
		}

		function transfer(address dst, uint256 wad) public returns (bool) {
			return transferFrom(msg.sender, dst, wad);
		}

		function transferFrom(
			address src,
			address dst,
			uint256 wad
		) public returns (bool) {
			require(balanceOf[src] >= wad);

			if (src != msg.sender && allowance[src][msg.sender] != 255) {
				require(allowance[src][msg.sender] >= wad);
				allowance[src][msg.sender] -= wad;
			}

			balanceOf[src] -= wad;
			balanceOf[dst] += wad;

			emit Transfer(src, dst, wad);

			return true;
		}
	}

	contract AnycallExecutor {
		struct Context {
			address from;
			uint256 fromChainID;
			uint256 nonce;
		}
		// Context public override context;
		Context public context;

		constructor() {
			context.fromChainID = 1;
			context.from = address(2);
			context.nonce = 1;
		}
	}

	contract AnycallV7Config {
		event Deposit(address indexed account, uint256 amount);

		mapping(address => uint256) public executionBudget;

		/// @notice Deposit native currency crediting `_account` for execution costs on this chain
		/// @param _account The account to deposit and credit for
		function deposit(address _account) external payable {
			executionBudget[_account] += msg.value;
			emit Deposit(_account, msg.value);
		}
	}

	contract BridgeAgent {
		error AnycallUnauthorizedCaller();
		error GasErrorOrRepeatedTx();

		uint256 internal constant MIN_EXECUTION_OVERHEAD = 155_000; // 100_000 for anycall + 30_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Execution

		uint256 public initialGas;

		WETH9 public immutable wrappedNativeToken;

		AnycallV7Config public anycallV7Config;

		uint256 public accumulatedFees;

		/// @notice Local Chain Id
		uint24 public immutable localChainId;

		/// @notice Chain -> Branch Bridge Agent Address. For N chains, each Root Bridge Agent Address has M =< N Branch Bridge Agent Address.
		mapping(uint256 => address) public getBranchBridgeAgent;
		/// @notice Local Anyexec Address
		address public immutable localAnyCallExecutorAddress;

		/// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
		address public immutable localAnyCallAddress;

		constructor() {
			AnycallExecutor anycallExecutor = new AnycallExecutor();
			localAnyCallExecutorAddress = address(anycallExecutor);

			localChainId = 1;
			getBranchBridgeAgent[1] = address(2);

			wrappedNativeToken = new WETH9();

			localAnyCallAddress = address(3);

			anycallV7Config = new AnycallV7Config();
		}

		modifier requiresExecutor() {
			_requiresExecutor();
			_;
		}

		function _requiresExecutor() internal view {
			if (msg.sender == getBranchBridgeAgent[localChainId]) return;

			if (msg.sender != localAnyCallExecutorAddress)
				revert AnycallUnauthorizedCaller();
			(address from, uint256 fromChainId, ) = IAnycallExecutor(
				localAnyCallExecutorAddress
			).context();
			if (getBranchBridgeAgent[fromChainId] != from)
				revert AnycallUnauthorizedCaller();
		}

		function _replenishGas(uint256 _executionGasSpent) internal {
			//Unwrap Gas
			wrappedNativeToken.withdraw(_executionGasSpent);
			anycallV7Config.deposit{value: _executionGasSpent}(address(this));
			// IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
		}

		function _forceRevert() internal {
			if (initialGas == 0) revert GasErrorOrRepeatedTx();

			IAnycallConfig anycallConfig = IAnycallConfig(
				IAnycallProxy(localAnyCallAddress).config()
			);
			uint256 executionBudget = anycallConfig.executionBudget(address(this));

			// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
			if (executionBudget > 0)
				try anycallConfig.withdraw(executionBudget) {} catch {}
		}

		function _payExecutionGas(
			uint128 _depositedGas,
			uint128 _gasToBridgeOut,
			uint256 _initialGas,
			uint24 _fromChain
		) internal {
			//Get Available Gas // depostedGas - remoteExecutionGas
			uint256 availableGas = _depositedGas - _gasToBridgeOut;

			//Get Root Environment Execution Cost
			// Assume gasprice as we don't have it here
			// 1e9 is gasprice;
			uint256 minExecCost = 1e9 *
				(MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());
			//Check if sufficient balance
			if (minExecCost > availableGas) {
				_forceRevert();
				return;
			}

			//Replenish Gas
			_replenishGas(minExecCost);

			//Account for excess gas
			accumulatedFees += availableGas - minExecCost;
		}

		function anyExecute(
			bytes memory data
		)
			public
			virtual
			requiresExecutor
			returns (bool success, bytes memory result)
		{
			//Get Initial Gas Checkpoint
			uint256 _initialGas = gasleft();
			initialGas = _initialGas;

			// Other Code Here

			_payExecutionGas(1e18, 0, _initialGas, 0);
		}

		function depositIntoWeth(uint256 amt) external {
			wrappedNativeToken.deposit{value: amt}();
		}

		fallback() external payable {}
	}

	contract BridgeAgentEmpty {
		error AnycallUnauthorizedCaller();
		error GasErrorOrRepeatedTx();

		uint256 internal constant MIN_EXECUTION_OVERHEAD = 155_000; // 100_000 for anycall + 30_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Execution

		uint256 public initialGas;

		WETH9 public immutable wrappedNativeToken;

		AnycallV7Config public anycallV7Config;

		uint256 public accumulatedFees;

		/// @notice Local Chain Id
		uint24 public immutable localChainId;

		/// @notice Chain -> Branch Bridge Agent Address. For N chains, each Root Bridge Agent Address has M =< N Branch Bridge Agent Address.
		mapping(uint256 => address) public getBranchBridgeAgent;
		/// @notice Local Anyexec Address
		address public immutable localAnyCallExecutorAddress;

		/// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
		address public immutable localAnyCallAddress;

		constructor() {
			AnycallExecutor anycallExecutor = new AnycallExecutor();
			localAnyCallExecutorAddress = address(anycallExecutor);

			localChainId = 1;
			getBranchBridgeAgent[1] = address(2);

			wrappedNativeToken = new WETH9();

			localAnyCallAddress = address(3);

			anycallV7Config = new AnycallV7Config();
		}

		modifier requiresExecutor() {
			_requiresExecutor();
			_;
		}

		function _requiresExecutor() internal view {
			if (msg.sender == getBranchBridgeAgent[localChainId]) return;

			if (msg.sender != localAnyCallExecutorAddress)
				revert AnycallUnauthorizedCaller();
			(address from, uint256 fromChainId, ) = IAnycallExecutor(
				localAnyCallExecutorAddress
			).context();
			if (getBranchBridgeAgent[fromChainId] != from)
				revert AnycallUnauthorizedCaller();
		}

		function _replenishGas(uint256 _executionGasSpent) internal {
			//Unwrap Gas
			wrappedNativeToken.withdraw(_executionGasSpent);
			anycallV7Config.deposit{value: _executionGasSpent}(address(this));
			// IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
		}

		function _forceRevert() internal {
			if (initialGas == 0) revert GasErrorOrRepeatedTx();

			IAnycallConfig anycallConfig = IAnycallConfig(
				IAnycallProxy(localAnyCallAddress).config()
			);
			uint256 executionBudget = anycallConfig.executionBudget(address(this));

			// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
			if (executionBudget > 0)
				try anycallConfig.withdraw(executionBudget) {} catch {}
		}

		function _payExecutionGas(
			uint128 _depositedGas,
			uint128 _gasToBridgeOut,
			uint256 _initialGas,
			uint24 _fromChain
		) internal {
			//Get Available Gas // depostedGas - remoteExecutionGas
			uint256 availableGas = _depositedGas - _gasToBridgeOut;

			//Get Root Environment Execution Cost
			// Assume gasprice as we don't have it here
			// 1e9 is gasprice;
			uint256 minExecCost = 1e9 *
				(MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());

			// This code is commented so it is not calculated for gas
			// //Check if sufficient balance
			// if (minExecCost > availableGas) {
			//     _forceRevert();
			//     return;
			// }

			// //Replenish Gas
			// _replenishGas(minExecCost);

			// //Account for excess gas
			// accumulatedFees += availableGas - minExecCost;
		}

		// No modifier here
		function anyExecute(
			bytes memory data
		)
			public
			virtual
			returns (
				// requiresExecutor // comment this modifier
				bool success,
				bytes memory result
			)
		{
			//Get Initial Gas Checkpoint
			uint256 _initialGas = gasleft();
			initialGas = _initialGas;

			// Other Code Here

			_payExecutionGas(1e18, 0, _initialGas, 0);
		}

		function depositIntoWeth(uint256 amt) external {
			wrappedNativeToken.deposit{value: amt}();
		}

		fallback() external payable {}
	}

	contract GasCalc is DSTest, Test {
		BridgeAgent bridgeAgent;
		BridgeAgentEmpty bridgeAgentEmpty;

		function setUp() public {
			bridgeAgentEmpty = new BridgeAgentEmpty();
			vm.deal(
				address(bridgeAgentEmpty.localAnyCallExecutorAddress()),
				100 ether
			); // executer pays gas
			vm.deal(address(bridgeAgentEmpty), 100 ether);

			bridgeAgent = new BridgeAgent();
			vm.deal(address(bridgeAgent.localAnyCallExecutorAddress()), 100 ether); // executer pays gas
			vm.deal(address(bridgeAgent), 100 ether);
		}

		function test_calcgas() public {
			// add weth balance
			bridgeAgentEmpty.depositIntoWeth(100 ether);

			vm.prank(address(bridgeAgentEmpty.localAnyCallExecutorAddress()));
			uint256 gasStart_ = gasleft();
			bridgeAgentEmpty.anyExecute(bytes(""));
			uint256 gasEnd_ = gasleft();
			vm.stopPrank();
			uint256 gasSpent_ = gasStart_ - gasEnd_;
			console.log("bridgeAgentEmpty.anyExecute Gas Spent => %d", gasSpent_);

			//
			// add weth balance
			bridgeAgent.depositIntoWeth(100 ether);

			vm.prank(address(bridgeAgent.localAnyCallExecutorAddress()));
			uint256 gasStart = gasleft();
			bridgeAgent.anyExecute(bytes(""));
			uint256 gasEnd = gasleft();
			vm.stopPrank();
			uint256 gasSpent = gasStart - gasEnd;
			console.log("bridgeAgent.anyExecute Gas Spent => %d", gasSpent);

			uint256 difference = gasSpent - gasSpent_;

			console.log("The gas difference %d - %d = %d", gasSpent,gasSpent_,difference);


		}
	}

PoC#2 (The gas consumed by anyExec method in AnyCall)

Overview

We have contracts that simulating Anycall contracts:

  1. AnycallV7Config
  2. AnycallExecutor
  3. AnycallV7

The flow like this: MPC => AnycallV7 => AnycallExecutor => IApp

In the code, IApp(_to).anyExecute is commented out because we don't want to calculate its gas since it is done in PoC#1.

Here is the output of the test:

[PASS] test_gasInanycallv7() (gas: 102613)
Logs:
  anycallV7.anyExec Gas Spent => 110893
coded PoC
  • Foundry.toml
	[profile.default]
	solc = '0.8.17'
	src = 'solidity'
	test = 'solidity/test'
	out = 'out'
	libs = ['lib']
	fuzz_runs = 1000
	optimizer_runs = 10_000
  • .gitmodules
	[submodule "lib/ds-test"]
		path = lib/ds-test
		url = https://github.com/dapphub/ds-test
		branch = master
	[submodule "lib/forge-std"]
		path = lib/forge-std
		url = https://github.com/brockelmore/forge-std
		branch = master
  • remappings.txt
	ds-test/=lib/ds-test/src
	forge-std/=lib/forge-std/src
  • Test File
// PoC => Maia OmniChain: gasCalculation in AnyCall v7  contracts
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";

/// IAnycallConfig interface of the anycall config
interface IAnycallConfig {
    function checkCall(
        address _sender,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags
    ) external view returns (string memory _appID, uint256 _srcFees);

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view;

    function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft) external;
}

/// IAnycallExecutor interface of the anycall executor
interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

/// IApp interface of the application
interface IApp {
    /// (required) call on the destination chain to exec the interaction
    function anyExecute(bytes calldata _data)
        external
        returns (bool success, bytes memory result);

    /// (optional,advised) call back on the originating chain if the cross chain interaction fails
    /// `_data` is the orignal interaction arguments exec on the destination chain
    function anyFallback(bytes calldata _data)
        external
        returns (bool success, bytes memory result);
}

library AnycallFlags {
    // call flags which can be specified by user
    uint256 public constant FLAG_NONE = 0x0;
    uint256 public constant FLAG_MERGE_CONFIG_FLAGS = 0x1;
    uint256 public constant FLAG_PAY_FEE_ON_DEST = 0x1 << 1;
    uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;

    // exec flags used internally
    uint256 public constant FLAG_EXEC_START_VALUE = 0x1 << 16;
    uint256 public constant FLAG_EXEC_FALLBACK = 0x1 << 16;
}

contract AnycallV7Config {
    uint256 public constant PERMISSIONLESS_MODE = 0x1;
    uint256 public constant FREE_MODE = 0x1 << 1;

    mapping(string => mapping(address => bool)) public appExecWhitelist;
    mapping(string => bool) public appBlacklist;

    uint256 public mode;

    uint256 public minReserveBudget;

    mapping(address => uint256) public executionBudget;

    constructor() {
         mode = PERMISSIONLESS_MODE;
    }

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view {
        require(!appBlacklist[_appID], "blacklist");

        if (!_isSet(mode, PERMISSIONLESS_MODE)) {
            require(appExecWhitelist[_appID][_to], "no permission");
        }

        if (!_isSet(mode, FREE_MODE)) {
            require(
                executionBudget[_from] >= minReserveBudget,
                "less than min budget"
            );
        }
    }

    function _isSet(
        uint256 _value,
        uint256 _testBits
    ) internal pure returns (bool) {
        return (_value & _testBits) == _testBits;
    }
}

contract AnycallExecutor {
    bytes32 public constant PAUSE_ALL_ROLE = 0x00;

    event Paused(bytes32 role);
    event Unpaused(bytes32 role);

    modifier whenNotPaused(bytes32 role) {
        require(
            !paused(role) && !paused(PAUSE_ALL_ROLE),
            "PausableControl: paused"
        );
        _;
    }
    mapping(bytes32 => bool) private _pausedRoles;
    mapping(address => bool) public isSupportedCaller;


    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    function paused(bytes32 role) public view virtual returns (bool) {
        return _pausedRoles[role];
    }

    modifier onlyAuth() {
        require(isSupportedCaller[msg.sender], "not supported caller");
        _;
    }

    constructor(address anycall) {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;

        isSupportedCaller[anycall] = true;
    }

function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }

    // @dev `_extdata` content is implementation based in each version
    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata /*_extdata*/
    )
        external
        virtual
        onlyAuth
        whenNotPaused(PAUSE_ALL_ROLE)
        returns (bool success, bytes memory result)
    {
        bool isFallback = _isSet(_flags, AnycallFlags.FLAG_EXEC_FALLBACK);

        context = Context({
            from: _from,
            fromChainID: _fromChainID,
            nonce: _nonce
        });

        if (!isFallback) {
            // we skip calling anyExecute since it is irrelevant for this PoC
            // (success, result) = IApp(_to).anyExecute(_data);
        } else {
            (success, result) = IApp(_to).anyFallback(_data);
        }

        context = Context({from: address(0), fromChainID: 0, nonce: 0});
    }
}

contract AnycallV7 {

    event LogAnyCall(
        address indexed from,
        address to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );
    event LogAnyCall(
        address indexed from,
        string to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );

    event LogAnyExec(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bool success,
        bytes result
    );


    event StoreRetryExecRecord(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bytes data
    );

    // Context of the request on originating chain
    struct RequestContext {
        bytes32 txhash;
        address from;
        uint256 fromChainID;
        uint256 nonce;
        uint256 flags;
    }
    address public mpc;

    bool public paused;

    // applications should give permission to this executor
    address public executor;

    // anycall config contract
    address public config;

    mapping(bytes32 => bytes32) public retryExecRecords;
    bool public retryWithPermit;

    mapping(bytes32 => bool) public execCompleted;
    uint256 nonce;

    uint256 private unlocked;

    modifier lock() {
        require(unlocked == 1, "locked");
        unlocked = 0;
        _;
        unlocked = 1;
    }
    /// @dev Access control function
    modifier onlyMPC() {
        require(msg.sender == mpc, "only MPC");
        _;
    }

    /// @dev pausable control function
    modifier whenNotPaused() {
        require(!paused, "paused");
        _;
    }

    function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }


    /// @dev Charge an account for execution costs on this chain
    /// @param _from The account to charge for execution costs
    modifier chargeDestFee(address _from, uint256 _flags) {
        if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
            uint256 _prevGasLeft = gasleft();
            _;
            IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
        } else {
            _;
        }
    }

    constructor(address _mpc) {
        unlocked = 1; // needs to be unlocked initially
        mpc = _mpc;
        config = address(new AnycallV7Config());
        executor = address(new AnycallExecutor(address(this)));
    }

    /// @notice Calc unique ID
    function calcUniqID(
        bytes32 _txhash,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encode(_txhash, _from, _fromChainID, _nonce));
    }

    function _execute(
        address _to,
        bytes memory _data,
        RequestContext memory _ctx,
        bytes memory _extdata
    ) internal returns (bool success) {
        bytes memory result;

        try
            IAnycallExecutor(executor).execute(
                _to,
                _data,
                _ctx.from,
                _ctx.fromChainID,
                _ctx.nonce,
                _ctx.flags,
                _extdata
            )
        returns (bool succ, bytes memory res) {
            (success, result) = (succ, res);
        } catch Error(string memory reason) {
            result = bytes(reason);
        } catch (bytes memory reason) {
            result = reason;
        }

        emit LogAnyExec(
            _ctx.txhash,
            _ctx.from,
            _to,
            _ctx.fromChainID,
            _ctx.nonce,
            success,
            result
        );
    }

    /**
        @notice Execute a cross chain interaction
        @dev Only callable by the MPC
        @param _to The cross chain interaction target
        @param _data The calldata supplied for interacting with target
        @param _appID The app identifier to check whitelist
        @param _ctx The context of the request on originating chain
        @param _extdata The extension data for execute context
    */
   // Note: changed from callback to memory so we can call it from the test contract
    function anyExec(
        address _to,
        bytes memory _data,
        string memory _appID,
        RequestContext memory _ctx, 
        bytes memory _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags)
        onlyMPC
    {
        IAnycallConfig(config).checkExec(_appID, _ctx.from, _to);
        bytes32 uniqID = calcUniqID(
            _ctx.txhash,
            _ctx.from,
            _ctx.fromChainID,
            _ctx.nonce
        );
        require(!execCompleted[uniqID], "exec completed");

        bool success = _execute(_to, _data, _ctx, _extdata);
        // success = false on purpose, because when it is true, it consumes less gas. so we are considering worse case here

        // set exec completed (dont care success status)
        execCompleted[uniqID] = true;

        if (!success) {
            if (_isSet(_ctx.flags, AnycallFlags.FLAG_ALLOW_FALLBACK)) { 
                // this will be executed here since the call failed
                // Call the fallback on the originating chain
                nonce++;
                string memory appID = _appID; // fix Stack too deep
                emit LogAnyCall(
                    _to,
                    _ctx.from,
                    _data,
                    _ctx.fromChainID,
                    AnycallFlags.FLAG_EXEC_FALLBACK |
                        AnycallFlags.FLAG_PAY_FEE_ON_DEST, // pay fee on dest chain
                    appID,
                    nonce,
                    ""
                );
            } else {
                // Store retry record and emit a log
                bytes memory data = _data; // fix Stack too deep
                retryExecRecords[uniqID] = keccak256(abi.encode(_to, data));
                emit StoreRetryExecRecord(
                    _ctx.txhash,
                    _ctx.from,
                    _to,
                    _ctx.fromChainID,
                    _ctx.nonce,
                    data
                );
            }
        }
    }
}

contract GasCalcAnyCallv7 is DSTest, Test {


    AnycallV7 anycallV7;
    address mpc = vm.addr(7);
    function setUp() public {
        anycallV7 = new AnycallV7(mpc);
    }

    function test_gasInanycallv7() public {

        vm.prank(mpc);
        AnycallV7.RequestContext memory ctx = AnycallV7.RequestContext({
            txhash:keccak256(""),
            from:address(0),
            fromChainID:1,
            nonce:1,
            flags:AnycallFlags.FLAG_ALLOW_FALLBACK
        });
        uint256 gasStart_ = gasleft();
        anycallV7.anyExec(address(0),bytes(""),"1",ctx,bytes(""));
        uint256 gasEnd_ = gasleft();
        vm.stopPrank();
        uint256 gasSpent_ = gasStart_ - gasEnd_;
        console.log("anycallV7.anyExec Gas Spent => %d", gasSpent_);
    }
}

Tools Used

Manual analysis

Increase the MIN_EXECUTION_OVERHEAD by:

  • 35_000 for RootBridgeAgent.anyExecute.
  • 110_000 for anyExec method in AnyCall.

35_000 + 110_000 = 145_000 So MIN_EXECUTION_OVERHEAD becomes 300_000 instead of 155_000.

Additionally, calculate the gas consumption of the input data passed, add it to the cost.

Note: I suggest that the MIN_EXECUTION_OVERHEAD should be configurable/changeable. After launching OmniChain for some time, collect stats about the actual gas used for AnyCall on chain then adjust it accordingly. This also keeps you in the safe side in case any changes are applied on AnyCall contracts in future since it is upgradable.

Assessed type

Other

#0 - c4-judge

2023-07-11T08:42:07Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-11T08:42:12Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T15:43:34Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-sponsor

2023-07-12T15:43:40Z

0xBugsy marked the issue as disagree with severity

#4 - 0xBugsy

2023-07-12T15:47:52Z

The variable data cost should be addressed by consulting premium(), the value is used in their calcualtions here uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium); and we should abide and only pay as much as they will credit us

#5 - trust1995

2023-07-24T14:35:10Z

@0xBugsy pls share reasoning for reduced severity. Thanks.

#6 - c4-judge

2023-07-25T13:14:08Z

trust1995 marked the issue as selected for report

#7 - 0xBugsy

2023-07-25T17:29:52Z

@0xBugsy pls share reasoning for reduced severity. Thanks.

Was mistakenly gauging from other gas related issues that did not involve deposited assets for trading for example like #349 but I now see it has since then been updated to High.

#8 - c4-judge

2023-07-26T15:50:48Z

trust1995 marked the issue as not selected for report

#9 - c4-judge

2023-07-26T15:51:02Z

trust1995 marked the issue as duplicate of #607

#10 - c4-sponsor

2023-07-27T19:08:29Z

0xBugsy marked the issue as sponsor acknowledged

#11 - c4-sponsor

2023-07-28T13:22:07Z

0xBugsy marked the issue as sponsor confirmed

#12 - 0xBugsy

2023-07-28T13:22:20Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: Koolex

Also found by: Koolex

Labels

bug
3 (High Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
H-16

Awards

5707.2337 USDC - $5,707.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1018-L1054

Vulnerability details

Impact

Context:

anyExecute method is called by Anycall Executor on the destination chain to execute interaction. The user has to pay for the remote call execution gas, this is done at the end of the call. However, if there is not enough gasRemaining, the anyExecute will be reverted due to a revert caused by the Anycall Executor.

Here is the calculation for the gas used

	///Save gas left
	uint256 gasLeft = gasleft();

	//Get Branch Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

	//Check if sufficient balance
	if (minExecCost > gasRemaining) {
		_forceRevert();
		return;
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1018-L1054

_forceRevert will withdraw all execution budget.

// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget" if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}

So Anycall Executor will revert if there is not enough budget. This is done at

	uint256 budget = executionBudget[_from];
	require(budget > totalCost, "no enough budget");
	executionBudget[_from] = budget - totalCost;

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L206C42-L206C58

(1) Gas Calculation:

To calculate how much the user has to pay, the following formula is used:

        //Get Branch Environment Execution Cost
        uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

Gas units are calculated as follows:

  • Store gasleft() at initialGas at the beginning of anyExecute method
	//Get Initial Gas Checkpoint
	uint256 initialGas = gasleft();

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1125

  • Nearly at the end of the method, deduct gasleft() from initialGas. This covers everything between initial gas checkpoint and end gas checkpoint.
        ///Save gas left
        uint256 gasLeft = gasleft();

        //Get Branch Environment Execution Cost
        uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);
  • Add MIN_EXECUTION_OVERHEAD which is 160_000.
uint256 internal constant MIN_EXECUTION_OVERHEAD = 160_000; // 100_000 for anycall + 35_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Executions

This overhead is supposed to cover:

  • 100_000 for anycall. This is extra cost required by Anycall
Line:38	
uint256 constant EXECUTION_OVERHEAD = 100000;
	.
	.
Line:203	
uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

  • 35_000 Pre 1st Gas Checkpoint Execution. For example, to cover the modifier requiresExecutor

  • 25_000 Post Last Gas Checkpoint Execution. To cover everthing after the end gas checkpoint.

	//Get Branch Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

	//Check if sufficient balance
	if (minExecCost > gasRemaining) {
		_forceRevert();
		return;
	}

	//Replenish Gas
	_replenishGas(minExecCost);

	//Transfer gas remaining to recipient
	SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost);

	//Save Gas
	uint256 gasAfterTransfer = gasleft();

	//Check if sufficient balance
	if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) {
		_forceRevert();
		return;
	}

The issue is that 60_000 is not enough to cover pre 1st gas checkpoint and post last gas checkpoint. This means that the user paying less than the actual gas cost. According to the sponsor, Bridge Agent deployer deposits first time into anycallConfig where the goal is to replenish the execution budget after use every time. The issue could possibly lead to:

  1. **Overpaying the remaining gas the user **.

  2. execution budget is decreasing over time (slow draining) in case it has funds already.

  3. anyExecute calls will fail since the calculation of the gas used in the Anycall contracts is way bigger. In Anycall, this is done by the modifier chargeDestFee

    • modifier chargeDestFee
    	modifier chargeDestFee(address _from, uint256 _flags) {
    	if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
    		uint256 _prevGasLeft = gasleft();
    		_;
    		IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
    	} else {
    		_;
    	}
    }

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L163-L171

    • function chargeFeeOnDestChain
    	function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft)
    		external
    		onlyAnycallContract
    	{
    		if (!_isSet(mode, FREE_MODE)) {
    			uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
    			uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium);
    			uint256 budget = executionBudget[_from];
    			require(budget > totalCost, "no enough budget");
    			executionBudget[_from] = budget - totalCost;
    			_feeData.accruedFees += uint128(totalCost);
    		}
    	}

    https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L203

(3) Gas Calculation in AnyCall:

There is also a gas consumption at anyExec method called by the MPC (in AnyCall) here

    function anyExec(
        address _to,
        bytes calldata _data,
        string calldata _appID,
        RequestContext calldata _ctx,
        bytes calldata _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags) // <= starting from here
        onlyMPC
    {
		.
		.
		.
		bool success = _execute(_to, _data, _ctx, _extdata);
		.
		.
   }

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Upgradeable.sol#L276

The gas is nearly 110_000. It is not taken into account.

(3) Base Fee & Input Data Fee:

From Ethereum yellow paper:

Gtransaction 21000 Paid for every transaction Gtxdatazero 4 Paid for every zero byte of data or code for a transaction. Gtxdatanonzero 16 Paid for every non-zero byte of data or code for a transaction.

So

  1. We have 21_000 as a base fee. This should be taken into account. However, it is paid by AnyCall, since the TX is sent by MPC. So, we are fine here. Probably this explains the overhead (100_000) added by anycall

  2. Because anyExecute method has bytes data to be passed, we have extra gas consumption which is not taken into account. For every zero byte => 4 For every non-zero byte => 16

So generally speaking, the bigger the data is, the bigger the gas becomes. you can simply prove this by adding arbitrary data to anyExecute method in PoC#1 test below. and you will see the gas spent increases.

Summary
  1. MIN_EXECUTION_OVERHEAD is underestimated.
  2. The gas consumed by anyExec method called by the MPC is not considered.
  3. Input data fee isn't taken into account.

There are two PoCs proving the first two points above. The third point can be proven by simply adding arbitrary data to anyExecute method in PoC#1 test.

Proof of Concept

PoC#1 (MIN_EXECUTION_OVERHEAD is underestimated)

Overview

This PoC is independent from the codebase (but uses the same code). There are two contracts simulating BranchBridgeAgent.anyExecute.

  1. BranchBridgeAgent which has the code of pre 1st gas checkpoint and post last gas checkpoint.
  2. BranchBridgeAgentEmpty which has the code of pre 1st gas checkpoint and post last gas checkpoint commented out.

We run the same test for both, the difference in gas is what’s at least nearly the minimum required to cover pre 1st gas checkpoint and post last gas checkpoint. In this case here it is 78097 which is bigger than 60_000.

Here is the output of the test:

[PASS] test_calcgas() (gas: 119050)
Logs:
  branchBridgeAgent.anyExecute Gas Spent => 92852

[PASS] test_calcgasEmpty() (gas: 44461)
Logs:
  branchBridgeAgentEmpty.anyExecute Gas Spent => 14755

92852-14755 = 78097

Explanation

BranchBridgeAgent.anyExecute method depends on the following external calls:

  1. AnycallExecutor.context()
  2. AnycallProxy.config()
  3. AnycallConfig.executionBudget()
  4. AnycallConfig.withdraw()
  5. AnycallConfig.deposit()
  6. WETH9.withdraw()

For this reason, I've copied the same code from multichain-smart-contracts. For WETH9, I've used the contract from the codebase which has minimal code.

Please note that:

  • tx.gasprice is replaced with a fixed value in _payExecutionGas method as it is not available in Foundry.
  • In _replenishGas, reading the config via IAnycallProxy(localAnyCallAddress).config() is replaced with Immediate call for simplicity. In other words, avoiding proxy to make the PoC simpler and shorter. However, if done with proxy the gas used would increase. So in both ways, it is in favor of the PoC.
  • The condition if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD) is replaced with if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD && false) . This is to avoid entering the forceRevert. The increase of gas here is negligible anyway.
The coded PoC
  • Foundry.toml
	[profile.default]
	solc = '0.8.17'
	src = 'solidity'
	test = 'solidity/test'
	out = 'out'
	libs = ['lib']
	fuzz_runs = 1000
	optimizer_runs = 10_000
  • .gitmodules
	[submodule "lib/ds-test"]
		path = lib/ds-test
		url = https://github.com/dapphub/ds-test
		branch = master
	[submodule "lib/forge-std"]
		path = lib/forge-std
		url = https://github.com/brockelmore/forge-std
		branch = master
  • remappings.txt
	ds-test/=lib/ds-test/src
	forge-std/=lib/forge-std/src
  • Test File
// PoC => Maia OmniChain: gasCalculation in BranchBridgeAgent  
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";



library SafeTransferLib {
    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                       CUSTOM ERRORS                        */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev The ETH transfer has failed.
    error ETHTransferFailed();

    /// @dev The ERC20 `transferFrom` has failed.
    error TransferFromFailed();

    /// @dev The ERC20 `transfer` has failed.
    error TransferFailed();

    /// @dev The ERC20 `approve` has failed.
    error ApproveFailed();

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                         CONSTANTS                          */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Suggested gas stipend for contract receiving ETH
    /// that disallows any storage writes.
    uint256 internal constant _GAS_STIPEND_NO_STORAGE_WRITES = 2300;

    /// @dev Suggested gas stipend for contract receiving ETH to perform a few
    /// storage reads and writes, but low enough to prevent griefing.
    /// Multiply by a small constant (e.g. 2), if needed.
    uint256 internal constant _GAS_STIPEND_NO_GRIEF = 100000;

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                       ETH OPERATIONS                       */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Sends `amount` (in wei) ETH to `to`.
    /// Reverts upon failure.
    ///
    /// Note: This implementation does NOT protect against gas griefing.
    /// Please use `forceSafeTransferETH` for gas griefing protection.
    function safeTransferETH(address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(gas(), to, amount, 0, 0, 0, 0)) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
        }
    }

    /// @dev Force sends `amount` (in wei) ETH to `to`, with a `gasStipend`.
    /// The `gasStipend` can be set to a low enough value to prevent
    /// storage writes or gas griefing.
    ///
    /// If sending via the normal procedure fails, force sends the ETH by
    /// creating a temporary contract which uses `SELFDESTRUCT` to force send the ETH.
    ///
    /// Reverts if the current contract has insufficient balance.
    function forceSafeTransferETH(address to, uint256 amount, uint256 gasStipend) internal {
        /// @solidity memory-safe-assembly
        assembly {
            // If insufficient balance, revert.
            if lt(selfbalance(), amount) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(gasStipend, to, amount, 0, 0, 0, 0)) {
                mstore(0x00, to) // Store the address in scratch space.
                mstore8(0x0b, 0x73) // Opcode `PUSH20`.
                mstore8(0x20, 0xff) // Opcode `SELFDESTRUCT`.
                // We can directly use `SELFDESTRUCT` in the contract creation.
                // Compatible with `SENDALL`: https://eips.ethereum.org/EIPS/eip-4758
                if iszero(create(amount, 0x0b, 0x16)) {
                    // To coerce gas estimation to provide enough gas for the `create` above.
                    if iszero(gt(gas(), 1000000)) { revert(0, 0) }
                }
            }
        }
    }

    /// @dev Force sends `amount` (in wei) ETH to `to`, with a gas stipend
    /// equal to `_GAS_STIPEND_NO_GRIEF`. This gas stipend is a reasonable default
    /// for 99% of cases and can be overridden with the three-argument version of this
    /// function if necessary.
    ///
    /// If sending via the normal procedure fails, force sends the ETH by
    /// creating a temporary contract which uses `SELFDESTRUCT` to force send the ETH.
    ///
    /// Reverts if the current contract has insufficient balance.
    function forceSafeTransferETH(address to, uint256 amount) internal {
        // Manually inlined because the compiler doesn't inline functions with branches.
        /// @solidity memory-safe-assembly
        assembly {
            // If insufficient balance, revert.
            if lt(selfbalance(), amount) {
                // Store the function selector of `ETHTransferFailed()`.
                mstore(0x00, 0xb12d13eb)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Transfer the ETH and check if it succeeded or not.
            if iszero(call(_GAS_STIPEND_NO_GRIEF, to, amount, 0, 0, 0, 0)) {
                mstore(0x00, to) // Store the address in scratch space.
                mstore8(0x0b, 0x73) // Opcode `PUSH20`.
                mstore8(0x20, 0xff) // Opcode `SELFDESTRUCT`.
                // We can directly use `SELFDESTRUCT` in the contract creation.
                // Compatible with `SENDALL`: https://eips.ethereum.org/EIPS/eip-4758
                if iszero(create(amount, 0x0b, 0x16)) {
                    // To coerce gas estimation to provide enough gas for the `create` above.
                    if iszero(gt(gas(), 1000000)) { revert(0, 0) }
                }
            }
        }
    }

    /// @dev Sends `amount` (in wei) ETH to `to`, with a `gasStipend`.
    /// The `gasStipend` can be set to a low enough value to prevent
    /// storage writes or gas griefing.
    ///
    /// Simply use `gasleft()` for `gasStipend` if you don't need a gas stipend.
    ///
    /// Note: Does NOT revert upon failure.
    /// Returns whether the transfer of ETH is successful instead.
    function trySafeTransferETH(address to, uint256 amount, uint256 gasStipend)
        internal
        returns (bool success)
    {
        /// @solidity memory-safe-assembly
        assembly {
            // Transfer the ETH and check if it succeeded or not.
            success := call(gasStipend, to, amount, 0, 0, 0, 0)
        }
    }

    /*Β΄:Β°β€’.Β°+.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°β€’.*β€’Β΄.*:˚.Β°*.Λšβ€’Β΄.Β°:Β°β€’.Β°+.*β€’Β΄.*:*/
    /*                      ERC20 OPERATIONS                      */
    /*.β€’Β°:Β°.Β΄+˚.*Β°.˚:*.Β΄β€’*.+Β°.β€’Β°:Β΄*.Β΄β€’*.β€’Β°.β€’Β°:Β°.Β΄:β€’ΛšΒ°.*Β°.˚:*.Β΄+Β°.β€’*/

    /// @dev Sends `amount` of ERC20 `token` from `from` to `to`.
    /// Reverts upon failure.
    ///
    /// The `from` account must have at least `amount` approved for
    /// the current contract to manage.
    function safeTransferFrom(address token, address from, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.

            mstore(0x60, amount) // Store the `amount` argument.
            mstore(0x40, to) // Store the `to` argument.
            mstore(0x2c, shl(96, from)) // Store the `from` argument.
            // Store the function selector of `transferFrom(address,address,uint256)`.
            mstore(0x0c, 0x23b872dd000000000000000000000000)

            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x60, 0) // Restore the zero slot to zero.
            mstore(0x40, m) // Restore the free memory pointer.
        }
    }

    /// @dev Sends all of ERC20 `token` from `from` to `to`.
    /// Reverts upon failure.
    ///
    /// The `from` account must have their entire balance approved for
    /// the current contract to manage.
    function safeTransferAllFrom(address token, address from, address to)
        internal
        returns (uint256 amount)
    {
        /// @solidity memory-safe-assembly
        assembly {
            let m := mload(0x40) // Cache the free memory pointer.

            mstore(0x40, to) // Store the `to` argument.
            mstore(0x2c, shl(96, from)) // Store the `from` argument.
            // Store the function selector of `balanceOf(address)`.
            mstore(0x0c, 0x70a08231000000000000000000000000)
            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                    staticcall(gas(), token, 0x1c, 0x24, 0x60, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            // Store the function selector of `transferFrom(address,address,uint256)`.
            mstore(0x00, 0x23b872dd)
            // The `amount` argument is already written to the memory word at 0x60.
            amount := mload(0x60)

            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFromFailed()`.
                mstore(0x00, 0x7939f424)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x60, 0) // Restore the zero slot to zero.
            mstore(0x40, m) // Restore the free memory pointer.
        }
    }

    /// @dev Sends `amount` of ERC20 `token` from the current contract to `to`.
    /// Reverts upon failure.
    function safeTransfer(address token, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, to) // Store the `to` argument.
            mstore(0x34, amount) // Store the `amount` argument.
            // Store the function selector of `transfer(address,uint256)`.
            mstore(0x00, 0xa9059cbb000000000000000000000000)

            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Sends all of ERC20 `token` from the current contract to `to`.
    /// Reverts upon failure.
    function safeTransferAll(address token, address to) internal returns (uint256 amount) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x00, 0x70a08231) // Store the function selector of `balanceOf(address)`.
            mstore(0x20, address()) // Store the address of the current contract.
            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                    staticcall(gas(), token, 0x1c, 0x24, 0x34, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }

            mstore(0x14, to) // Store the `to` argument.
            // The `amount` argument is already written to the memory word at 0x34.
            amount := mload(0x34)
            // Store the function selector of `transfer(address,uint256)`.
            mstore(0x00, 0xa9059cbb000000000000000000000000)

            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `TransferFailed()`.
                mstore(0x00, 0x90b8ec18)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Sets `amount` of ERC20 `token` for `to` to manage on behalf of the current contract.
    /// Reverts upon failure.
    function safeApprove(address token, address to, uint256 amount) internal {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, to) // Store the `to` argument.
            mstore(0x34, amount) // Store the `amount` argument.
            // Store the function selector of `approve(address,uint256)`.
            mstore(0x00, 0x095ea7b3000000000000000000000000)

            if iszero(
                and( // The arguments of `and` are evaluated from right to left.
                    // Set success to whether the call reverted, if not we check it either
                    // returned exactly 1 (can't just be non-zero data), or had no return data.
                    or(eq(mload(0x00), 1), iszero(returndatasize())),
                    call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
                )
            ) {
                // Store the function selector of `ApproveFailed()`.
                mstore(0x00, 0x3e3f8f73)
                // Revert with (offset, size).
                revert(0x1c, 0x04)
            }
            // Restore the part of the free memory pointer that was overwritten.
            mstore(0x34, 0)
        }
    }

    /// @dev Returns the amount of ERC20 `token` owned by `account`.
    /// Returns zero if the `token` does not exist.
    function balanceOf(address token, address account) internal view returns (uint256 amount) {
        /// @solidity memory-safe-assembly
        assembly {
            mstore(0x14, account) // Store the `account` argument.
            // Store the function selector of `balanceOf(address)`.
            mstore(0x00, 0x70a08231000000000000000000000000)
            amount :=
                mul(
                    mload(0x20),
                    and( // The arguments of `and` are evaluated from right to left.
                        gt(returndatasize(), 0x1f), // At least 32 bytes returned.
                        staticcall(gas(), token, 0x10, 0x24, 0x20, 0x20)
                    )
                )
        }
    }
}

interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

interface IAnycallConfig {
    function calcSrcFees(
        address _app,
        uint256 _toChainID,
        uint256 _dataLength
    ) external view returns (uint256);

    function executionBudget(address _app) external view returns (uint256);

    function deposit(address _account) external payable;

    function withdraw(uint256 _amount) external;
}

interface IAnycallProxy {
    function executor() external view returns (address);

    function config() external view returns (address);

    function anyCall(
        address _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;

    function anyCall(
        string calldata _to,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags,
        bytes calldata _extdata
    ) external payable;
}

contract WETH9 {
    string public name = "Wrapped Ether";
    string public symbol = "WETH";
    uint8 public decimals = 18;

    event Approval(address indexed src, address indexed guy, uint256 wad);
    event Transfer(address indexed src, address indexed dst, uint256 wad);
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // function receive() external payable {
    //   deposit();
    // }

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(
        address src,
        address dst,
        uint256 wad
    ) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != 255) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

contract AnycallExecutor {
    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    constructor() {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;
    }
}

contract AnycallV7Config {
    event Deposit(address indexed account, uint256 amount);

    mapping(address => uint256) public executionBudget;

    /// @notice Deposit native currency crediting `_account` for execution costs on this chain
    /// @param _account The account to deposit and credit for
    function deposit(address _account) external payable {
        executionBudget[_account] += msg.value;
        emit Deposit(_account, msg.value);
    }
}

contract BranchBridgeAgent {
    error AnycallUnauthorizedCaller();
    error GasErrorOrRepeatedTx();

    uint256 public remoteCallDepositedGas;

    uint256 internal constant MIN_EXECUTION_OVERHEAD = 160_000; // 100_000 for anycall + 35_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Executions
    uint256 internal constant TRANSFER_OVERHEAD = 24_000;


    WETH9 public immutable wrappedNativeToken;

    AnycallV7Config public anycallV7Config;

    uint256 public accumulatedFees;

    /// @notice Local Chain Id
    uint24 public immutable localChainId;

    /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address where cross-chain requests are executed in the Root Chain.
    address public immutable rootBridgeAgentAddress;
    /// @notice Local Anyexec Address
    address public immutable localAnyCallExecutorAddress;

    /// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
    address public immutable localAnyCallAddress;

    constructor() {
        AnycallExecutor anycallExecutor = new AnycallExecutor();
        localAnyCallExecutorAddress = address(anycallExecutor);

        localChainId = 1;

        wrappedNativeToken = new WETH9();

        localAnyCallAddress = address(3);

        rootBridgeAgentAddress = address(2);

        anycallV7Config = new AnycallV7Config();
    }

    modifier requiresExecutor() {
        _requiresExecutor();
        _;
    }

    function _requiresExecutor() internal view {
                if (msg.sender != localAnyCallExecutorAddress) revert AnycallUnauthorizedCaller();
        (address from,,) = IAnycallExecutor(localAnyCallExecutorAddress).context();
        if (from != rootBridgeAgentAddress) revert AnycallUnauthorizedCaller();

    }

    function _replenishGas(uint256 _executionGasSpent) internal virtual {
        //Deposit Gas
        anycallV7Config.deposit{value: _executionGasSpent}(address(this));
        // IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
    }

    function _forceRevert() internal virtual {
        IAnycallConfig anycallConfig = IAnycallConfig(IAnycallProxy(localAnyCallAddress).config());
        uint256 executionBudget = anycallConfig.executionBudget(address(this));

        // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
        if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}
    }

   /**
     * @notice Internal function repays gas used by Branch Bridge Agent to fulfill remote initiated interaction.
     *   @param _recipient address to send excess gas to.
     *   @param _initialGas gas used by Branch Bridge Agent.
     */
    function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual {
        //Gas remaining
        uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this));

        //Unwrap Gas
        wrappedNativeToken.withdraw(gasRemaining);

        //Delete Remote Initiated Action State
        delete(remoteCallDepositedGas);

        ///Save gas left
        uint256 gasLeft = gasleft();

        //Get Branch Environment Execution Cost
        // Assume tx.gasPrice 1e9 
        uint256 minExecCost = 1e9 * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

        //Check if sufficient balance
        if (minExecCost > gasRemaining) {
            _forceRevert();
            return;
        }

        //Replenish Gas
        _replenishGas(minExecCost);

        //Transfer gas remaining to recipient
        SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost);

        //Save Gas
        uint256 gasAfterTransfer = gasleft();

        //Check if sufficient balance
        if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD && false) { // added false here so it doesn't enter.  
            _forceRevert();
            return;
        }
    }

    function anyExecute(
        bytes memory data
    )
        public
        virtual
        requiresExecutor
        returns (bool success, bytes memory result)
    {
        //Get Initial Gas Checkpoint
        uint256 initialGas = gasleft();

        //Action Recipient
        address recipient = address(0x1); // for simplicity and since it is irrelevant //address(uint160(bytes20(data[PARAMS_START:PARAMS_START_SIGNED])));


        // Other Code Here


        //Deduct gas costs from deposit and replenish this bridge agent's execution budget.
        _payExecutionGas(recipient, initialGas);
    }

    function depositIntoWeth(uint256 amt) external {
        wrappedNativeToken.deposit{value: amt}();
    }

    fallback() external payable {}
}


contract BranchBridgeAgentEmpty {
    error AnycallUnauthorizedCaller();
    error GasErrorOrRepeatedTx();

    uint256 public remoteCallDepositedGas;

    uint256 internal constant MIN_EXECUTION_OVERHEAD = 160_000; // 100_000 for anycall + 35_000 Pre 1st Gas Checkpoint Execution + 25_000 Post last Gas Checkpoint Executions
    uint256 internal constant TRANSFER_OVERHEAD = 24_000;


    WETH9 public immutable wrappedNativeToken;

    AnycallV7Config public anycallV7Config;

    uint256 public accumulatedFees;

    /// @notice Local Chain Id
    uint24 public immutable localChainId;

    /// @notice Address for Bridge Agent who processes requests submitted for the Root Router Address where cross-chain requests are executed in the Root Chain.
    address public immutable rootBridgeAgentAddress;
    /// @notice Local Anyexec Address
    address public immutable localAnyCallExecutorAddress;

    /// @notice Address for Local AnycallV7 Proxy Address where cross-chain requests are sent to the Root Chain Router.
    address public immutable localAnyCallAddress;

    constructor() {
        AnycallExecutor anycallExecutor = new AnycallExecutor();
        localAnyCallExecutorAddress = address(anycallExecutor);

        localChainId = 1;

        wrappedNativeToken = new WETH9();

        localAnyCallAddress = address(3);

        rootBridgeAgentAddress = address(2);

        anycallV7Config = new AnycallV7Config();
    }

    modifier requiresExecutor() {
        _requiresExecutor();
        _;
    }

    function _requiresExecutor() internal view {
                if (msg.sender != localAnyCallExecutorAddress) revert AnycallUnauthorizedCaller();
        (address from,,) = IAnycallExecutor(localAnyCallExecutorAddress).context();
        if (from != rootBridgeAgentAddress) revert AnycallUnauthorizedCaller();

    }

    function _replenishGas(uint256 _executionGasSpent) internal virtual {
        //Deposit Gas
        anycallV7Config.deposit{value: _executionGasSpent}(address(this));
        // IAnycallConfig(IAnycallProxy(localAnyCallAddress).config()).deposit{value: _executionGasSpent}(address(this));
    }

    function _forceRevert() internal virtual {
        IAnycallConfig anycallConfig = IAnycallConfig(IAnycallProxy(localAnyCallAddress).config());
        uint256 executionBudget = anycallConfig.executionBudget(address(this));

        // Withdraw all execution gas budget from anycall for tx to revert with "no enough budget"
        if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}
    }

   /**
     * @notice Internal function repays gas used by Branch Bridge Agent to fulfill remote initiated interaction.
     *   @param _recipient address to send excess gas to.
     *   @param _initialGas gas used by Branch Bridge Agent.
     */
    function _payExecutionGas(address _recipient, uint256 _initialGas) internal virtual {
        //Gas remaining
        uint256 gasRemaining = wrappedNativeToken.balanceOf(address(this));

        //Unwrap Gas
        wrappedNativeToken.withdraw(gasRemaining);

        //Delete Remote Initiated Action State
        delete(remoteCallDepositedGas);

        ///Save gas left
        uint256 gasLeft = gasleft(); // Everything after this is not taken into account

        //Get Branch Environment Execution Cost
        // Assume tx.gasPrice 1e9 
        // uint256 minExecCost = 1e9 * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);

        // //Check if sufficient balance
        // if (minExecCost > gasRemaining) {
        //     _forceRevert();
        //     return;
        // }

        // //Replenish Gas
        // _replenishGas(minExecCost);

        // //Transfer gas remaining to recipient
        // SafeTransferLib.safeTransferETH(_recipient, gasRemaining - minExecCost);

        // //Save Gas
        // uint256 gasAfterTransfer = gasleft();

        // //Check if sufficient balance
        // if (gasLeft - gasAfterTransfer > TRANSFER_OVERHEAD && false) { // added false here so it doesn't enter.  
        //     _forceRevert();
        //     return;
        // }
    }

    function anyExecute(
        bytes memory data
    )
        public
        virtual
        // requiresExecutor
        returns (bool success, bytes memory result)
    {
        //Get Initial Gas Checkpoint
        uint256 initialGas = gasleft();

        //Action Recipient
        address recipient = address(0x1); // for simplicity and since it is irrelevant //address(uint160(bytes20(data[PARAMS_START:PARAMS_START_SIGNED])));


        // Other Code Here


        //Deduct gas costs from deposit and replenish this bridge agent's execution budget.
        _payExecutionGas(recipient, initialGas);
    }

    function depositIntoWeth(uint256 amt) external {
        wrappedNativeToken.deposit{value: amt}();
    }

    fallback() external payable {}
}


contract GasCalc is DSTest, Test {
    BranchBridgeAgent branchBridgeAgent;
    BranchBridgeAgentEmpty branchBridgeAgentEmpty;

    function setUp() public {
        branchBridgeAgentEmpty = new BranchBridgeAgentEmpty();
        vm.deal(address(branchBridgeAgentEmpty.localAnyCallExecutorAddress()), 100 ether); // executer pays gas
        vm.deal(address(branchBridgeAgentEmpty), 100 ether);


        branchBridgeAgent = new BranchBridgeAgent();
        vm.deal(address(branchBridgeAgent.localAnyCallExecutorAddress()), 100 ether); // executer pays gas
        vm.deal(address(branchBridgeAgent), 100 ether);


    }

    // code after end checkpoint gasLeft not included
    function test_calcgasEmpty() public {
 // add weth balance
        branchBridgeAgentEmpty.depositIntoWeth(100 ether);

        vm.prank(address(branchBridgeAgentEmpty.localAnyCallExecutorAddress()));
        uint256 gasStart_ = gasleft();
        branchBridgeAgentEmpty.anyExecute(bytes(""));
        uint256 gasEnd_ = gasleft();
        vm.stopPrank();
        uint256 gasSpent_ = gasStart_ - gasEnd_;
        console.log("branchBridgeAgentEmpty.anyExecute Gas Spent => %d", gasSpent_);
        


    }

    // code after end checkpoint gasLeft included
     function test_calcgas() public {

        // add weth balance
        branchBridgeAgent.depositIntoWeth(100 ether);

        vm.prank(address(branchBridgeAgent.localAnyCallExecutorAddress()));
        uint256 gasStart = gasleft();
        branchBridgeAgent.anyExecute(bytes(""));
        uint256 gasEnd = gasleft();
        vm.stopPrank();
        uint256 gasSpent = gasStart - gasEnd;
        console.log("branchBridgeAgent.anyExecute Gas Spent => %d", gasSpent);


    }
}

PoC#2 (The gas consumed by anyExec method in AnyCall)

Overview

We have contracts that simulating Anycall contracts:

  1. AnycallV7Config
  2. AnycallExecutor
  3. AnycallV7

The flow like this: MPC => AnycallV7 => AnycallExecutor => IApp

In the code, IApp(_to).anyExecute is commented out because we don't want to calculate its gas since it is done in PoC#1.

Here is the output of the test:

[PASS] test_gasInanycallv7() (gas: 102613)
Logs:
  anycallV7.anyExec Gas Spent => 110893
coded PoC
// PoC => Maia OmniChain: gasCalculation in AnyCall v7  contracts
pragma solidity >=0.8.4 <0.9.0;

import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

import {DSTest} from "ds-test/test.sol";

/// IAnycallConfig interface of the anycall config
interface IAnycallConfig {
    function checkCall(
        address _sender,
        bytes calldata _data,
        uint256 _toChainID,
        uint256 _flags
    ) external view returns (string memory _appID, uint256 _srcFees);

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view;

    function chargeFeeOnDestChain(address _from, uint256 _prevGasLeft) external;
}

/// IAnycallExecutor interface of the anycall executor
interface IAnycallExecutor {
    function context()
        external
        view
        returns (address from, uint256 fromChainID, uint256 nonce);

    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata _extdata
    ) external returns (bool success, bytes memory result);
}

/// IApp interface of the application
interface IApp {
    /// (required) call on the destination chain to exec the interaction
    function anyExecute(bytes calldata _data)
        external
        returns (bool success, bytes memory result);

    /// (optional,advised) call back on the originating chain if the cross chain interaction fails
    /// `_data` is the orignal interaction arguments exec on the destination chain
    function anyFallback(bytes calldata _data)
        external
        returns (bool success, bytes memory result);
}

library AnycallFlags {
    // call flags which can be specified by user
    uint256 public constant FLAG_NONE = 0x0;
    uint256 public constant FLAG_MERGE_CONFIG_FLAGS = 0x1;
    uint256 public constant FLAG_PAY_FEE_ON_DEST = 0x1 << 1;
    uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;

    // exec flags used internally
    uint256 public constant FLAG_EXEC_START_VALUE = 0x1 << 16;
    uint256 public constant FLAG_EXEC_FALLBACK = 0x1 << 16;
}

contract AnycallV7Config {
    uint256 public constant PERMISSIONLESS_MODE = 0x1;
    uint256 public constant FREE_MODE = 0x1 << 1;

    mapping(string => mapping(address => bool)) public appExecWhitelist;
    mapping(string => bool) public appBlacklist;

    uint256 public mode;

    uint256 public minReserveBudget;

    mapping(address => uint256) public executionBudget;

    constructor() {
         mode = PERMISSIONLESS_MODE;
    }

    function checkExec(
        string calldata _appID,
        address _from,
        address _to
    ) external view {
        require(!appBlacklist[_appID], "blacklist");

        if (!_isSet(mode, PERMISSIONLESS_MODE)) {
            require(appExecWhitelist[_appID][_to], "no permission");
        }

        if (!_isSet(mode, FREE_MODE)) {
            require(
                executionBudget[_from] >= minReserveBudget,
                "less than min budget"
            );
        }
    }

    function _isSet(
        uint256 _value,
        uint256 _testBits
    ) internal pure returns (bool) {
        return (_value & _testBits) == _testBits;
    }
}

contract AnycallExecutor {
    bytes32 public constant PAUSE_ALL_ROLE = 0x00;

    event Paused(bytes32 role);
    event Unpaused(bytes32 role);

    modifier whenNotPaused(bytes32 role) {
        require(
            !paused(role) && !paused(PAUSE_ALL_ROLE),
            "PausableControl: paused"
        );
        _;
    }
    mapping(bytes32 => bool) private _pausedRoles;
    mapping(address => bool) public isSupportedCaller;


    struct Context {
        address from;
        uint256 fromChainID;
        uint256 nonce;
    }
    // Context public override context;
    Context public context;

    function paused(bytes32 role) public view virtual returns (bool) {
        return _pausedRoles[role];
    }

    modifier onlyAuth() {
        require(isSupportedCaller[msg.sender], "not supported caller");
        _;
    }

    constructor(address anycall) {
        context.fromChainID = 1;
        context.from = address(2);
        context.nonce = 1;

        isSupportedCaller[anycall] = true;
    }

function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }

    // @dev `_extdata` content is implementation based in each version
    function execute(
        address _to,
        bytes calldata _data,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce,
        uint256 _flags,
        bytes calldata /*_extdata*/
    )
        external
        virtual
        onlyAuth
        whenNotPaused(PAUSE_ALL_ROLE)
        returns (bool success, bytes memory result)
    {
        bool isFallback = _isSet(_flags, AnycallFlags.FLAG_EXEC_FALLBACK);

        context = Context({
            from: _from,
            fromChainID: _fromChainID,
            nonce: _nonce
        });

        if (!isFallback) {
            // we skip calling anyExecute since it is irrelevant for this PoC
            // (success, result) = IApp(_to).anyExecute(_data);
        } else {
            (success, result) = IApp(_to).anyFallback(_data);
        }

        context = Context({from: address(0), fromChainID: 0, nonce: 0});
    }
}

contract AnycallV7 {

    event LogAnyCall(
        address indexed from,
        address to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );
    event LogAnyCall(
        address indexed from,
        string to,
        bytes data,
        uint256 toChainID,
        uint256 flags,
        string appID,
        uint256 nonce,
        bytes extdata
    );

    event LogAnyExec(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bool success,
        bytes result
    );


    event StoreRetryExecRecord(
        bytes32 indexed txhash,
        address indexed from,
        address indexed to,
        uint256 fromChainID,
        uint256 nonce,
        bytes data
    );

    // Context of the request on originating chain
    struct RequestContext {
        bytes32 txhash;
        address from;
        uint256 fromChainID;
        uint256 nonce;
        uint256 flags;
    }
    address public mpc;

    bool public paused;

    // applications should give permission to this executor
    address public executor;

    // anycall config contract
    address public config;

    mapping(bytes32 => bytes32) public retryExecRecords;
    bool public retryWithPermit;

    mapping(bytes32 => bool) public execCompleted;
    uint256 nonce;

    uint256 private unlocked;

    modifier lock() {
        require(unlocked == 1, "locked");
        unlocked = 0;
        _;
        unlocked = 1;
    }
    /// @dev Access control function
    modifier onlyMPC() {
        require(msg.sender == mpc, "only MPC");
        _;
    }

    /// @dev pausable control function
    modifier whenNotPaused() {
        require(!paused, "paused");
        _;
    }

    function _isSet(uint256 _value, uint256 _testBits)
        internal
        pure
        returns (bool)
    {
        return (_value & _testBits) == _testBits;
    }


    /// @dev Charge an account for execution costs on this chain
    /// @param _from The account to charge for execution costs
    modifier chargeDestFee(address _from, uint256 _flags) {
        if (_isSet(_flags, AnycallFlags.FLAG_PAY_FEE_ON_DEST)) {
            uint256 _prevGasLeft = gasleft();
            _;
            IAnycallConfig(config).chargeFeeOnDestChain(_from, _prevGasLeft);
        } else {
            _;
        }
    }

    constructor(address _mpc) {
        unlocked = 1; // needs to be unlocked initially
        mpc = _mpc;
        config = address(new AnycallV7Config());
        executor = address(new AnycallExecutor(address(this)));
    }

    /// @notice Calc unique ID
    function calcUniqID(
        bytes32 _txhash,
        address _from,
        uint256 _fromChainID,
        uint256 _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encode(_txhash, _from, _fromChainID, _nonce));
    }

    function _execute(
        address _to,
        bytes memory _data,
        RequestContext memory _ctx,
        bytes memory _extdata
    ) internal returns (bool success) {
        bytes memory result;

        try
            IAnycallExecutor(executor).execute(
                _to,
                _data,
                _ctx.from,
                _ctx.fromChainID,
                _ctx.nonce,
                _ctx.flags,
                _extdata
            )
        returns (bool succ, bytes memory res) {
            (success, result) = (succ, res);
        } catch Error(string memory reason) {
            result = bytes(reason);
        } catch (bytes memory reason) {
            result = reason;
        }

        emit LogAnyExec(
            _ctx.txhash,
            _ctx.from,
            _to,
            _ctx.fromChainID,
            _ctx.nonce,
            success,
            result
        );
    }

    /**
        @notice Execute a cross chain interaction
        @dev Only callable by the MPC
        @param _to The cross chain interaction target
        @param _data The calldata supplied for interacting with target
        @param _appID The app identifier to check whitelist
        @param _ctx The context of the request on originating chain
        @param _extdata The extension data for execute context
    */
   // Note: changed from callback to memory so we can call it from the test contract
    function anyExec(
        address _to,
        bytes memory _data,
        string memory _appID,
        RequestContext memory _ctx, 
        bytes memory _extdata
    )
        external
        virtual
        lock
        whenNotPaused
        chargeDestFee(_to, _ctx.flags)
        onlyMPC
    {
        IAnycallConfig(config).checkExec(_appID, _ctx.from, _to);
        bytes32 uniqID = calcUniqID(
            _ctx.txhash,
            _ctx.from,
            _ctx.fromChainID,
            _ctx.nonce
        );
        require(!execCompleted[uniqID], "exec completed");

        bool success = _execute(_to, _data, _ctx, _extdata);
        // success = false on purpose, because when it is true, it consumes less gas. so we are considering worse case here

        // set exec completed (dont care success status)
        execCompleted[uniqID] = true;

        if (!success) {
            if (_isSet(_ctx.flags, AnycallFlags.FLAG_ALLOW_FALLBACK)) { 
                // this will be executed here since the call failed
                // Call the fallback on the originating chain
                nonce++;
                string memory appID = _appID; // fix Stack too deep
                emit LogAnyCall(
                    _to,
                    _ctx.from,
                    _data,
                    _ctx.fromChainID,
                    AnycallFlags.FLAG_EXEC_FALLBACK |
                        AnycallFlags.FLAG_PAY_FEE_ON_DEST, // pay fee on dest chain
                    appID,
                    nonce,
                    ""
                );
            } else {
                // Store retry record and emit a log
                bytes memory data = _data; // fix Stack too deep
                retryExecRecords[uniqID] = keccak256(abi.encode(_to, data));
                emit StoreRetryExecRecord(
                    _ctx.txhash,
                    _ctx.from,
                    _to,
                    _ctx.fromChainID,
                    _ctx.nonce,
                    data
                );
            }
        }
    }
}

contract GasCalcAnyCallv7 is DSTest, Test {


    AnycallV7 anycallV7;
    address mpc = vm.addr(7);
    function setUp() public {
        anycallV7 = new AnycallV7(mpc);
    }

    function test_gasInanycallv7() public {

        vm.prank(mpc);
        AnycallV7.RequestContext memory ctx = AnycallV7.RequestContext({
            txhash:keccak256(""),
            from:address(0),
            fromChainID:1,
            nonce:1,
            flags:AnycallFlags.FLAG_ALLOW_FALLBACK
        });
        uint256 gasStart_ = gasleft();
        anycallV7.anyExec(address(0),bytes(""),"1",ctx,bytes(""));
        uint256 gasEnd_ = gasleft();
        vm.stopPrank();
        uint256 gasSpent_ = gasStart_ - gasEnd_;
        console.log("anycallV7.anyExec Gas Spent => %d", gasSpent_);
    }
}

Tools Used

Manual analysis

Increase the MIN_EXECUTION_OVERHEAD by:

  • 20_000 for RootBridgeAgent.anyExecute.
  • 110_000 for anyExec method in AnyCall.

20_000 + 110_000 = 130_000 So MIN_EXECUTION_OVERHEAD becomes 290_000 instead of 160_000.

Additionally, calculate the gas consumption of the input data passed, add it to the cost.

Note: I suggest that the MIN_EXECUTION_OVERHEAD should be configurable/changeable. After launching OmniChain for some time, collect stats about the actual gas used for AnyCall on chain then adjust it accordingly. This also keeps you in the safe side in case any changes are applied on AnyCall contracts in future since it is upgradable.

Assessed type

Other

#0 - c4-judge

2023-07-11T08:46:35Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-11T08:46:41Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T15:51:59Z

0xBugsy marked the issue as sponsor confirmed

#3 - c4-sponsor

2023-07-12T15:52:05Z

0xBugsy marked the issue as disagree with severity

#4 - 0xBugsy

2023-07-12T15:52:24Z

The variable data cost should be addressed by consulting premium(), the value is used in their calcualtions here uint256 totalCost = gasUsed * (tx.gasprice + _feeData.premium); and we should abide and only pay as much as they will credit us the remainder belonging to the user

#5 - trust1995

2023-07-24T14:28:03Z

Similar to #764 but different LOC and ultimately different vulnerability.

#6 - c4-judge

2023-07-25T13:13:55Z

trust1995 marked the issue as selected for report

#7 - c4-sponsor

2023-07-27T19:08:18Z

0xBugsy marked the issue as sponsor acknowledged

#8 - c4-sponsor

2023-07-28T13:21:51Z

0xBugsy marked the issue as sponsor confirmed

#9 - 0xBugsy

2023-07-28T13:21:53Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: ltyu

Also found by: BPZ, Koolex, RED-LOTUS-REACH, xuwinnie, yellowBirdy

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-91

Awards

432.0595 USDC - $432.06

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1006-L1011

Vulnerability details

Impact

_performCall function (in BranchBridgeAgent) whichs performs call to AnycallProxy Contract for cross-chain messaging has a wrong anycall flag. The flag is AnycallFlags.FLAG_ALLOW_FALLBACK which has value 4

    function _performCall(bytes memory _calldata) internal virtual {
        //Sends message to AnycallProxy
        IAnycallProxy(localAnyCallAddress).anyCall(
            rootBridgeAgentAddress, _calldata, rootChainId, AnycallFlags.FLAG_ALLOW_FALLBACK, ""
        );
    }

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1006-L1011

FLAG_ALLOW_FALLBACK constant

   uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/lib/AnycallFlags.sol#L11

This flag is used to pay the fee on source chain. However, it should be the destination chain as it is clear from the code. check RootBridgeAgent.anyExecute method

    function anyExecute(bytes calldata data)
        external
        virtual
        requiresExecutor
        returns (bool success, bytes memory result)
    {
			.
			.
			.
			.
			//Zero out gas after use if remote call
			if (initialGas > 0) {
				_payExecutionGas(userFeeInfo.depositedGas, userFeeInfo.gasToBridgeOut, _initialGas, fromChainId);
			}
			.
	}

This causes the function to revert always since there is no deposit fee on source chain.

Please note that the sponsor also confirmed this.

Proof of Concept

According to AnyCall docs - parameter for fee:

4: Gas fee paid on source chain. Allow fallback 6: Gas fee paid on destination chain. Allow fallback

Here is a link to request-parameters for V7 https://docs.multichain.org/developer-guide/anycall-v7/how-to-integrate-anycall-v7#request-parameters

Since the AnyCall flag is 4, the fee is expected to be paid on the source chain, otherwise, the request will revert.

Tools Used

Manual analysis

Replace FLAG_ALLOW_FALLBACK with FLAG_ALLOW_FALLBACK_DST which has the value 6

    uint256 public constant FLAG_ALLOW_FALLBACK_DST = 6;

https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/lib/AnycallFlags.sol#L12

Assessed type

Other

#0 - c4-judge

2023-07-09T11:58:17Z

trust1995 marked the issue as duplicate of #91

#1 - c4-judge

2023-07-11T08:45:11Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-11T17:06:25Z

trust1995 changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: Koolex

Also found by: peakbolt

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
M-05

Awards

770.4765 USDC - $770.48

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L831-L846

Vulnerability details

Impact

_payFallbackGas is used to update the user deposit with the amount of gas needed to pay for the fallback function execution. However, it doesn't replenish gas. In other words, it doesn't deposit the executionGasSpent into AnycallConfig execution budget.

Proof of Concept

Here is the method body.

	function _payFallbackGas(uint32 _settlementNonce, uint256 _initialGas) internal virtual {
		//Save gasleft
		uint256 gasLeft = gasleft();

		//Get Branch Environment Execution Cost
		uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);

		//Check if sufficient balance
		if (minExecCost > getSettlement[_settlementNonce].gasToBridgeOut) {
			_forceRevert();
			return;
		}

		//Update user deposit reverts if not enough gas
		getSettlement[_settlementNonce].gasToBridgeOut -= minExecCost.toUint128();
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L831-L846

As you can see, no gas replenishing call.

_payFallbackGas is called at the end in anyFallback after reopening user's settlement.

	function anyFallback(bytes calldata data)
		external
		virtual
		requiresExecutor
		returns (bool success, bytes memory result)
	{
		//Get Initial Gas Checkpoint
		uint256 _initialGas = gasleft();

		//Get fromChain
		(, uint256 _fromChainId) = _getContext();
		uint24 fromChainId = _fromChainId.toUint24();

		//Save Flag
		bytes1 flag = data[0];

		//Deposit nonce
		uint32 _settlementNonce;

		/// SETTLEMENT FLAG: 1 (single asset settlement)
		if (flag == 0x00) {
			_settlementNonce = uint32(bytes4(data[PARAMS_START_SIGNED:25]));
			_reopenSettlemment(_settlementNonce);

			/// SETTLEMENT FLAG: 1 (single asset settlement)
		} else if (flag == 0x01) {
			_settlementNonce = uint32(bytes4(data[PARAMS_START_SIGNED:25]));
			_reopenSettlemment(_settlementNonce);

			/// SETTLEMENT FLAG: 2 (multiple asset settlement)
		} else if (flag == 0x02) {
			_settlementNonce = uint32(bytes4(data[22:26]));
			_reopenSettlemment(_settlementNonce);
		}
		emit LogCalloutFail(flag, data, fromChainId);

		_payFallbackGas(_settlementNonce, _initialGas);

		return (true, "");
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L1177

Tools Used

Manual analysis

Withdraw Gas from port, unwrap it, then call _replenishGas to top up the execution budget

Assessed type

Other

#0 - c4-judge

2023-07-10T09:30:03Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-10T09:30:13Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-07-11T17:14:43Z

trust1995 changed the severity to 3 (High Risk)

#3 - c4-sponsor

2023-07-12T16:52:34Z

0xBugsy marked the issue as sponsor confirmed

#4 - c4-judge

2023-07-25T11:23:29Z

trust1995 marked issue #397 as primary and marked this issue as a duplicate of 397

#5 - c4-judge

2023-07-28T10:22:16Z

trust1995 marked the issue as not a duplicate

#6 - c4-judge

2023-07-28T10:22:20Z

trust1995 marked the issue as primary issue

#7 - c4-judge

2023-07-28T10:22:49Z

trust1995 marked the issue as selected for report

#8 - c4-judge

2023-07-28T10:23:33Z

trust1995 changed the severity to 2 (Med Risk)

#9 - 0xBugsy

2023-07-28T15:37:10Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: Koolex

Also found by: Evo, zzebra83

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-10

Awards

462.2859 USDC - $462.29

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L319-L328

Vulnerability details

Impact

BranchBridgeAgent.retryDeposit is used to top up a previous deposit and perform a call afterwards. A modifier requiresFallbackGas is added to the method to verifiy enough gas is deposited to pay for an eventual fallback call. The same is done when creating a new deposit.

  • retryDeposit
function retryDeposit( bool _isSigned, uint32 _depositNonce, bytes calldata _params, uint128 _remoteExecutionGas, uint24 _toChain ) external payable lock requiresFallbackGas { //Check if deposit belongs to message sender if (getDeposit[_depositNonce].owner != msg.sender) revert NotDepositOwner(); . . . .

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L319-L328

  • An example of a new deposit/call
// One example
    function callOutSignedAndBridge(bytes calldata _params, DepositInput memory _dParams, uint128 _remoteExecutionGas)
        external
        payable
        lock
        requiresFallbackGas
    {

// Another one
    function callOutSignedAndBridgeMultiple(
        bytes calldata _params,
        DepositMultipleInput memory _dParams,
        uint128 _remoteExecutionGas
    ) external payable lock requiresFallbackGas {

Let's have a look at the modifier requiresFallbackGas

/// @notice Modifier that verifies enough gas is deposited to pay for an eventual fallback call. modifier requiresFallbackGas() { _requiresFallbackGas(); _; } /// @notice Verifies enough gas is deposited to pay for an eventual fallback call. Reuse to reduce contract bytesize. function _requiresFallbackGas() internal view virtual { if (msg.value <= MIN_FALLBACK_RESERVE * tx.gasprice) revert InsufficientGas(); }

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1404-L1412

It checks if the msg.value (deposited gas) is sufficient. This is used for both a new deposit and topping up an existing deposit. For a new deposit it makes sense. However, for topping up an existing deposit it doesn't consider the old deposited amount which enforce the user to overpay for the gas when retryDeposit. Please have a look at the PoC to get a clearer picture.

Proof of Concept

Imagine the following scenario:

  • User Bob makes a request by BaseBranchRouter.callOutAndBridge with msg.value 0.1 ETH (deposited gas is 0.1 ETH) assuming the cost of MIN_FALLBACK_RESERVE is 0.1 ETH.
  • This calls BranchBridgeAgent.performCallOutAndBridge
  • BranchBridgeAgent creates deposit and send Cross-Chain request by calling AnycallProxy.anyCall
  • Now AnyCall Executor calls RootBridgeAgent.anyExecute
  • Let's say RootBridgeAgent.anyExecute couldn't complete due to insufficient available gas.
	//Get Available Gas
	uint256 availableGas = _depositedGas - _gasToBridgeOut;

	//Get Root Environment Execution Cost
	uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());

	//Check if sufficient balance
	if (minExecCost > availableGas) {
		_forceRevert();
		return;
	}

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/RootBridgeAgent.sol#L810-L817

  • Notice that this _forceReverts and doesn't revert directly. This is to avoid triggering the fallback in BranchBridgeAgent (below an explanation of _forceRevert).
  • Let's assume that the additional required deposit was 0.05 ETH
  • So now Bob should top up the deposit with 0.05 ETH.
  • Bob calls BranchBridgeAgent.retryDeposit and since there is requiresFallbackGas modifier, he has to pass at least 0.1 ETH cost of MIN_FALLBACK_RESERVE. Thus, overpaying when it is not necessary.

This happens due to the lack of considering the already existing deposted gas amount.

Note: for simplicity, we assumed that tx.gasPrice didn't change.

About _forceRevert

_forceRevert withdraws all execution budget.

// Withdraw all execution gas budget from anycall for tx to revert with "no enough budget" if (executionBudget > 0) try anycallConfig.withdraw(executionBudget) {} catch {}

So Anycall Executor will revert if there is not enough budget. This is done at

	uint256 budget = executionBudget[_from];
	require(budget > totalCost, "no enough budget");
	executionBudget[_from] = budget - totalCost;

https://github.com/anyswap/multichain-smart-contracts/blob/main/contracts/anycall/v7/AnycallV7Config.sol#L206C42-L206C58

This way we avoid reverting directly. Instead, we let Anycall Executor to revert avoiding triggering the fallback.

Tools Used

Manual analysis

For retryDeposit, use the internal function _requiresFallbackGas(uint256 _depositedGas) instead of the modifier. Pass the existing deposited gas + msg.value to the function. Example:

_requiresFallbackGas(getDeposit[_depositNonce].depositedGas+msg.value)

https://github.com/code-423n4/2023-05-maia/blob/main/src/ulysses-omnichain/BranchBridgeAgent.sol#L1415-L1417

Assessed type

Other

#0 - c4-judge

2023-07-11T10:53:00Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-07-11T10:53:05Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-12T16:30:53Z

0xBugsy marked the issue as sponsor disputed

#3 - 0xBugsy

2023-07-12T16:31:35Z

It is intended, no gas refunds on failures it would be hard/expensive to gauge how much gas was spent on remote execution before failure

#4 - trust1995

2023-07-24T13:43:07Z

Similar to #718. @0xBugsy Did you document anywhere that this is intended?

#5 - c4-judge

2023-07-25T13:03:44Z

trust1995 marked the issue as selected for report

#6 - 0xBugsy

2023-07-26T14:43:39Z

Upon further thought if a given deposit has not been set to redeemable via fallback the user could be allowed to retryDeposit without paying for the fallback gas since it has not yet been spent.

#7 - c4-sponsor

2023-07-26T14:45:18Z

0xBugsy marked the issue as sponsor confirmed

#8 - 0xLightt

2023-09-06T17:27:44Z

We recognize the audit's findings on Anycall Gas Management. These will not be rectified due to the upcoming migration of this section to LayerZero.

Findings Information

🌟 Selected for report: ABA

Also found by: 0xTheC0der, Josiah, Koolex

Labels

bug
2 (Med Risk)
grade-c
satisfactory
duplicate-469

Awards

240.0331 USDC - $240.03

External Links

Lines of code

https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L109-L110

Vulnerability details

Impact

beforeWithdraw function performs the necessary verifications before a user can withdraw from their vMaia position. Basically, it checks if we're inside the unstaked period, if so then the user is able to withdraw.

	/// @dev Check if unstake period has not ended yet, continue if it is the case.
	if (unstakePeriodEnd >= block.timestamp) return;

	uint256 _currentMonth = DateTimeLib.getMonth(block.timestamp);
	if (_currentMonth == currentMonth) revert UnstakePeriodNotLive();

Then it checks if it is Tuesday

(bool isTuesday, uint256 _unstakePeriodStart) = DateTimeLib.isTuesday(block.timestamp); if (!isTuesday) revert UnstakePeriodNotLive();

https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L109-L110

However, it doesn't check if it is the first Tuesday. According to Maia, unstaking is done on 1st Tuesday each month. Here is the link of the tweet: https://twitter.com/MaiaDAOEco/status/1664383658935894016

Another source to confirm this is a comment in the code:

/// @dev Error thrown when trying to withdraw and it is not the first Tuesday of the month. error UnstakePeriodNotLive();

https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L120-L121

Because of this bug, the users can withdraw on any Tuesday of the month (but only one) which goes against how the protocol should work potentially causing harm economically.

In other words, if there was no withdrawal on the first Tuesday of the month, then the users will be able to withdraw on the second Tuesday. If no withdrawal occurs on the second, the third then and so on.

Proof of Concept

If you have a look at the function DateTimeLib.isTuesday that's used by beforeWithdraw, you can see that it takes a date (timestamp), does a calculation to return a number. if the number is 2 , it means it is Tuesday.

/// @dev Returns the weekday from the unix timestamp. /// Monday: 1, Tuesday: 2, ....., Sunday: 7. function isTuesday(uint256 timestamp) internal pure returns (bool result, uint256 startOfDay) { unchecked { uint256 day = timestamp / 86400; startOfDay = day * 86400; result = ((day + 3) % 7) + 1 == 2; } }

https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/libraries/DateTimeLib.sol#L55-L60

This is actually inspired by Solady

/// @dev Returns the weekday from the unix timestamp. /// Monday: 1, Tuesday: 2, ....., Sunday: 7. function weekday(uint256 timestamp) internal pure returns (uint256 result) { unchecked { result = ((timestamp / 86400 + 3) % 7) + 1; } }

https://github.com/Vectorized/solady/blob/main/src/utils/DateTimeLib.sol#L192-L198

To prove the issue above we will go through two scenarios. One that works as intended and the other where the issue occurs.

Working as intended

In the following flow, the withdrawal will revert on the second Tuesday

Assume

  1. unstakePeriodEnd => 0
  2. currentMonth => 0
  3. Today => 03/07 Monday

Now go through the following flow:

  • Withdraw [ Today => 03/07 Monday ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 0 == 7 => false => continue
      • revert if not isTuesday(block.timestamp)
        • Tuesday == Monday => false => revert
  • Withdraw [ Today => 04/07 First Tuesday of the month ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 0 == 7 => false => continue
      • revert if not isTuesday(block.timestamp)
        • Tuesday == Tuesday => true => continue
      • Update State
        • currentMonth = 7
        • unstakePeriodEnd = last second of Tuesday
    • Back to withdraw
  • Withdraw [ Today => 05/07 Wednesday ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 7 == 7 => true => revert
  • .
  • .
  • .
  • Withdraw [ Today => 11/07 Second Tuesday of the month ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 7 == 7 => true => revert

Not working as intended

In the following flow, the withdrawal will not revert on the second Tuesday

Assume

  1. unstakePeriodEnd => 0
  2. currentMonth => 0
  3. Today => 03/07 Monday

Now go through the following flow:

  • Withdraw [ Today => 03/07 Monday ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 0 == 7 => false => continue
      • revert if not isTuesday(block.timestamp)
        • Tuesday == Monday => false => revert
  • No withdrawal occured [ Today => 04/07 First Tuesday of the month ]
    • Note that state vars have not been updated
  • Withdraw [ Today => 05/07 Wednesday ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 0 == 7 => false => continue
      • revert if not isTuesday(block.timestamp)
        • Tuesday == Wednesday => false => revert
  • .
  • .
  • .
  • Withdraw [ Today => 11/07 Second Tuesday of the month ]
    • beforeWithdraw
      • revert if currentMonth == getMonth(block.timestamp)
        • 0 == 7 => false => continue
      • revert if not isTuesday(block.timestamp)
        • Tuesday == Tuesday => true => continue
      • Update State
        • currentMonth = 7
        • unstakePeriodEnd = last second of Tuesday
    • Back to withdraw

From the flow above, we showed a case where the issue occurs Note: if the second Tuesday passed with no withdrawals, then the users will be able to withdraw on the third Tuesday and so on.

Tools Used

Manual analysis

Check if the day of Tuesday is from the first seven days of the month. This way, you guarantee that it is always the first Tuesday of the month. You can do this by extracting the dd from the date dd/mm/yyyy, then check if dd is lower than 8

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-11T05:40:09Z

trust1995 changed the severity to QA (Quality Assurance)

#1 - c4-judge

2023-07-11T05:40:27Z

trust1995 marked the issue as grade-c

#2 - c4-judge

2023-07-11T05:42:00Z

This previously downgraded issue has been upgraded by trust1995

#3 - c4-judge

2023-07-11T05:42:00Z

This previously downgraded issue has been upgraded by trust1995

#4 - c4-judge

2023-07-11T05:42:15Z

trust1995 marked the issue as duplicate of #396

#5 - c4-judge

2023-07-11T05:42:33Z

trust1995 marked the issue as satisfactory

Findings Information

Awards

195.3093 USDC - $195.31

Labels

grade-b
satisfactory
sponsor confirmed
analysis-advanced
A-01

External Links

Any comments for the judge to contextualize your findings

I've added contexts for some issues that seem complex. This is to help the judge reducing the time spent on judging. I hope it helps. For issues that are gas related, please note that the goal of the PoC is to prove that the issue exist and not necessarily giving the exact numbers.

Approach taken in evaluating the codebase

I followed these steps:

  • Went through the docs to understand the concept.

  • Started to read the codebase high-level, starting from Hermes as it made more sense than starting from Maia.

  • Started to read the contracts and connect the dots between functions and what they are supposed to do keeping in mind what I've read in the docs.

  • If something isn't clear or maybe missing, I research the docs or ask the sponsor for confirmation. The sponsor was amazingly very responsive and supportive.

  • Now I pick public entry points from a contract, then I start to understand the logic. From there, I reach internal functionalities. This is helpful for me when auditing.

  • During this I write notes about potential issues to be investigated later.

  • I do investigate the notes later, then go on from there creating an issue from it, writing a PoC ..etc or disregard it.

  • For OmniChain specifically since it is a complex component of the system, I've created a diagram for the request/response flow.

Architecture recommendations

  • Ulysses Omnichain
    • The current design requires the user to deposit gas for each deposit independently. This way each deposit has its own deposted gas. I think it would be easier and more convenient (even more efficient) to have one bucket for each user (instead of for each deposit). From a UX perspective, nothing would change for the user except it gets better. For example, the user would be able to track all his/her deposited gas since it is stored in one bucket. At the moment, this is not possible.
    • Moving deposited gas (or even tokens) between two deposits would be a great feature. It is beneficial for the end user. At the moment, if a deposit failed, I should claim it and then can use it for a new deposit.
    • It would be better if there was a layer that abstracts the technical communication between Ulysses and anyCall V7. Decoupling it would reduce the risk of having a hard dependency (i.e. anyCall V7). It also allows to move to other solutions in case it is needed or required.
    • Ports are used to store tokens/gas deposited. For each chain, there is one port. This is risky, because this means all users deposits are accumulated in one single contract. It is more secure to have a contract (created dynamically) to store each user's deposit. At the moment, any risk on a port, means a risk for all users funds.
    • tx.gasPrice is used to calcualte the cost when sending a request from a chain to another. However, gas prices could change especially since there are multiple chains. So underpayments and overpayments are likely. We are interested here more in underpayments. This is a tricky issue. However, this should be addressed and evaulated the risk of it. On a large scale, if it happens often, many underpayments will eventually cause the system not to function as intended.

Systemic and/or Centralization risks

  • Ulysses Omnichain depends heavily on anyCall V7 (by Multi-chain). anyCall contracts are upgradeable. This means, technically speaking it can be destructed. This also means any systemic risks (or centralization risks) of anyCall is automatically systemic risk (or centralization risks) for Ulysses Omnichain. This should be evaluated and documented even if there is no disaster recovery plan. (Apologies if I missed a document that actually elaborates on this, but couldn't find any).

Time spent:

144 hours

#0 - c4-judge

2023-07-11T16:06:35Z

trust1995 marked the issue as grade-b

#1 - c4-judge

2023-07-11T16:06:41Z

trust1995 marked the issue as satisfactory

#2 - c4-sponsor

2023-07-13T12:15:59Z

0xLightt marked the issue as sponsor confirmed

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter