From 7023837b28940e3eca328d6245fdd136a0728eb4 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 28 May 2026 16:23:36 +0200 Subject: [PATCH] execution/types, stagedsync: EIP-7928 phantom-read early reject Implements the Security-Considerations mitigation from EIP-7928 against malicious proposers declaring phantom storage_reads. Without this check ValidateMaxItems only catches an oversized BAL post-execution, after the client has already paid the I/O cost the mitigation is meant to avoid. execution/types/block_access_list.go adds CountDeclaredReads, DeclaredReadTracker (idempotent under speculative re-execution) and the EarlyRejectCheck budget rule: G_remaining >= R_remaining * BalItemCost. execution/stagedsync/exec3_parallel.go captures the block gas limit at newBlockExec, builds a tracker from the declared accessList, and after each tx validates and consumes its gas walks the StoragePath reads, attributes them to declared slots, and calls EarlyRejectCheck. Failure returns ErrInvalidBlock via the existing invalidBlockResult path. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/stagedsync/exec3_parallel.go | 78 ++++++++++++--- execution/types/block_access_list.go | 85 ++++++++++++++++ execution/types/block_access_list_test.go | 115 ++++++++++++++++++++++ 3 files changed, 262 insertions(+), 16 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 978278b7d7b..b42c866c79f 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2054,6 +2054,12 @@ type blockExecutor struct { blobGasUsed uint64 gasPool *protocol.GasPool + // EIP-7928 phantom-read DoS mitigation: blockGasLimit is captured at + // newBlockExec time (fresh gasPool); declaredReadTracker counts which + // storage_reads from the declared BAL have been observed as actual reads. + blockGasLimit uint64 + declaredReadTracker *types.DeclaredReadTracker + execFailed, execAborted []int // Stores the execution statistics for the last incarnation of each task @@ -2104,24 +2110,47 @@ func (be *blockExecutor) sendResult(ctx context.Context, r applyResult) (err err } func newBlockExec(blockNum uint64, blockHash common.Hash, gasPool *protocol.GasPool, accessList types.BlockAccessList, applyResults chan applyResult, commitResults chan applyResult, profile bool, exhausted *ErrLoopExhausted) *blockExecutor { + // gasPool is freshly constructed per block, so its regular-dimension capacity + // equals the block gas limit at this point. Capture it for EIP-7928 mitigation. + var blockGasLimit uint64 + if gasPool != nil { + blockGasLimit = gasPool.RegularGasAvailable() + } return &blockExecutor{ - blockNum: blockNum, - blockHash: blockHash, - begin: time.Now(), - stats: map[int]ExecutionStat{}, - finalizedResults: map[int]*execResult{}, - settledInput: map[int]bool{}, - estimateDeps: map[int][]int{}, - preValidated: map[int]bool{}, - blockIO: &state.VersionedIO{}, - versionMap: state.NewVersionMap(accessList), - profile: profile, - applyResults: applyResults, - commitResults: commitResults, - gasPool: gasPool, - blockStateCache: state.NewBlockStateCache(), - exhausted: exhausted, + blockNum: blockNum, + blockHash: blockHash, + begin: time.Now(), + stats: map[int]ExecutionStat{}, + finalizedResults: map[int]*execResult{}, + settledInput: map[int]bool{}, + estimateDeps: map[int][]int{}, + preValidated: map[int]bool{}, + blockIO: &state.VersionedIO{}, + versionMap: state.NewVersionMap(accessList), + profile: profile, + applyResults: applyResults, + commitResults: commitResults, + gasPool: gasPool, + blockStateCache: state.NewBlockStateCache(), + exhausted: exhausted, + blockGasLimit: blockGasLimit, + declaredReadTracker: types.NewDeclaredReadTracker(accessList), + } +} + +// gasUsedSoFar returns the conservative high-water mark of block gas consumed +// across both EIP-8037 dimensions: blockGasLimit minus the smaller remaining +// reservoir. Used by EIP-7928 EarlyRejectCheck to size the gas budget still +// available for outstanding declared reads. +func (be *blockExecutor) gasUsedSoFar() uint64 { + if be.gasPool == nil { + return 0 + } + remaining := min(be.gasPool.RegularGasAvailable(), be.gasPool.StateGasAvailable()) + if remaining >= be.blockGasLimit { + return 0 } + return be.blockGasLimit - remaining } // invalidBlockResult wraps a block-validity failure (insufficient funds, gas @@ -2558,6 +2587,23 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r txResult.writes = normalizeWriteSet(rawWrites, be.versionMap, txVersion.TxIndex, resultIncarnation, stateReader, domainStorageKeys, pe.cfg.chainConfig.IsSpuriousDragon(be.blockNum)) } + // EIP-7928 phantom-read DoS mitigation. After a tx validates and + // consumes its gas, attribute its storage reads to the declared + // BAL and verify enough gas remains to satisfy outstanding + // declared reads at BalItemCost each. Empty BAL ⇒ Remaining() + // is 0 and the check is a no-op. + if reads := be.blockIO.ReadSet(txVersion.TxIndex); reads != nil { + reads.Scan(func(vr *state.VersionedRead) bool { + if vr.Path == state.StoragePath { + be.declaredReadTracker.ObserveRead(vr.Address.Value(), vr.Key.Value()) + } + return true + }) + } + if err := types.EarlyRejectCheck(be.blockGasLimit, be.gasUsedSoFar(), be.declaredReadTracker.Remaining()); err != nil { + return be.invalidBlockResult(fmt.Errorf("%w, block=%d txIdx=%d: %w", rules.ErrInvalidBlock, be.blockNum, txVersion.TxIndex, err)), nil + } + // Snapshot the finalized result before pushing — prevents // the publish loop from seeing a later incarnation if // be.results[tx] is overwritten by a concurrent worker. diff --git a/execution/types/block_access_list.go b/execution/types/block_access_list.go index 1e4520cbfaf..525a7fcc416 100644 --- a/execution/types/block_access_list.go +++ b/execution/types/block_access_list.go @@ -874,6 +874,91 @@ func (bal BlockAccessList) ValidateMaxItems(blockGasLimit uint64) error { return nil } +// CountDeclaredReads returns the total number of storage_reads entries across +// all accounts (R_declared in EIP-7928 Security Considerations). +func (bal BlockAccessList) CountDeclaredReads() int { + var n int + for _, ac := range bal { + n += len(ac.StorageReads) + } + return n +} + +// DeclaredReadTracker accumulates which (address, slot) pairs from a declared +// BlockAccessList's storage_reads have been satisfied by an actual SLOAD during +// block execution. It backs the EIP-7928 phantom-read DoS mitigation: at each +// transaction boundary, the executor observes the tx's reads, then asks +// EarlyRejectCheck whether enough block gas remains to still satisfy the +// outstanding declared reads at the minimum per-read cost. +type DeclaredReadTracker struct { + pending map[common.Address]map[common.Hash]struct{} + remaining int +} + +func NewDeclaredReadTracker(bal BlockAccessList) *DeclaredReadTracker { + t := &DeclaredReadTracker{pending: make(map[common.Address]map[common.Hash]struct{}, len(bal))} + for _, ac := range bal { + if ac == nil || len(ac.StorageReads) == 0 { + continue + } + addr := ac.Address.Value() + slots := make(map[common.Hash]struct{}, len(ac.StorageReads)) + for _, k := range ac.StorageReads { + slots[k.Value()] = struct{}{} + } + t.pending[addr] = slots + t.remaining += len(slots) + } + return t +} + +// ObserveRead marks (addr, slot) as observed. First-time observation of a +// declared storage_read decrements Remaining; reads of undeclared slots or +// repeats are no-ops. +func (t *DeclaredReadTracker) ObserveRead(addr common.Address, slot common.Hash) { + slots, ok := t.pending[addr] + if !ok { + return + } + if _, ok := slots[slot]; !ok { + return + } + delete(slots, slot) + if len(slots) == 0 { + delete(t.pending, addr) + } + t.remaining-- +} + +// Remaining returns the number of declared storage_reads not yet observed +// (R_remaining in EIP-7928 Security Considerations). +func (t *DeclaredReadTracker) Remaining() int { return t.remaining } + +// EarlyRejectCheck enforces the EIP-7928 Security Considerations budget: +// +// G_remaining >= R_remaining * BalItemCost +// +// where G_remaining = blockGasLimit - gasUsed and R_remaining is the count of +// declared storage_reads still outstanding. Returns nil when the block can +// still feasibly satisfy its declared reads, an error otherwise. The check is +// a non-consensus DoS defense: a malicious proposer could declare phantom +// reads, and post-execution validation alone would force the client to fetch +// the state before noticing the mismatch. +func EarlyRejectCheck(blockGasLimit, gasUsed uint64, remainingDeclaredReads int) error { + if gasUsed > blockGasLimit { + return fmt.Errorf("gas accounting: used %d exceeds limit %d", gasUsed, blockGasLimit) + } + if remainingDeclaredReads <= 0 { + return nil + } + gasRemaining := blockGasLimit - gasUsed + needed := uint64(remainingDeclaredReads) * BalItemCost + if gasRemaining < needed { + return fmt.Errorf("phantom declared reads: %d × %d = %d gas needed, %d remaining", remainingDeclaredReads, BalItemCost, needed, gasRemaining) + } + return nil +} + func (ac *AccountChanges) validate() error { if ac == nil { return errors.New("nil account changes") diff --git a/execution/types/block_access_list_test.go b/execution/types/block_access_list_test.go index d05ed1a4e34..81a3f0fa245 100644 --- a/execution/types/block_access_list_test.go +++ b/execution/types/block_access_list_test.go @@ -181,6 +181,121 @@ func TestBlockAccessListHashEmpty(t *testing.T) { } } +func TestBlockAccessListCountDeclaredReads(t *testing.T) { + var addrA, addrB, addrC common.Address + addrA[19] = 0x01 + addrB[19] = 0x02 + addrC[19] = 0x03 + + bal := BlockAccessList{ + { + Address: accounts.InternAddress(addrA), + StorageReads: []accounts.StorageKey{ + accounts.InternKey(common.HexToHash("0x01")), + accounts.InternKey(common.HexToHash("0x02")), + }, + }, + { + Address: accounts.InternAddress(addrB), + StorageReads: []accounts.StorageKey{ + accounts.InternKey(common.HexToHash("0x03")), + }, + }, + {Address: accounts.InternAddress(addrC)}, // no reads + } + if got, want := bal.CountDeclaredReads(), 3; got != want { + t.Fatalf("CountDeclaredReads: got %d, want %d", got, want) + } + if got := (BlockAccessList{}).CountDeclaredReads(); got != 0 { + t.Fatalf("empty BAL CountDeclaredReads: got %d, want 0", got) + } +} + +func TestDeclaredReadTracker(t *testing.T) { + var addrA, addrB common.Address + addrA[19] = 0x01 + addrB[19] = 0x02 + slotA1 := common.HexToHash("0x01") + slotA2 := common.HexToHash("0x02") + slotB1 := common.HexToHash("0x03") + + bal := BlockAccessList{ + { + Address: accounts.InternAddress(addrA), + StorageReads: []accounts.StorageKey{ + accounts.InternKey(slotA1), + accounts.InternKey(slotA2), + }, + }, + { + Address: accounts.InternAddress(addrB), + StorageReads: []accounts.StorageKey{accounts.InternKey(slotB1)}, + }, + } + + tr := NewDeclaredReadTracker(bal) + if got := tr.Remaining(); got != 3 { + t.Fatalf("initial Remaining: got %d, want 3", got) + } + + tr.ObserveRead(addrA, slotA1) + if got := tr.Remaining(); got != 2 { + t.Fatalf("after first declared read: got %d, want 2", got) + } + + tr.ObserveRead(addrA, slotA1) + if got := tr.Remaining(); got != 2 { + t.Fatalf("duplicate observe must not double-count: got %d, want 2", got) + } + + tr.ObserveRead(addrA, common.HexToHash("0xff")) + if got := tr.Remaining(); got != 2 { + t.Fatalf("undeclared slot must not decrement: got %d, want 2", got) + } + + tr.ObserveRead(common.HexToAddress("0xdeadbeef"), slotA1) + if got := tr.Remaining(); got != 2 { + t.Fatalf("read on unknown address must not decrement: got %d, want 2", got) + } + + tr.ObserveRead(addrA, slotA2) + tr.ObserveRead(addrB, slotB1) + if got := tr.Remaining(); got != 0 { + t.Fatalf("after draining all declared reads: got %d, want 0", got) + } +} + +func TestDeclaredReadTrackerEmpty(t *testing.T) { + tr := NewDeclaredReadTracker(nil) + if got := tr.Remaining(); got != 0 { + t.Fatalf("nil BAL Remaining: got %d, want 0", got) + } + tr.ObserveRead(common.HexToAddress("0x01"), common.HexToHash("0x01")) + if got := tr.Remaining(); got != 0 { + t.Fatalf("observe on empty tracker must remain 0: got %d", got) + } +} + +func TestEarlyRejectCheck(t *testing.T) { + // 60M gas limit, 1000 declared reads ⇒ 2,000,000 gas needed. + // gasUsed=58,000,000 leaves exactly 2,000,000 → pass. + if err := EarlyRejectCheck(60_000_000, 58_000_000, 1000); err != nil { + t.Fatalf("expected pass at exact boundary, got %v", err) + } + // gasUsed=58,000,001 leaves 1,999,999 → fail. + if err := EarlyRejectCheck(60_000_000, 58_000_001, 1000); err == nil { + t.Fatal("expected error when gasRemaining < R_remaining * BalItemCost") + } + // Zero outstanding reads → trivially passes regardless of gas. + if err := EarlyRejectCheck(60_000_000, 60_000_000, 0); err != nil { + t.Fatalf("zero remaining declared reads must always pass, got %v", err) + } + // gasUsed > blockGasLimit is a caller bug — surface it. + if err := EarlyRejectCheck(100, 101, 0); err == nil { + t.Fatal("expected error when gasUsed exceeds blockGasLimit") + } +} + // TestBlockAccessListEmptyRoundTrip verifies that an empty BAL encodes to the // canonical empty RLP list (0xc0) and decodes back to a non-nil empty slice. // EIP-7928 requires: "When no state changes are present, this field is the