Skip to content

GatewayZEVM Undercharges Destination Chain Gas by Ignoring Intrinsic Calldata Cost #615

@0xM3R

Description

@0xM3R

GatewayZEVM Undercharges Destination Chain Gas by Ignoring Intrinsic Calldata Cost

Summary

GatewayZEVM currently calculates gas fees based only on execution gas limit and a flat protocol fee, omitting the intrinsic calldata cost required for EVM transactions. This causes the protocol/relayer to subsidize intrinsic gas costs for large payloads, creating a potential financial drain vector.

Severity

Low Risk - Financial impact from missing fee collection, but does not compromise security or allow fund theft.

Description

When users call GatewayZEVM.call() or GatewayZEVM.withdrawAndCall(), the protocol calculates fees using only the execution gas limit specified by the user. However, EVM transactions require additional intrinsic gas for calldata bytes that is not accounted for in the fee calculation.

Current Implementation

The fee calculation in ZRC20.withdrawGasFeeWithGasLimit() only considers execution gas:

// contracts/zevm/ZRC20.sol:290
uint256 gasFee = gasPrice * gasLimit + PROTOCOL_FLAT_FEE;
// No term for calldata bytes cost

The Gateway functions validate message size against MAX_MESSAGE_SIZE (2880 bytes) but do not adjust fees based on actual message length:

// contracts/zevm/GatewayZEVM.sol:373
function _call(...) private {
    if (receiver.length == 0) revert ZeroAddress();
    _burnProtocolFees(zrc20, callOptions.gasLimit);  // Passes gasLimit unchanged
    emit Called(msg.sender, zrc20, receiver, message, callOptions, revertOptions);
}

EVM Intrinsic Gas Cost

According to EIP-2028, EVM transactions require intrinsic gas for calldata:

intrinsic_gas = 21,000 (base) + 4 * (zero_bytes) + 16 * (non_zero_bytes)

For a maximum-size payload (2880 bytes of non-zero data):

  • Base: 21,000 gas
  • Calldata: 16 * 2,880 = 46,080 gas
  • Total intrinsic: 67,080 gas

Impact

With current constants (MAX_MESSAGE_SIZE = 2880, MIN_GAS_LIMIT = 100,000):

What the protocol charges:

  • Execution gas: 100,000
  • Fee: gasPrice * 100,000 + PROTOCOL_FLAT_FEE

What the relayer must actually pay on destination:

  • Execution gas: 100,000
  • Intrinsic gas: 67,080
  • Total required: 167,080 gas

Per-transaction undercharge:

  • Missing intrinsic gas: 67,080 gas
  • At 50 gwei: approximately 0.003354 ETH per call
  • Scalable attack by repeatedly sending maximum-size payloads

Attack Scenario

An attacker can:

  1. Create maximum-size payloads (2880 bytes of non-zero data)
  2. Use minimum gas limit (100,000)
  3. Pay only for execution gas, bypassing intrinsic calldata costs
  4. Force the relayer to subsidize the missing intrinsic gas
  5. Repeat transactions to drain protocol/relayer funds

Affected Code

  • contracts/zevm/ZRC20.sol - withdrawGasFeeWithGasLimit() function
  • contracts/zevm/GatewayZEVM.sol - _burnProtocolFees(), _call(), withdrawAndCall() functions

Recommended Fix

Add intrinsic calldata cost calculation to the fee computation. Here are two approaches:

Option 1: Add Intrinsic Gas to Fee Calculation

Modify ZRC20.withdrawGasFeeWithGasLimit() to accept message size and calculate intrinsic gas:

function withdrawGasFeeWithGasLimit(uint256 gasLimit, uint256 calldataBytes) 
    public 
    view 
    override 
    returns (address, uint256) 
{
    address gasZRC20 = ISystem(SYSTEM_CONTRACT_ADDRESS).gasCoinZRC20ByChainId(CHAIN_ID);
    if (gasZRC20 == address(0)) revert ZeroGasCoin();

    uint256 gasPrice = ISystem(SYSTEM_CONTRACT_ADDRESS).gasPriceByChainId(CHAIN_ID);
    if (gasPrice == 0) {
        revert ZeroGasPrice();
    }
    
    // Calculate intrinsic gas for calldata (EIP-2028)
    // Assuming worst case: all non-zero bytes (16 gas per byte)
    // Base transaction cost: 21,000 gas
    uint256 intrinsicGas = 21_000 + (16 * calldataBytes);
    
    // Total gas = execution gas + intrinsic gas
    uint256 totalGas = gasLimit + intrinsicGas;
    uint256 gasFee = gasPrice * totalGas + PROTOCOL_FLAT_FEE;
    
    return (gasZRC20, gasFee);
}

Then update Gateway functions to pass message size:

function _burnProtocolFees(address zrc20, uint256 gasLimit, uint256 calldataBytes) 
    private 
    returns (uint256) 
{
    (address gasZRC20, uint256 gasFee) = IZRC20(zrc20).withdrawGasFeeWithGasLimit(gasLimit, calldataBytes);
    
    if (!IZRC20(gasZRC20).transferFrom(msg.sender, address(this), gasFee)) {
        revert GasFeeTransferFailed();
    }
    
    if (!IZRC20(gasZRC20).burn(gasFee)) revert ZRC20BurnFailed();
    
    return gasFee;
}

function _call(
    bytes memory receiver,
    address zrc20,
    bytes calldata message,
    CallOptions memory callOptions,
    RevertOptions memory revertOptions
) private {
    if (receiver.length == 0) revert ZeroAddress();
    
    // Calculate total calldata bytes (message + revert message)
    uint256 totalCalldataBytes = message.length + revertOptions.revertMessage.length;
    
    uint256 gasFee = _burnProtocolFees(zrc20, callOptions.gasLimit, totalCalldataBytes);
    
    emit Called(msg.sender, zrc20, receiver, message, callOptions, revertOptions);
}

Option 2: More Accurate Intrinsic Gas Calculation

For more accurate calculation, count zero vs non-zero bytes:

function _calculateIntrinsicGas(bytes memory data) private pure returns (uint256) {
    uint256 intrinsicGas = 21_000; // Base transaction cost
    
    for (uint256 i = 0; i < data.length; i++) {
        if (data[i] == 0) {
            intrinsicGas += 4;  // Zero byte cost
        } else {
            intrinsicGas += 16; // Non-zero byte cost
        }
    }
    
    return intrinsicGas;
}

Implementation Considerations

  1. Backward Compatibility: The withdrawGasFeeWithGasLimit() function signature change may require interface updates and migration strategy.

  2. Gas Estimation: Consider making intrinsic gas calculation configurable per chain, as different EVM chains may have different base costs.

  3. Testing: Add comprehensive tests verifying:

    • Empty payloads charge base intrinsic gas (21,000)
    • Maximum payloads charge full intrinsic gas (67,080)
    • Fees scale correctly with payload size
    • Different zero/non-zero byte ratios charge correctly
  4. Documentation: Update developer documentation to reflect that fees now include intrinsic calldata costs.

Testing

Add test cases to verify:

function test_FeeIncludesIntrinsicCalldataCost() public {
    // Test that empty message charges base intrinsic gas
    bytes memory emptyMsg = "";
    // ... verify fee includes 21,000 gas intrinsic cost
    
    // Test that maximum message charges full intrinsic gas
    bytes memory maxMsg = new bytes(2880);
    // Fill with non-zero bytes
    // ... verify fee includes 67,080 gas intrinsic cost
    
    // Test that fees scale with message size
    // ... verify linear relationship between message size and fee
}

Additional Notes

  • This issue affects all EVM destination chains using standard EIP-2028 intrinsic gas costs
  • The vulnerability is exploitable but requires repeated transactions to cause significant financial impact
  • Consider rate limiting or additional checks if implementing fix incrementally
  • Review relayer economics to ensure collected fees cover actual costs

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions