-
Notifications
You must be signed in to change notification settings - Fork 70
Description
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 costThe 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:
- Create maximum-size payloads (2880 bytes of non-zero data)
- Use minimum gas limit (100,000)
- Pay only for execution gas, bypassing intrinsic calldata costs
- Force the relayer to subsidize the missing intrinsic gas
- Repeat transactions to drain protocol/relayer funds
Affected Code
contracts/zevm/ZRC20.sol-withdrawGasFeeWithGasLimit()functioncontracts/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
-
Backward Compatibility: The
withdrawGasFeeWithGasLimit()function signature change may require interface updates and migration strategy. -
Gas Estimation: Consider making intrinsic gas calculation configurable per chain, as different EVM chains may have different base costs.
-
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
-
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
- EIP-2028: Transaction data gas cost reduction (https://eips.ethereum.org/EIPS/eip-2028)
- Current implementation:
contracts/zevm/ZRC20.sol - Current implementation:
contracts/zevm/GatewayZEVM.sol