diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 1963315834d..500d674e92a 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2072,6 +2072,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 @@ -2122,24 +2128,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 @@ -2575,6 +2604,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