EIP-1559 Vulnerability Patterns
EIP-1559 (London Hard Fork, 2021) introduced a new transaction fee mechanism for Ethereum, replacing the first-price auction model with a base fee that adjusts dynamically based on network congestion.
This article summarizes 1 EIP-1559 vulnerability pattern with real audit cases to help you understand the risks of incorrect gas issuance calculation in L2 implementations.
EIP-1559 Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ EIP-1559: FEE MARKET CHANGE │
└─────────────────────────────────────────────────────────────────────────┘
Before EIP-1559 (First-Price Auction):
┌────────────────────────────────────────────────────────────────────────┐
│ Users bid gas prices competitively │
│ │
│ User A: "I'll pay 100 gwei" │
│ User B: "I'll pay 150 gwei" ← Winner, but overpays │
│ User C: "I'll pay 80 gwei" │
│ │
│ Problems: │
│ • Unpredictable fees │
│ • Users often overpay │
│ • MEV exploitation │
└────────────────────────────────────────────────────────────────────────┘
After EIP-1559:
┌────────────────────────────────────────────────────────────────────────┐
│ Transaction Fee = Base Fee + Priority Fee (Tip) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Base Fee │ + │ Priority Fee │ = Total Fee │
│ │ (burned) │ │ (to validator) │ │
│ └──────────────┘ └──────────────────┘ │
│ ↑ │
│ Algorithmically │
│ determined by │
│ network demand │
└────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BASE FEE ADJUSTMENT MECHANISM │
└─────────────────────────────────────────────────────────────────────────┘
Target: Each block should use 50% of max capacity (15M gas target, 30M max)
Block Usage vs Target:
┌────────────────────────────────────────────────────────────────────────┐
│ │
│ Block > 50% full → Base Fee INCREASES (up to 12.5% per block) │
│ Block < 50% full → Base Fee DECREASES (up to 12.5% per block) │
│ Block = 50% full → Base Fee UNCHANGED │
│ │
│ Example (baseFee of block N+1 is determined by block N's usage): │
│ ┌─────────┬─────────────┬─────────────┬──────────────────────────┐ │
│ │ Block │ Gas Used │ % of Max │ Next Block's Base Fee │ │
│ ├─────────┼─────────────┼─────────────┼──────────────────────────┤ │
│ │ N │ 15M │ 50% │ 100 gwei (unchanged) │ │
│ │ N+1 │ 30M │ 100% │ 112 gwei (+12.5%) │ │
│ │ N+2 │ 30M │ 100% │ 126 gwei (+12.5%) │ │
│ │ N+3 │ 0M │ 0% │ 110 gwei (-12.5%) │ │
│ └─────────┴─────────────┴─────────────┴──────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ KEY COMPONENTS │
└─────────────────────────────────────────────────────────────────────────┘
1. Base Fee (baseFeePerGas)
• Minimum fee required for transaction inclusion
• Burned (removed from circulation) → ETH becomes deflationary
• Predictable: known before transaction submission
2. Priority Fee (maxPriorityFeePerGas)
• Tip to incentivize validators
• User-defined, typically 1-2 gwei
3. Max Fee (maxFeePerGas)
• Maximum total fee user is willing to pay
• Unused portion refunded: refund = maxFee - (baseFee + priorityFee)
Formula:
┌────────────────────────────────────────────────────────────────────────┐
│ Actual Fee = min(maxFeePerGas, baseFeePerGas + maxPriorityFeePerGas) │
│ │
│ If maxFeePerGas < baseFeePerGas → Transaction FAILS │
└────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BASE FEE CALCULATION FORMULA │
└─────────────────────────────────────────────────────────────────────────┘
parent_base_fee = previous block's baseFee
parent_gas_used = previous block's actual gas usage
parent_gas_target = 15M (target)
If parent_gas_used == parent_gas_target:
base_fee = parent_base_fee (unchanged)
If parent_gas_used > parent_gas_target:
delta = parent_base_fee × (parent_gas_used - parent_gas_target) ÷ parent_gas_target ÷ 8
base_fee = parent_base_fee + delta (increase, max 12.5%)
If parent_gas_used < parent_gas_target:
delta = parent_base_fee × (parent_gas_target - parent_gas_used) ÷ parent_gas_target ÷ 8
base_fee = parent_base_fee - delta (decrease, max 12.5%)
Vulnerability Patterns
1. Gas Issuance Accumulation Error
Prerequisite Knowledge:
EIP-1559 specifies a base fee adjustment mechanism where the network targets a specific gas usage per block. The key rule is: ‘The base fee per gas is increased when blocks are above the gas target, and decreased when blocks are below the gas target.’ The protocol should issue exactly gasTargetPerL1Block worth of gas for each L1 block to maintain this equilibrium. The calculation should be per-block, not accumulated over multiple blocks.
Root Cause:
The implementation incorrectly accumulates gas issuance over multiple L1 blocks instead of issuing the correct amount per block. The code calculates issuance = numL1Blocks * _config.gasTargetPerL1Block where numL1Blocks can be > 1, leading to over-issuance. This violates the EIP-1559 principle of gradual base fee adjustment based on per-block gas usage relative to target.
Attack Vector:
When anchor() is called on consecutive blocks before lastSyncedBlock is updated (every 5 blocks), the system accumulates and issues gas for multiple L1 blocks at once. This causes the calculated base fee to diverge from block.basefee, either halting the chain (if base fees don’t match) or continuing with severely deflated base fees, disrupting the EIP-1559 fee market mechanism.
Source:
- [code4rena/2024-03-taiko] Gas issuance is inflated and will halt the chain or lead to incorrect base fee (
finding-001.md)packages/protocol/contracts/L2/TaikoL2.solBusiness Context: Taiko L2 EIP-1559 Base Fee Calculation
┌─────────────────────────────────────────────────────────────────────────┐ │ WHAT IS TAIKO? │ └─────────────────────────────────────────────────────────────────────────┘ Taiko is a Type-1 zkEVM L2 that aims to be fully Ethereum-equivalent. It implements EIP-1559 on L2 to manage gas fees similarly to Ethereum L1. ┌─────────────────────────────────────────────────────────────────────────┐ │ L2 BASE FEE MECHANISM │ └─────────────────────────────────────────────────────────────────────────┘ On L2, the base fee is calculated using: • gasExcess: Historical accumulated gas excess • lastSyncedBlock: Last L1 block synced with L2 • gasTargetPerL1Block: Gas target per L1 block (60M for Taiko) The formula tracks how much gas has been used vs. issued: excess = gasExcess + parentGasUsed - issuance ┌─────────────────────────────────────────────────────────────────────────┐ │ THE BUG │ └─────────────────────────────────────────────────────────────────────────┘ Settings: lastSyncedBlock = 100 (initial) gasTargetPerL1Block = 60,000,000 (60M) BLOCK_SYNC_THRESHOLD = 5 (lastSyncedBlock only updates when current > last + 5) Call anchor() on L1 blocks 101, 102, 103, 104, 105: Block 101: lastSyncedBlock = 100 (not updated, 101 < 100+5) numL1Blocks = 101 - 100 = 1 issuance = 1 × 60M = 60M Block 102: lastSyncedBlock = 100 (not updated, 102 < 100+5) numL1Blocks = 102 - 100 = 2 issuance = 2 × 60M = 120M Block 103: lastSyncedBlock = 100 (not updated, 103 < 100+5) numL1Blocks = 103 - 100 = 3 issuance = 3 × 60M = 180M Block 104: lastSyncedBlock = 100 (not updated, 104 < 100+5) numL1Blocks = 104 - 100 = 4 issuance = 4 × 60M = 240M Block 105: lastSyncedBlock = 100 (not updated, 105 = 100+5, not >) numL1Blocks = 105 - 100 = 5 issuance = 5 × 60M = 300M ───────────────────────────────── Total issuance: 60M + 120M + 180M + 240M + 300M = 900M Expected issuance: 5 blocks × 60M = 300M Problem: lastSyncedBlock stays at 100 for 5 blocks! Each block recalculates from block 100, causing repeated issuance. Actual / Expected = 900M / 300M = 3x over-issued! ┌─────────────────────────────────────────────────────────────────────────┐ │ IMPACT │ └─────────────────────────────────────────────────────────────────────────┘ issuance inflated → excess reduced too fast → gasExcess too low → baseFee too low → baseFee != block.basefee → revert → chain halted ┌─────────────────────────────────────────────────────────────────────────┐ │ FIX │ └─────────────────────────────────────────────────────────────────────────┘ Use a separate variable for gas issuance tracking: // Track the last block used for gas issuance calculation separately uint64 public lastGasIssuedBlock; function _calc1559BaseFee(...) { // Always use lastGasIssuedBlock for issuance calculation if (lastGasIssuedBlock > 0 && _l1BlockId > lastGasIssuedBlock) { numL1Blocks = _l1BlockId - lastGasIssuedBlock; } // Update lastGasIssuedBlock every time (not tied to sync threshold) lastGasIssuedBlock = _l1BlockId; // Now numL1Blocks is always 1 for consecutive blocks // issuance = 1 × gasTargetPerL1Block per block ✓ }Vulnerable Code:
// ==================== anchor() function ==================== // This function is called to anchor L1 state to L2 function anchor(...) external { // Calculate basefee from gasExcess (basefee, gasExcess) = _calc1559BaseFee(config, _l1BlockId, _parentGasUsed); // Check if calculated basefee matches actual block.basefee if (!skipFeeCheck() && block.basefee != basefee) { revert L2_BASEFEE_MISMATCH(); // Chain halts if mismatch! } // BUG LOCATION: lastSyncedBlock only updates when current > last + 5 // So for blocks 101-105: lastSyncedBlock stays at 100 if (_l1BlockId > lastSyncedBlock + BLOCK_SYNC_THRESHOLD) { lastSyncedBlock = _l1BlockId; // Only updates here! } } // ==================== _calc1559BaseFee() function ==================== // This function calculates the EIP-1559 base fee function _calc1559BaseFee(...) private view returns (...) { uint256 excess = uint256(gasExcess) + _parentGasUsed; // BUG: numL1Blocks = current - lastSyncedBlock // Since lastSyncedBlock=100 (not updated in anchor), each block recalculates from 100 // Block 101: 101-100=1, Block 102: 102-100=2, Block 103: 103-100=3... uint256 numL1Blocks; if (lastSyncedBlock > 0 && _l1BlockId > lastSyncedBlock) { numL1Blocks = _l1BlockId - lastSyncedBlock; } // BUG: issuance = numL1Blocks × quota (60M per L1 block) // Should be 1×60M per block, but becomes (1+2+3+4+5)×60M = 900M over 5 blocks // Over-issuance → excess drops too fast → baseFee too low if (numL1Blocks > 0) { uint256 issuance = numL1Blocks * _config.gasTargetPerL1Block; excess = excess > issuance ? excess - issuance : 1; } basefee_ = Lib1559Math.basefee(gasExcess_, ...); }
EIP-1559 Audit Checklist
When auditing contracts that implement EIP-1559 fee mechanisms, ensure you check these items:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Is gas issuance calculated per-block? Accumulating issuance over multiple blocks violates EIP-1559 principles | Pattern 1 |
| ✅ | Are state variables for fee calculation updated consistently? Variables like lastSyncedBlock should be updated appropriately to avoid accumulation errors |
Pattern 1 |
| ✅ | Is the calculated base fee verified against block.basefee? Mismatch detection prevents chain operation with incorrect fees | Pattern 1 |
InfiniteSec