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
Rank: 2/101
Findings: 8
Award: $19,221.86
π Selected for report: 5
π Solo Findings: 3
π Selected for report: Koolex
5707.2337 USDC - $5,707.23
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; }
_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;
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:
anyFallback
method//Get Initial Gas Checkpoint uint256 initialGas = gasleft();
//Save gas uint256 gasLeft = gasleft(); //Get Branch Environment Execution Cost uint256 minExecCost = tx.gasprice * (MIN_FALLBACK_RESERVE + _initialGas - gasLeft);
This overhead is supposed to cover:
Line:38 uint256 constant EXECUTION_OVERHEAD = 100000; . . Line:203 uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
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:
execution budget is decreasing over time (slow draining) in case it has funds already.
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
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 { _; } }
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); } }
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); . . }
The gas is nearly 110_000. It is not taken into account. (proven in the PoCs)
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
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
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.
anyExec
method. check next point).anyExec
method called by the MPC is not considered.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).
Note: (estimation doesn't consider anyExec
method's actual cost).
This PoC is independent from the codebase (but uses the same code).
There are two contracts simulating BranchBridgeAgent.anyFallback
.
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
BranchBridgeAgent.anyFallback
method depends on the following external calls:
AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
WETH9.withdraw()
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:
_payFallbackGas
method as it is not available in Foundry._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.[profile.default] solc = '0.8.17' src = 'solidity' test = 'solidity/test' out = 'out' libs = ['lib'] fuzz_runs = 1000 optimizer_runs = 10_000
[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
ds-test/=lib/ds-test/src forge-std/=lib/forge-std/src
// 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); } }
anyExec
method in AnyCall)We have contracts that simulating Anycall contracts:
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
// 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_); } }
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.
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
π Selected for report: Koolex
5707.2337 USDC - $5,707.23
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; }
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;
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.
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
BranchBridgeAgent.anyExecute
method depends on the following external calls:
AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
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:
_payExecutionGas
method as it is not available in Foundry._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._forceRevert
, we call anycallConfig Immediately skippping the returned value from AnycallProxy. This is anyway irrelevant for this PoC.[profile.default] solc = '0.8.17' src = 'solidity' test = 'solidity/test' out = 'out' libs = ['lib'] fuzz_runs = 1000 optimizer_runs = 10_000
[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
ds-test/=lib/ds-test/src forge-std/=lib/forge-std/src
// 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(); } }
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
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.
5707.2337 USDC - $5,707.23
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; }
_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;
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:
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
//Get Root Environment Execution Cost uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasleft());
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:
Line:38 uint256 constant EXECUTION_OVERHEAD = 100000; . . Line:203 uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
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:
execution budget is decreasing over time (slow draining) in case it has funds already.
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
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 { _; } }
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); } }
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); . . }
The gas is nearly 110_000. It is not taken into account.
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
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
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.
anyExec
method called by the MPC is not considered.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.
This PoC is independent from the codebase (but uses the same code).
There are two contracts simulating RootBridgeAgent.anyExecute
.
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
RootBridgeAgent.anyExecute
method depends on the following external calls:
AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
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:
_payExecutionGas
method as it is not available in Foundry._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.[profile.default] solc = '0.8.17' src = 'solidity' test = 'solidity/test' out = 'out' libs = ['lib'] fuzz_runs = 1000 optimizer_runs = 10_000
[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
ds-test/=lib/ds-test/src forge-std/=lib/forge-std/src
// 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); } }
anyExec
method in AnyCall)We have contracts that simulating Anycall contracts:
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
[profile.default] solc = '0.8.17' src = 'solidity' test = 'solidity/test' out = 'out' libs = ['lib'] fuzz_runs = 1000 optimizer_runs = 10_000
[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
ds-test/=lib/ds-test/src forge-std/=lib/forge-std/src
// 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_); } }
Manual analysis
Increase the MIN_EXECUTION_OVERHEAD by:
RootBridgeAgent.anyExecute
.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.
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.
5707.2337 USDC - $5,707.23
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; }
_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;
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:
anyExecute
method//Get Initial Gas Checkpoint uint256 initialGas = gasleft();
///Save gas left uint256 gasLeft = gasleft(); //Get Branch Environment Execution Cost uint256 minExecCost = tx.gasprice * (MIN_EXECUTION_OVERHEAD + _initialGas - gasLeft);
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:
Line:38 uint256 constant EXECUTION_OVERHEAD = 100000; . . Line:203 uint256 gasUsed = _prevGasLeft + EXECUTION_OVERHEAD - gasleft();
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:
**Overpaying the remaining gas the user **.
execution budget is decreasing over time (slow draining) in case it has funds already.
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
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 { _; } }
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); } }
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); . . }
The gas is nearly 110_000. It is not taken into account.
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
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
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.
anyExec
method called by the MPC is not considered.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.
This PoC is independent from the codebase (but uses the same code).
There are two contracts simulating BranchBridgeAgent.anyExecute
.
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
BranchBridgeAgent.anyExecute
method depends on the following external calls:
AnycallExecutor.context()
AnycallProxy.config()
AnycallConfig.executionBudget()
AnycallConfig.withdraw()
AnycallConfig.deposit()
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:
_payExecutionGas
method as it is not available in Foundry._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.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.[profile.default] solc = '0.8.17' src = 'solidity' test = 'solidity/test' out = 'out' libs = ['lib'] fuzz_runs = 1000 optimizer_runs = 10_000
[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
ds-test/=lib/ds-test/src forge-std/=lib/forge-std/src
// 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); } }
anyExec
method in AnyCall)We have contracts that simulating Anycall contracts:
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
// 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_); } }
Manual analysis
Increase the MIN_EXECUTION_OVERHEAD by:
RootBridgeAgent.anyExecute
.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.
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.
π Selected for report: ltyu
Also found by: BPZ, Koolex, RED-LOTUS-REACH, xuwinnie, yellowBirdy
432.0595 USDC - $432.06
_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, "" ); }
FLAG_ALLOW_FALLBACK constant
uint256 public constant FLAG_ALLOW_FALLBACK = 0x1 << 2;
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.
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.
Manual analysis
Replace FLAG_ALLOW_FALLBACK with FLAG_ALLOW_FALLBACK_DST which has the value 6
uint256 public constant FLAG_ALLOW_FALLBACK_DST = 6;
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)
770.4765 USDC - $770.48
_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.
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(); }
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
Manual analysis
Withdraw Gas from port, unwrap it, then call _replenishGas to top up the execution budget
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.
462.2859 USDC - $462.29
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.
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(); . . . .
// 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(); }
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.
Imagine the following scenario:
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.BranchBridgeAgent.performCallOutAndBridge
AnycallProxy.anyCall
RootBridgeAgent.anyExecute
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; }
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.
_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;
This way we avoid reverting directly. Instead, we let Anycall Executor to revert avoiding triggering the fallback.
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)
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.
π Selected for report: ABA
Also found by: 0xTheC0der, Josiah, Koolex
240.0331 USDC - $240.03
https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L109-L110
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.
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.
In the following flow, the withdrawal will revert on the second Tuesday
Assume
Now go through the following flow:
In the following flow, the withdrawal will not revert on the second Tuesday
Assume
Now go through the following flow:
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.
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
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
195.3093 USDC - $195.31
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.
I followed these steps:
Went through the docs to understand the concept.
In the docs, it says
Users can stake their MAIA tokens at any time, but can only withdraw their staked tokens on the first Monday of each month.
However, in the code the implementation is consdiering Tuesday instead. Links: - https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L22-L25 - https://github.com/code-423n4/2023-05-maia/blob/main/src/maia/vMaia.sol#L120
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.
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