Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 62 additions & 16 deletions execution/stagedsync/exec3_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
85 changes: 85 additions & 0 deletions execution/types/block_access_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
115 changes: 115 additions & 0 deletions execution/types/block_access_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading