EIP-4844 Blob Transaction Vulnerability Patterns
EIP-4844 (Proto-Danksharding) introduces blob-carrying transactions to Ethereum, providing a cheaper data availability layer for Layer 2 rollups. This is a fundamental change to how rollups post data to L1.
This article summarizes 3 EIP-4844 vulnerability patterns with real audit cases to help you understand the risks of implementing blob transactions.
What is EIP-4844?
EIP-4844, also known as Proto-Danksharding, was activated in the Cancun-Deneb upgrade (March 2024). It introduces a new transaction type that can carry large data “blobs” at significantly lower cost than calldata.
Source: EIP-4844 Motivation
“Rollups are significantly reducing fees for many Ethereum users…However, even these fees are too expensive for many users. The long-term solution…has always been data sharding…However, data sharding will still take a considerable amount of time to finish implementing and deploying.”
┌─────────────────────────────────────────────────────────────────────────┐
│ WHY DO ROLLUPS NEED EIP-4844? │
└─────────────────────────────────────────────────────────────────────────┘
Source: EIP-4844 Motivation section
The Rollup Data Availability Problem:
Rollups execute transactions off-chain (L2) for scalability
But must POST TRANSACTION DATA to L1 for security!
Why? So anyone can:
→ Reconstruct L2 state from L1 data
→ Verify L2 state transitions are correct
→ Challenge invalid state roots (fraud proofs)
This is called "Data Availability" (DA)
┌─────────────────────────────────────────────────────────────────────────┐
│ BEFORE vs AFTER EIP-4844 │
└─────────────────────────────────────────────────────────────────────────┘
Before EIP-4844: Rollups used CALLDATA for DA
Calldata costs 16 gas per non-zero byte
(Source: EIP-2028, Yellow Paper Appendix G)
Problem: Calldata is stored FOREVER in blockchain history
→ Expensive for rollups (majority of L2 tx cost)
→ State bloat for all full nodes
After EIP-4844: Rollups use BLOBS for DA
Blobs are PRUNED after ~18 days (not stored forever)
(Source: consensus-specs/deneb/p2p-interface.md, MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS = 4096)
Benefits:
→ Separate fee market (blob gas vs execution gas)
→ 10-100x cheaper than calldata
→ Nodes can prune old blobs, reducing storage burden
Blob Transaction Structure
Source: EIP-4844 Specification
┌─────────────────────────────────────────────────────────────────────────┐
│ TYPE-3 TRANSACTION STRUCTURE │
└─────────────────────────────────────────────────────────────────────────┘
Source: EIP-4844 "Blob transaction" section
BLOB_TX_TYPE = 0x03
TransactionPayloadBody (execution layer):
┌────────────────────────────────────────────────────────────────────────┐
│ [chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, │
│ gas_limit, to, value, data, access_list, │
│ max_fee_per_blob_gas, ← NEW: max price for blob gas │
│ blob_versioned_hashes, ← NEW: list of blob commitment hashes │
│ y_parity, r, s] │
└────────────────────────────────────────────────────────────────────────┘
Network Representation (mempool/gossip):
┌────────────────────────────────────────────────────────────────────────┐
│ rlp([tx_payload_body, blobs, commitments, proofs]) │
│ ↑ ↑ ↑ │
│ │ │ └─ KZG proofs │
│ │ └─ KZG commitments │
│ └─ Actual blob data │
└────────────────────────────────────────────────────────────────────────┘
Note: blobs, commitments, proofs are ONLY in network representation,
NOT in execution payload (EIP-4844: "blob transaction TransactionPayload")
What are blobs, commitments, proofs?
blob = The actual data (128 KiB, 4096 field elements)
commitment = A unique fingerprint of the blob (48 bytes)
proof = Cryptographic evidence that fingerprint matches blob
Example:
blob = [d₀, d₁, d₂, ..., d₄₀₉₅] (4096 field elements = 128 KiB)
│
▼ kzg_blob_to_commitment()
commitment = 0xabcd... (48 bytes, G1 point on BLS12-381 curve)
│
├──▶ versioned_hash = 0x01 || sha256(commitment)[1:]
│ └─► 32 bytes, goes into tx.blob_versioned_hashes[]
│ accessible via BLOBHASH opcode in EVM
│
▼ compute_blob_kzg_proof()
proof = 0xef01... (48 bytes)
└─► Proves this commitment actually matches the blob data
Nodes verify: commitment + blob + proof are consistent
If mismatch → reject transaction
Why separate versioned_hash?
- EVM only sees versioned_hash (32 bytes), not full commitment (48 bytes)
- Version byte 0x01 = KZG scheme (future-proof for other schemes)
┌─────────────────────────────────────────────────────────────────────────┐
│ BLOB DATA SEPARATION │
└─────────────────────────────────────────────────────────────────────────┘
Source: EIP-4844 Abstract
"data that cannot be accessed by EVM execution, but whose commitment
can be accessed"
Source: EIP-4844 "Consensus layer validation" section
"the consensus layer is tasked with persisting the blobs for data
availability, the execution layer is not"
Source: consensus-specs/deneb/p2p-interface.md
Blobs are stored as "BlobSidecar" containers, separate from beacon blocks
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA FLOW: WHERE DOES BLOB DATA GO? │
└─────────────────────────────────────────────────────────────────────────┘
Step 0: Transaction Construction (User/Wallet Side)
Note: This section describes practical implementation based on EIP-4844 specification.
The transaction structure follows EIP-4844, but workflow details are illustrative.
WHO SENDS BLOB TRANSACTIONS?
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. L2 Batcher (Most Common) │
│ → Rollup sequencer posting L2 tx batches to L1 │
│ → Example: Optimism Batcher, Arbitrum Sequencer │
│ → Blob contains: compressed L2 transaction data │
│ │
│ 2. Regular Users (Rare, but possible) │
│ → Anyone can send Type-3 tx, but few use cases exist │
│ → Example: Storing data on-chain cheaply (temporary 18 days) │
│ → Example: Custom DA layer for app-specific rollup │
│ → Note: EVM contracts CANNOT read blob content, only hash! │
└─────────────────────────────────────────────────────────────────────────┘
CASE A: Regular User (e.g., storing arbitrary data)
┌─────────────────────────────────────────────────────────────────────────┐
│ User wants to store 100KB of data on Ethereum cheaply │
│ │
│ // Using ethers.js or similar library │
│ const blob_data = myDataBuffer; // pad to 131072 bytes │
│ │
│ // Library handles KZG computation automatically │
│ const tx = { │
│ type: 3, // Type-3 blob tx │
│ to: "0x0000...0000", // Can be zero address (no contract)│
│ maxFeePerBlobGas: 100n, // Max blob gas price willing to pay│
│ blobs: [blob_data], // Actual blob content │
│ // ... other EIP-1559 fields │
│ }; │
│ │
│ // ethers.js computes: commitment, proof, versioned_hash internally │
│ const txResponse = await wallet.sendTransaction(tx); │
│ │
│ // After tx confirmed, blob_versioned_hash is accessible on-chain │
│ // but blob content is NOT - it's only in consensus layer │
└─────────────────────────────────────────────────────────────────────────┘
CASE B: L2 Batcher (production rollup)
┌─────────────────────────────────────────────────────────────────────────┐
│ Optimism/Arbitrum Batcher posts L2 transaction batch │
│ │
│ 1. Collect L2 transactions from sequencer │
│ l2_txs = [tx1, tx2, tx3, ..., tx1000] │
│ │
│ 2. Compress transaction data │
│ compressed = zstd_compress(rlp_encode(l2_txs)) // e.g., 100KB │
│ │
│ 3. Pad to blob size │
│ blob_data = pad_to_131072_bytes(compressed) │
│ │
│ 4. Compute KZG (using c-kzg or similar) │
│ commitment = kzg_blob_to_commitment(blob_data) // 48 bytes │
│ proof = compute_blob_kzg_proof(blob_data, commitment) │
│ versioned_hash = 0x01 || sha256(commitment)[1:32] │
│ │
│ 5. Build Type-3 transaction │
│ tx = { │
│ type: 0x03, │
│ to: ROLLUP_INBOX_CONTRACT, │
│ data: encodeCall("submitBatch", [stateRoot, ...]), │
│ maxFeePerBlobGas: currentBlobBaseFee * 2, │
│ blob_versioned_hashes: [versioned_hash], │
│ // ... other fields │
│ } │
│ │
│ 6. Sign and create network representation │
│ signed_tx = sign(tx, batcher_private_key) │
│ network_tx = rlp([signed_tx, [blob_data], [commitment], [proof]]) │
│ │
│ 7. Send via eth_sendRawTransaction │
│ // Node receives full blob data for propagation │
└─────────────────────────────────────────────────────────────────────────┘
TECHNICAL DETAILS (both cases):
┌─────────────────────────────────────────────────────────────────────────┐
│ KZG Computation (usually handled by library): │
│ (Source: EIP-4844 "Helpers" section, consensus-specs polynomial-commitments.md)
│ │
│ commitment = kzg_blob_to_commitment(blob_data) │
│ → Input: 131072 bytes (4096 field elements × 32 bytes) │
│ → Output: 48 bytes (G1 point on BLS12-381) │
│ │
│ proof = compute_blob_kzg_proof(blob_data, commitment) │
│ → Proves blob matches commitment │
│ → Output: 48 bytes │
│ │
│ versioned_hash = kzg_to_versioned_hash(commitment) │
│ → 0x01 || sha256(commitment)[1:32] (Source: EIP-4844 "Helpers") │
│ → Output: 32 bytes │
│ → This hash goes into the transaction and is accessible via BLOBHASH│
└─────────────────────────────────────────────────────────────────────────┘
Step 1: Network Propagation (Mempool)
┌─────────────────────────────────────────────────────────────────────────┐
│ SINGLE TRANSACTION Network Representation │
│ (Source: EIP-4844 "Networking" section) │
│ │
│ ONE Type-3 transaction can carry multiple blobs. │
│ (Source: EIP-4844 only requires len(blob_versioned_hashes) > 0, │
│ no per-tx upper limit; constrained by block's MAX_BLOB_GAS) │
│ │
│ Example: ONE transaction carrying 2 blobs: │
│ │
│ rlp([ │
│ tx_payload, // signed tx (contains blob_versioned_hashes[]) │
│ [blob_1, blob_2], // this tx's 2 blobs (128KB each, fixed size) │
│ [comm_1, comm_2], // corresponding 2 KZG commitments (48 bytes) │
│ [proof_1, proof_2] // corresponding 2 KZG proofs (48 bytes) │
│ ]) │
│ │
│ Key points: │
│ - This is ONE transaction, NOT multiple transactions │
│ - blob_versioned_hashes in tx_payload is an array: [hash_1, hash_2] │
│ - Each blob is exactly 128 KiB (padded with zeros if data < 128 KiB) │
│ (Source: Blob = ByteVector[32 * 4096] = 131,072 bytes fixed) │
│ - Each blob has its own commitment and proof │
│ - Max 6 blobs per block (across ALL transactions in that block) │
│ (Source: MAX_BLOB_GAS_PER_BLOCK = 786,432 = 6 × 131,072) │
│ - Nodes verify: commitment matches blob, proof is valid │
└─────────────────────────────────────────────────────────────────────────┘
│
│ Block is built...
▼
Step 2: Data Separation (at block inclusion)
Sources:
- ExecutionPayload: consensus-specs/deneb/beacon-chain.md
- BeaconBlockBody: consensus-specs/deneb/beacon-chain.md
- BlobSidecar: consensus-specs/deneb/p2p-interface.md
┌─────────────────────────────────────────────────────────────────────────┐
│ EXECUTION LAYER │
└─────────────────────────────────────────────────────────────────────────┘
ExecutionPayload (block-level container):
┌─────────────────────────────────────────────────────────────────────────┐
│ parent_hash, state_root, block_number, timestamp, ... │
│ │
│ transactions: [ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Transaction (Type-3 blob tx): │ │
│ │ chain_id, nonce, max_fee_per_gas, gas_limit, to, value, data, │ │
│ │ max_fee_per_blob_gas, │ │
│ │ blob_versioned_hashes: [hash_1, hash_2] ← only hashes! │ │
│ │ signature │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ... │
│ ] │
│ │
│ blob_gas_used: uint64 ← [New in Deneb] blob gas used in block │
│ excess_blob_gas: uint64 ← [New in Deneb] for blob fee calculation │
└─────────────────────────────────────────────────────────────────────────┘
NOTE: ExecutionPayload does NOT store actual blob data, only 32-byte versioned_hash
┌─────────────────────────────────────────────────────────────────────────┐
│ CONSENSUS LAYER │
└─────────────────────────────────────────────────────────────────────────┘
BeaconBlockBody:
┌─────────────────────────────────────────────────────────────────────────┐
│ randao_reveal, eth1_data, graffiti, ... │
│ proposer_slashings, attester_slashings, attestations, ... │
│ execution_payload: ExecutionPayload ← [Modified in Deneb] │
│ blob_kzg_commitments: List[KZGCommitment] ← [New in Deneb] │
└─────────────────────────────────────────────────────────────────────────┘
BlobSidecar (one per blob, propagated/stored separately from BeaconBlock):
┌─────────────────────────────────────────────────────────────────────────┐
│ index: BlobIndex # blob position in block (0-5) │
│ blob: Blob # actual data, 131072 bytes │
│ kzg_commitment: KZGCommitment # 48 bytes │
│ kzg_proof: KZGProof # 48 bytes │
│ signed_block_header: SignedBeaconBlockHeader │
│ ↑ verify which block this blob belongs to (without full block) │
│ kzg_commitment_inclusion_proof: Vector[Bytes32, 17] │
│ ↑ Merkle proof: proves commitment is in BeaconBlockBody │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ HOW THEY FIT TOGETHER │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────┬───────────────────────────────────────────────┐
│ EXECUTION LAYER │ CONSENSUS LAYER │
│ (ExecutionPayload) │ (BeaconBlock + BlobSidecars) │
├─────────────────────────────────┼───────────────────────────────────────────────┤
│ │ │
│ Transaction (Type-3): │ BeaconBlockBody: │
│ ┌───────────────────────────┐ │ ┌─────────────────────────────────────────┐ │
│ │ ... │ │ │ ... │ │
│ │ blob_versioned_hashes: [ │ │ │ execution_payload: ExecutionPayload │ │
│ │ hash_1, ◄───────────────┼──┼──┼─ blob_kzg_commitments: [ │ │
│ │ hash_2, │ │ │ commitment_1, ←────────────────┐ │ │
│ │ ] │ │ │ commitment_2, │ │ │
│ │ ... │ │ │ ] │ │ │
│ └───────────────────────────┘ │ └─────────────────────────────────────────┘ │
│ │ │ │
│ NO blob data stored here! │ BlobSidecar: │ │
│ Only 32-byte hashes │ ┌─────────────────────────────────────────┐ │
│ │ │ index: 0 │ │ │
│ versioned_hash = │ │ blob: [131072 bytes] │ │ │
│ 0x01||sha256(commitment)[1:] │ │ kzg_commitment ──────────────────────┘ │ │
│ │ │ kzg_proof │ │
│ │ │ ... │ │
│ │ └─────────────────────────────────────────┘ │
└─────────────────────────────────┴───────────────────────────────────────────────┘
Verification flow (when node receives a block):
1. Verify ExecutionPayload matches BeaconBlockBody
┌────────────────────────────────────────────────────────────────────────┐
│ ExecutionPayload has: BeaconBlockBody has: │
│ blob_versioned_hashes: [ blob_kzg_commitments: [ │
│ hash_1, commitment_1, │
│ hash_2, commitment_2, │
│ ] ] │
│ │
│ Verify: hash_1 == 0x01 || sha256(commitment_1)[1:] ✓ │
│ hash_2 == 0x01 || sha256(commitment_2)[1:] ✓ │
│ │
│ This ensures: tx's declared blob hashes match BeaconBlockBody commits │
└────────────────────────────────────────────────────────────────────────┘
2. Verify BlobSidecar contains valid blob data
┌────────────────────────────────────────────────────────────────────────┐
│ BlobSidecar is propagated separately via p2p, contains actual 128KB │
│ │
│ Verify: kzg_verify(blob, commitment, proof) == true ✓ │
│ │
│ This ensures: BlobSidecar's blob data actually matches the commitment │
│ (prevents fake blob data) │
└────────────────────────────────────────────────────────────────────────┘
3. If any mismatch → reject entire block
Step 3: Storage
EXECUTION LAYER stores: CONSENSUS LAYER stores:
┌─────────────────────────────┐ ┌─────────────────────────────────┐
│ ExecutionPayload │ │ BeaconBlock (permanent) │
│ ├─ transactions[] │ │ ├─ blob_kzg_commitments[] │
│ │ └─ blob_versioned_hashes │ └─ ... │
│ ├─ blobGasUsed │ │ │
│ └─ excessBlobGas │ │ BlobSidecars (temporary ~18d) │
│ │ │ ├─ blob data │
│ Stored PERMANENTLY │ │ ├─ proofs │
│ (but just hashes) │ │ └─ can be PRUNED after 18 days │
└─────────────────────────────┘ └─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ WHY THIS SEPARATION? │
└─────────────────────────────────────────────────────────────────────────┘
1. EVM doesn't need blob data
→ EVM can only access blob_versioned_hashes via BLOBHASH opcode
→ Blob content is for L2 sequencers, not smart contracts
2. Reduces execution layer burden
→ 6 blobs × 128 KB = 768 KB per block
→ If stored in execution layer, massive state growth
3. Enables future pruning
→ Blobs only needed for ~18 days (fraud proof window)
→ After that, KZG commitment proves "data was available"
4. Prepares for Danksharding
→ Future: Data Availability Sampling (DAS)
→ Nodes won't need full blobs, just samples + KZG proofs
Execution Layer (EVM): Consensus Layer (Beacon Chain):
┌────────────────────────┐ ┌────────────────────────────────┐
│ Sees: │ │ Sees (BlobSidecar): │
│ - blob_versioned_hashes│ │ - index │
│ (via BLOBHASH opcode)│ │ - blob │
│ │ │ - kzg_commitment │
│ Cannot access: │ │ - kzg_proof │
│ - Actual blob content │ │ - signed_block_header │
│ │ │ - kzg_commitment_inclusion_proof
└────────────────────────┘ └────────────────────────────────┘
KZG Commitments
Note: EIP-4844 uses KZG commitments but does NOT specify the cryptographic details.
See Ethereum KZG Ceremony for details.
KZG commitment allows:
- Commit to 128KB blob with 48-byte fingerprint
- Prove "value at position X is Y" without revealing entire blob
- Enables efficient fraud proofs for rollups
How it works (simplified):
1. Blob data → Polynomial p(x) with 4096 points
2. Commitment = p(τ) × G₁ (τ from trusted setup, unknown to everyone)
3. Proof = cryptographic evidence that blob matches commitment
Trusted setup: Ethereum's KZG Ceremony had 140,000+ participants.
Security assumption: at least ONE destroyed their secret.
EIP-4844 Constants and Economics
Source: EIP-4844 Parameters
┌─────────────────────────────────────────────────────────────────────────┐
│ KEY NUMBERS TO REMEMBER │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Blob size: 128 KiB (131,072 bytes = 4096 × 32) │
│ Max per block: 6 blobs (768 KiB total) │
│ Target: 3 blobs (price stable when avg = 3) │
│ Blob tx type: 0x03 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BLOB FEE = SEPARATE FROM GAS FEE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Two independent fee markets: │
│ 1. Execution gas → normal EIP-1559 (base_fee + priority_fee) │
│ 2. Blob gas → new EIP-4844 market (blob_base_fee) │
│ │
│ Blob fee adjusts like EIP-1559: │
│ > 3 blobs/block → blob_base_fee increases (exponentially) │
│ < 3 blobs/block → blob_base_fee decreases │
│ │
│ Why this matters for auditing: │
│ → Rollups must track BOTH fees when calculating L1 costs │
│ → Fee spikes can happen independently (Pattern 1, 4) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
New EVM Opcodes
Source: EIP-4844 Specification
BLOBHASH (0x49) - 3 gas
bytes32 hash = blobhash(index);
Returns tx.blob_versioned_hashes[index], or 0x00...00 if out of bounds
BLOBBASEFEE (0x4a) - EIP-7516, NOT EIP-4844
Returns current block's blob base fee (block.blobbasefee in Solidity)
Blob Data Lifecycle
┌─────────────────────────────────────────────────────────────────────────┐
│ BLOB DATA LIFECYCLE │
└─────────────────────────────────────────────────────────────────────────┘
Source: consensus-specs deneb/p2p-interface.md
MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS = 4096 epochs
EIP-4844 states:
"The specific value chosen is MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS
epochs, which is around 18 days"
Calculation:
4096 epochs × 32 slots/epoch × 12 seconds/slot
= 4096 × 32 × 12 seconds
= 1,572,864 seconds
≈ 18.2 days
Timeline:
Block N Block N + ~4096 epochs
┌─────┐ ┌─────┐
│Blob │ │ │
│Data │ │ │
└──┬──┘ └─────┘
│ │
│◄─────── MIN_EPOCHS_FOR_BLOB_SIDECARS ──►│
│ (4096 epochs ≈ 18 days) │
▼ ▼
Blob available Blob pruned
(nodes must serve) (can be deleted)
Note: After pruning, the KZG commitment remains in the beacon block,
but the actual blob data is no longer required to be served.
Engine API V3
EIP-4844 requires updates to the Engine API - the communication protocol between execution clients (Geth, Reth) and consensus clients (Prysm, Lighthouse).
┌─────────────────────────────────────────────────────────────────────────┐
│ ENGINE API VERSION HISTORY │
└─────────────────────────────────────────────────────────────────────────┘
Pre-Merge Post-Merge Cancun Prague
(PoW) (PoS) (EIP-4844) (Future)
│ │ │ │
▼ ▼ ▼ ▼
No Engine Engine API V1 Engine API V3 Engine API V4
API NewPayloadV1 NewPayloadV3 NewPayloadV4
ForkchoiceV1 ForkchoiceV3 ForkchoiceV4
GetPayloadV1 GetPayloadV3 GetPayloadV4
┌─────────────────────────────────────────────────────────────────────────┐
│ CANCUN ENGINE API METHODS │
└─────────────────────────────────────────────────────────────────────────┘
Three methods updated for EIP-4844:
1. engine_newPayloadV3 - Validate & execute new block
2. engine_forkchoiceUpdatedV3 - Update fork choice, request payload build
3. engine_getPayloadV3 - Retrieve built payload + blob bundle
┌─────────────────────────────────────────────────────────────────────────┐
│ engine_newPayloadV3 │
└─────────────────────────────────────────────────────────────────────────┘
Purpose: Validate and execute a new execution payload
Parameters:
┌────────────────────────────────────────────────────────────────────────┐
│ 1. executionPayload (ExecutionPayloadV3) │
│ └─ Block data: txs, state root, receipts root, etc. │
│ └─ NEW: blobGasUsed, excessBlobGas fields │
│ │
│ 2. expectedBlobVersionedHashes (Array<Hash>) │
│ └─ Ordered list of blob hashes from consensus layer │
│ └─ MUST match hashes extracted from Type-3 txs in payload │
│ │
│ 3. parentBeaconBlockRoot (Hash) │
│ └─ For EIP-4788 beacon block root in EVM │
└────────────────────────────────────────────────────────────────────────┘
Response:
┌────────────────────────────────────────────────────────────────────────┐
│ { │
│ status: "VALID" | "INVALID" | "SYNCING", │
│ latestValidHash: Hash | null, │
│ validationError: string | null │
│ } │
└────────────────────────────────────────────────────────────────────────┘
Validation Flow:
┌──────────────────┐ ┌──────────────────┐
│ Consensus Layer │ │ Execution Layer │
│ (Prysm/Lighthouse) │ (Geth/Reth) │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ engine_newPayloadV3(payload, hashes, root) │
│─────────────────────────────────────────────────►
│ │
│ Step 1: Parameter Validation │
│ ├─ Check timestamp in Cancun range
│ ├─ Verify payload structure │
│ └─ If fail → return error code │
│ │
│ Step 2: Blob Hash Validation │
│ ├─ Extract hashes from Type-3 txs│
│ ├─ Compare with expectedHashes │
│ └─ If mismatch → INVALID │
│ (runs even during sync!) │
│ │
│ Step 3: Execute Payload │
│ ├─ Process all transactions │
│ └─ Compute new state root │
│ │
│◄─────────────────────────────────────────────────
│ {status: VALID/INVALID, latestValidHash, ...} │
Error Codes:
-32602: Invalid params (structure mismatch)
-38005: Unsupported fork (timestamp not in Cancun range)
┌─────────────────────────────────────────────────────────────────────────┐
│ engine_forkchoiceUpdatedV3 │
└─────────────────────────────────────────────────────────────────────────┘
Purpose: Update fork choice state, optionally request payload building
Parameters:
┌────────────────────────────────────────────────────────────────────────┐
│ 1. forkchoiceState (ForkchoiceStateV1) │
│ ├─ headBlockHash: current head │
│ ├─ safeBlockHash: safe block │
│ └─ finalizedBlockHash: finalized block │
│ │
│ 2. payloadAttributes (PayloadAttributesV3 | null) │
│ ├─ timestamp: target block timestamp │
│ ├─ prevRandao: RANDAO value │
│ ├─ suggestedFeeRecipient: fee recipient address │
│ ├─ withdrawals: validator withdrawals │
│ └─ parentBeaconBlockRoot: NEW for Cancun (EIP-4788) │
└────────────────────────────────────────────────────────────────────────┘
Response:
┌────────────────────────────────────────────────────────────────────────┐
│ { │
│ payloadStatus: {status, latestValidHash, validationError}, │
│ payloadId: 8-byte identifier | null │
│ } │
└────────────────────────────────────────────────────────────────────────┘
Error Codes:
-38003: Invalid payload attributes
-38005: Unsupported fork
┌─────────────────────────────────────────────────────────────────────────┐
│ engine_getPayloadV3 │
└─────────────────────────────────────────────────────────────────────────┘
Purpose: Retrieve constructed payload and blob bundle
Parameters:
┌────────────────────────────────────────────────────────────────────────┐
│ 1. payloadId (8 bytes) │
│ └─ Identifier from forkchoiceUpdatedV3 response │
└────────────────────────────────────────────────────────────────────────┘
Response:
┌────────────────────────────────────────────────────────────────────────┐
│ { │
│ executionPayload: ExecutionPayloadV3, │
│ blockValue: wei value for fee recipient, │
│ blobsBundle: { ◄─── NEW for Cancun │
│ commitments: [KZG commitment for each blob], │
│ proofs: [KZG proof for each blob], │
│ blobs: [actual blob data] │
│ }, │
│ shouldOverrideBuilder: boolean │
│ } │
└────────────────────────────────────────────────────────────────────────┘
Blob Bundle Requirements:
→ commitments MUST match versioned hashes in payload txs (same order)
→ proofs MUST be valid KZG proofs for corresponding blobs
→ Empty bundle if no blob transactions in payload
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLETE BLOCK PROPOSAL FLOW │
└─────────────────────────────────────────────────────────────────────────┘
Proposer builds and broadcasts a new block:
Consensus Client Execution Client
┌────────────────┐ ┌────────────────┐
│ │ │ │
│ 1. My slot to │ forkchoiceUpdatedV3 │ │
│ propose! │ ─────────────────────────► │ Start building │
│ │ (with payloadAttributes) │ payload... │
│ │ ◄───────────────────────── │ │
│ │ {payloadId: 0x1234...} │ │
│ │ │ │
│ 2. Get payload │ getPayloadV3(0x1234) │ │
│ │ ─────────────────────────► │ │
│ │ ◄───────────────────────── │ │
│ │ {payload, blobsBundle} │ │
│ │ │ │
│ 3. Broadcast │ │ │
│ block + │ │ │
│ blobs │ │ │
└────────────────┘ └────────────────┘
│
▼
Other validators receive block...
Other Validators Their Execution
┌────────────────┐ ┌────────────────┐
│ │ newPayloadV3 │ │
│ 4. Validate │ ─────────────────────────► │ Validate & │
│ received │ (payload, blobHashes, │ execute │
│ block │ beaconRoot) │ │
│ │ ◄───────────────────────── │ │
│ │ {status: VALID} │ │
│ │ │ │
│ 5. Update │ forkchoiceUpdatedV3 │ │
│ fork choice │ ─────────────────────────► │ Update head │
│ │ (new head, no attributes) │ │
└────────────────┘ └────────────────┘
Point Evaluation Precompile
┌─────────────────────────────────────────────────────────────────────────┐
│ PRECOMPILE: POINT EVALUATION (0x0A) │
└─────────────────────────────────────────────────────────────────────────┘
Source: EIP-4844 Parameters
POINT_EVALUATION_PRECOMPILE_ADDRESS = 0x0A
POINT_EVALUATION_PRECOMPILE_GAS = 50,000
Source: EIP-4844 "Point evaluation precompile" section
Input (192 bytes):
versioned_hash: 32 bytes - "the versioned hash to be verified"
z: 32 bytes - "x-coordinate of evaluation point" (padded big endian)
y: 32 bytes - "expected y for the blob evaluation" (padded big endian)
commitment: 48 bytes - "the commitment, which can be KZG or any scheme"
proof: 48 bytes - "a proof for the commitment"
Output (64 bytes):
FIELD_ELEMENTS_PER_BLOB || BLS_MODULUS (both as 32-byte big endian)
What it verifies (from EIP-4844):
"Verify p(z) = y given commitment that corresponds to the polynomial
p(x) and a KZG proof. Also verify that the provided commitment
matches the provided versioned_hash."
┌─────────────────────────────────────────────────────────────────────────┐
│ WHY IS THIS NEEDED? │
└─────────────────────────────────────────────────────────────────────────┘
Problem: EVM cannot access blob data, only blob_versioned_hashes
But smart contracts may need to verify claims about blob content!
Solution: Point evaluation precompile allows proving:
"At position z in the blob, the value is y"
without revealing the entire blob
Use case example (ZK Rollup):
1. Rollup posts blob with transaction batch
2. Fraud proof needs to prove "tx at index 42 has value X"
3. Cannot access blob directly in EVM
4. Use point evaluation: prove p(42) = X where p is blob polynomial
5. Precompile verifies the proof on-chain
┌─────────────────────────────────────────────────────────────────────────┐
│ VERIFICATION FLOW │
└─────────────────────────────────────────────────────────────────────────┘
On-chain (EVM) Off-chain
┌─────────────┐ ┌─────────────┐
│ Smart │ │ Blob Data │
│ Contract │ │ (full) │
│ │ │ │
│ Has: │ │ Can compute:│
│ - blobhash │◄────────────────│ - commitment│
│ (0x49) │ commitment, │ - z, y │
│ │ proof, z, y │ - proof │
└─────┬───────┘ └─────────────┘
│
│ Call precompile 0x0A
▼
┌─────────────────────────────────────────────┐
│ Point Evaluation Precompile │
│ │
│ 1. Verify: sha256(commitment)[1:] matches │
│ versioned_hash (sans version byte) │
│ │
│ 2. Verify KZG proof: p(z) = y │
│ using pairing check │
│ │
│ 3. Return success or revert │
└─────────────────────────────────────────────┘
Solidity usage example:
function verifyBlobData(
bytes32 versionedHash,
bytes32 z,
bytes32 y,
bytes memory commitment, // 48 bytes
bytes memory proof // 48 bytes
) external view returns (bool) {
bytes memory input = abi.encodePacked(
versionedHash,
z,
y,
commitment,
proof
);
(bool success,) = address(0x0A).staticcall(input);
return success;
}
Vulnerability Patterns
1. Blob Size Constraint Violation in Batch Processing
Prerequisite Knowledge:
Each blob has a fixed size limit of 128 KiB. A rollup batch containing multiple L2 blocks must fit within this limit. If a batch exceeds blob size, it must be split across multiple blobs - but this requires explicit handling.
Root Cause:
The batch processing logic assumes all batches fit in a single blob. When L2 transactions have large calldata, batches can exceed 128 KiB with no mechanism to split them.
Attack Vector:
Attacker submits a single L2 transaction with 128 KB of calldata. Since the batch processing cannot split data across multiple blobs, this single transaction fills the entire blob. The batch ends up containing only the attacker’s transaction, effectively “stuffing” the L2 block. Unlike traditional block stuffing (which requires expensive computation), this attack is much cheaper because blob fees are lower than execution gas. The attack blocks all other L2 transactions from being included in that batch.
Source:
- [sherlock/2024-08-morphl2] Rollup.sol cannot split batches across blobs (
issue-91.md)morph/contracts/contracts/l1/rollup/Rollup.solBusiness Scenario:
┌─────────────────────────────────────────────────────────────────────────┐ │ MORPH ROLLUP DATA STRUCTURE │ └─────────────────────────────────────────────────────────────────────────┘ L2 Blocks ──► Chunks ──► Batch ──► Blob (on L1) │ │ │ │ │ │ │ └─ 128 KiB fixed (EIP-4844) │ │ └─ Up to 45 chunks (maxChunks in Gov.sol) │ └─ Up to 100 blocks (MaxBlocksPerChunk) └─ Contains L2 transactions Theoretical max: 45 × 100 = 4,500 blocks per batch KEY CONSTRAINT: One batch MUST fit into ONE blob (128 KiB)! ┌─────────────────────────────────────────────────────────────────────────┐ │ NORMAL FLOW │ └─────────────────────────────────────────────────────────────────────────┘ L2 Sequencer L1 ┌────────────────────────────────┐ ┌────────────┐ │ Block 1: [tx, tx, tx, ...] │ │ │ │ Block 2: [tx, tx, ...] │ commitBatch() │ Blob │ │ Block 3: [tx, tx, tx, ...] │ ─────────────► │ < 128 KiB │ │ ... │ │ │ │ Total: ~50 KiB │ └────────────┘ └────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ ATTACK SCENARIO │ └─────────────────────────────────────────────────────────────────────────┘ Attacker submits ONE tx with 128 KB calldata L2 Sequencer L1 ┌────────────────────────────────┐ ┌────────────┐ │ │ │ │ │ Block 1: [attacker_tx] │ commitBatch() │ Blob │ │ (128 KB calldata) │ ─────────────► │ = 128 KiB │ │ │ │ FULL! │ │ ❌ No room for other txs! │ └────────────┘ │ ❌ Cannot split to 2 blobs! │ └────────────────────────────────┘ Result: Entire batch = 1 block = 1 tx (attacker's) All other L2 transactions blocked! ┌─────────────────────────────────────────────────────────────────────────┐ │ WHY IS THIS CHEAP? │ └─────────────────────────────────────────────────────────────────────────┘ Normal block stuffing: Fill 30M gas limit → very expensive This attack: Fill 128 KB blob → much cheaper (blob fees < execution gas)Vulnerable Code:
The vulnerability is in the L2 block building logic (
miner/pipeline.go):// miner/pipeline.go (morph-l2/go-ethereum) // Block building tracks cumulative size and skips txs exceeding blob limit for { tx := txs.Peek() if tx == nil { break } // VULNERABILITY: If single tx fills blob, block contains only that tx // No mechanism to split batch across multiple blobs if w.current.tcount > 0 && cumulativeSize+txSize > blobLimit { txs.Pop() // Skip tx that would exceed blob continue } // Include tx in block cumulativeSize += txSize // ... }On L1,
Rollup.solassumes batch fits in a single blob:// Rollup.sol function commitBatch(...) external { // ... // Uses blobhash(0) - assumes single blob per batch bytes32 _blobVersionedHash = blobhash(0); // ← Only checks first blob! // No mechanism to handle batch spanning multiple blobs }
2. Empty VersionedHashes in Engine API V3 Blob Handling
Prerequisite Knowledge:
As described in the Engine API V3 section above, NewPayloadV3 requires the expectedBlobVersionedHashes parameter to match the hashes computed from the block’s blob transactions. If they don’t match, the execution layer returns INVALID and the block is rejected.
Root Cause:
The consensus layer’s ProcessProposal always passes an empty versionedHashes array to NewPayloadV3, regardless of whether the block contains blob transactions.
Attack Vector:
Any user submits a Type-3 blob transaction to the network. When this transaction is included in a block, the execution layer extracts the blob versioned hashes from the transaction. However, the consensus layer’s ProcessProposal always passes an empty array [] to NewPayloadV3. Since the empty array doesn’t match the actual blob hashes, the execution layer returns INVALID status. The block is rejected, and all future blocks containing blob transactions will also be rejected. Cost: just the gas for one blob transaction. Impact: complete chain denial of service.
Source:
- [cantina/2024-omni-network] Blob transactions can halt the chain (
3.1.7)omni/halo/app/proofprovider.goBusiness Scenario:
┌─────────────────────────────────────────────────────────────────────────┐ │ OMNI NETWORK CONSENSUS LAYER │ └─────────────────────────────────────────────────────────────────────────┘ Omni Chain Architecture: ┌──────────────────────────────────────────────────────────────────────┐ │ Consensus Layer (CometBFT/Tendermint) │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ ProcessProposal() │ │ │ │ → Validates proposed blocks │ │ │ │ → Calls Engine API to verify execution │ │ │ └────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ engine_newPayloadV3() │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ Execution Layer (Ethereum-compatible) │ │ │ │ → Executes transactions │ │ │ │ → Validates blob versioned hashes │ │ │ └────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘ Expected NewPayloadV3 Call: ┌──────────────────────────────────────────────────────────────────────┐ │ Block contains Type-3 blob tx │ │ │ │ NewPayloadV3( │ │ payload, │ │ versionedHashes: [0x01abc..., 0x01def...] ← from blob txs │ │ beaconRoot │ │ ) │ │ │ │ Execution layer: hashes match ✓ → VALID │ └──────────────────────────────────────────────────────────────────────┘ Actual (Buggy) Call: ┌──────────────────────────────────────────────────────────────────────┐ │ Block contains Type-3 blob tx │ │ │ │ NewPayloadV3( │ │ payload, ← contains blob tx with hashes [0x01abc...] │ │ versionedHashes: [] ← ALWAYS EMPTY! ❌ │ │ beaconRoot │ │ ) │ │ │ │ Execution layer validates: │ │ 1. Extract hashes from payload's blob txs → [0x01abc...] │ │ 2. Compare with versionedHashes parameter → [] │ │ 3. [0x01abc...] != [] → INVALID ❌ │ └──────────────────────────────────────────────────────────────────────┘ Result: ANY blob transaction → Block rejected → Chain haltsVulnerable Code:
// proofprovider.go func (a *App) ProcessProposal(_ context.Context, req *abci.RequestProcessProposal) (*abci.ResponseProcessProposal, error) { // ... // VULNERABILITY: Always empty, never matches actual blob hashes emptyVersionHashes := make([]common.Hash, 0) status, err := engineCl.NewPayloadV3(ctx, payload, emptyVersionHashes, &appHash) // When block has blob txs: blobHashes != [] but versionedHashes == [] // Result: status = INVALID → block rejected → chain halts if err != nil || isInvalid(status) { return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT}, nil } // ... }
3. L2 Fee Reimbursement Excluding L1 Data Costs
Prerequisite Knowledge:
On L2 rollups, transaction costs include:
- L2 execution fee: Gas used on L2 × L2 gas price
- L1 data fee: Cost to post tx data to L1 (often the larger component!)
Relayers/keepers who execute transactions on behalf of users must be reimbursed for BOTH costs.
Root Cause:
Fee reimbursement logic only considers L2 gas consumption, completely ignoring L1 data fees. On rollups, L1 data fees can be 10-100x larger than L2 execution fees.
Attack Vector:
On L2 rollups, keepers pay both L2 execution fees ($0.30) and L1 data fees ($7.00) when submitting transactions. However, the reimbursement logic only calculates gasSpent × block.basefee, which covers only the L2 portion. Keepers lose ~$7.00 per transaction. Over time, keepers stop executing jobs because they lose money on every execution. This causes time-sensitive operations (liquidations, oracle updates) to fail, making the protocol non-functional on L2.
Instances:
[sherlock/2024-04-xkeeper] L1 data fees are not reimbursed (
issue-57.md)xkeeper-core/solidity/contracts/relays/OpenRelay.solBusiness Scenario:
┌─────────────────────────────────────────────────────────────────────────┐ │ xKEEPER AUTOMATION PROTOCOL │ └─────────────────────────────────────────────────────────────────────────┘ How xKeeper Works: ┌──────────────────────────────────────────────────────────────────────┐ │ 1. User creates Automation Vault │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Vault contains: │ │ │ │ - Jobs to execute (e.g., harvest rewards, rebalance) │ │ │ │ - Funds to pay keepers │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ 2. Keeper monitors and executes │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Keeper Bot │ │ │ │ → Watches for executable jobs │ │ │ │ → Submits tx to execute job │ │ │ │ → Pays gas upfront │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ 3. Reimbursement (BUGGY!) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ payment = (gasSpent + BONUS) × block.basefee × MULTIPLIER │ │ │ │ │ │ │ │ ❌ Missing: L1 data fee! │ │ │ │ │ │ │ │ On L2 (Arbitrum/Optimism): │ │ │ │ L2 gas: ~$0.30 │ │ │ │ L1 data fee: ~$7.00 ← NOT REIMBURSED! │ │ │ │ Keeper loss: ~$6.70 per execution │ │ │ └─────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘Vulnerable Code:
// OpenRelay.sol uint256 _initialGas = gasleft(); _automationVault.exec(msg.sender, _execData, new IAutomationVault.FeeData[](0)); uint256 _gasSpent = _initialGas - gasleft(); // VULNERABILITY: Only L2 gas, ignores L1 data fee! uint256 _payment = (_gasSpent + GAS_BONUS) * block.basefee * GAS_MULTIPLIER / BASE;
[sherlock/2024-05-elfi-protocol] The keeper will suffer continuing losses (
issue-141.md)elfi-perp-contracts/contracts/process/GasProcess.solBusiness Scenario:
┌─────────────────────────────────────────────────────────────────────────┐ │ ELFI PERPETUAL PROTOCOL │ └─────────────────────────────────────────────────────────────────────────┘ Deployed on: Arbitrum, Optimism (L2 rollups) Order Execution Flow: ┌──────────────────────────────────────────────────────────────────────┐ │ 1. User submits order + deposits execution fee │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Order: Open 10x long ETH │ │ │ │ userExecutionFee: 0.001 ETH (prepaid) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 2. Keeper executes order │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ Keeper submits tx to L2 │ │ │ │ │ │ │ │ Keeper pays: │ │ │ │ L2 execution: 100,000 gas × 0.001 gwei = ~$0.30 │ │ │ │ L1 data fee: (tx posted to L1) = ~$7.00 │ │ │ │ ───────────────────────────────────────────────── │ │ │ │ Total paid by keeper: = ~$7.30 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 3. Reimbursement (BUGGY!) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ executionFee = usedGas × tx.gasprice │ │ │ │ = 100,000 × 0.001 gwei = ~$0.30 │ │ │ │ │ │ │ │ Keeper receives: $0.30 │ │ │ │ Keeper paid: $7.30 │ │ │ │ ──────────────────────── │ │ │ │ Keeper LOSS: $7.00 per order! ❌ │ │ │ └─────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘Vulnerable Code:
// GasProcess.sol function processExecutionFee(PayExecutionFeeParams memory cache) external { uint256 usedGas = cache.startGas - gasleft(); // VULNERABILITY: Only L2 fee, no L1 rollup fee! uint256 executionFee = usedGas * tx.gasprice; uint256 refundFee; uint256 lossFee; if (executionFee > cache.userExecutionFee) { executionFee = cache.userExecutionFee; lossFee = executionFee - cache.userExecutionFee; } else { refundFee = cache.userExecutionFee - executionFee; } VaultProcess.transferOut(...); // Keeper receives only L2 fee compensation VaultProcess.withdrawEther(cache.keeper, executionFee); // ... }
EIP-4844 Audit Checklist
When auditing contracts that interact with EIP-4844 blob transactions, check for these issues:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Handles batches > 128 KiB? Must split across multiple blobs or reject gracefully | Pattern 1 |
| ✅ | Engine API versionedHashes populated? Must match blobHashes from block’s blob txs | Pattern 2 |
| ✅ | L1 data fees included in reimbursement? Especially critical for L2 deployments | Pattern 3 |
InfiniteSec