diff --git a/core/types/bal/bal.go b/core/types/bal/bal.go index 35b31194cf..752465c6e2 100644 --- a/core/types/bal/bal.go +++ b/core/types/bal/bal.go @@ -50,17 +50,30 @@ func newAccessListBuilder() *idxAccessListBuilder { } } -func (c *idxAccessListBuilder) storageRead(address common.Address, key common.Hash) { - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} +// currentScope returns the current (topmost) scope, lazily allocating if needed. +// This avoids allocating a map for scopes that never record any state changes, +// which is critical for precompile-heavy blocks where STATICCALL creates/destroys +// thousands of empty scopes per transaction. +func (c *idxAccessListBuilder) currentScope() map[common.Address]*constructionAccountAccess { + top := len(c.accessesStack) - 1 + if c.accessesStack[top] == nil { + c.accessesStack[top] = make(map[common.Address]*constructionAccountAccess) } - acctAccesses := c.accessesStack[len(c.accessesStack)-1][address] - acctAccesses.StorageRead(key) + return c.accessesStack[top] +} + +func (c *idxAccessListBuilder) storageRead(address common.Address, key common.Hash) { + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} + } + scope[address].StorageRead(key) } func (c *idxAccessListBuilder) accountRead(address common.Address) { - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} } } @@ -75,11 +88,11 @@ func (c *idxAccessListBuilder) storageWrite(address common.Address, key, prevVal c.prestates[address].storage[key] = prevVal } - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} } - acctAccesses := c.accessesStack[len(c.accessesStack)-1][address] - acctAccesses.StorageWrite(key, prevVal, newVal) + scope[address].StorageWrite(key, prevVal, newVal) } func (c *idxAccessListBuilder) balanceChange(address common.Address, prev, cur *uint256.Int) { @@ -89,11 +102,11 @@ func (c *idxAccessListBuilder) balanceChange(address common.Address, prev, cur * if c.prestates[address].balance == nil { c.prestates[address].balance = prev } - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} } - acctAccesses := c.accessesStack[len(c.accessesStack)-1][address] - acctAccesses.BalanceChange(cur) + scope[address].BalanceChange(cur) } func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []byte) { @@ -113,12 +126,11 @@ func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []by } c.prestates[address].code = prev } - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} } - acctAccesses := c.accessesStack[len(c.accessesStack)-1][address] - - acctAccesses.CodeChange(cur) + scope[address].CodeChange(cur) } // selfDestruct is invoked when an account which has been created and invoked @@ -127,7 +139,8 @@ func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []by // Any storage accesses/modifications performed at the contract during execution // of the current call are retained in the block access list as state reads. func (c *idxAccessListBuilder) selfDestruct(address common.Address) { - access := c.accessesStack[len(c.accessesStack)-1][address] + scope := c.currentScope() + access := scope[address] if len(access.storageMutations) != 0 && access.storageReads == nil { access.storageReads = make(map[common.Hash]struct{}) } @@ -144,16 +157,19 @@ func (c *idxAccessListBuilder) nonceChange(address common.Address, prev, cur uin if c.prestates[address].nonce == nil { c.prestates[address].nonce = &prev } - if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok { - c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{} + scope := c.currentScope() + if _, ok := scope[address]; !ok { + scope[address] = &constructionAccountAccess{} } - acctAccesses := c.accessesStack[len(c.accessesStack)-1][address] - acctAccesses.NonceChange(cur) + scope[address].NonceChange(cur) } // enterScope is called after a new EVM call frame has been entered. +// The scope map is lazily allocated by currentScope() only when a state +// change occurs, avoiding heap allocations for precompile calls that +// don't touch state. func (c *idxAccessListBuilder) enterScope() { - c.accessesStack = append(c.accessesStack, make(map[common.Address]*constructionAccountAccess)) + c.accessesStack = append(c.accessesStack, nil) } // exitScope is called after an EVM call scope terminates. If the call scope @@ -162,8 +178,14 @@ func (c *idxAccessListBuilder) enterScope() { // * mutated accounts/storage are added into the calling scope's access list as state accesses func (c *idxAccessListBuilder) exitScope(evmErr bool) { childAccessList := c.accessesStack[len(c.accessesStack)-1] - parentAccessList := c.accessesStack[len(c.accessesStack)-2] + c.accessesStack = c.accessesStack[:len(c.accessesStack)-1] + // If no state was accessed in this scope, nothing to merge. + if childAccessList == nil { + return + } + + parentAccessList := c.currentScope() for addr, childAccess := range childAccessList { if _, ok := parentAccessList[addr]; ok { } else { @@ -177,8 +199,6 @@ func (c *idxAccessListBuilder) exitScope(evmErr bool) { parentAccessList[addr].Merge(childAccess) } } - - c.accessesStack = c.accessesStack[:len(c.accessesStack)-1] } // finalise returns the net state mutations at the access list index as well as @@ -188,6 +208,11 @@ func (a *idxAccessListBuilder) finalise() (*StateDiff, StateAccesses) { diff := &StateDiff{make(map[common.Address]*AccountMutations)} stateAccesses := make(StateAccesses) + // Root scope may be nil if no state changes occurred at all. + if a.accessesStack[0] == nil { + return diff, stateAccesses + } + for addr, access := range a.accessesStack[0] { // remove any reported mutations from the access list with no net difference vs the index prestate value if access.nonce != nil && *a.prestates[addr].nonce == *access.nonce { diff --git a/core/types/bal/bal_test.go b/core/types/bal/bal_test.go index a0538c2b95..726ea48b58 100644 --- a/core/types/bal/bal_test.go +++ b/core/types/bal/bal_test.go @@ -19,6 +19,7 @@ package bal import ( "bytes" "cmp" + "fmt" "reflect" "slices" "testing" @@ -257,5 +258,117 @@ func TestBlockAccessListValidation(t *testing.T) { } } +// TestLazyScopeCorrectness verifies that lazy scope allocation produces +// identical results to the previous eager allocation for mixed workloads: +// precompile calls (empty scopes) interspersed with state-changing calls. +func TestLazyScopeCorrectness(t *testing.T) { + builder := newAccessListBuilder() + sender := common.HexToAddress("0x1234") + contract := common.HexToAddress("0x5678") + precompile := common.HexToAddress("0x06") + + // Tx-level: sender balance/nonce + builder.balanceChange(sender, uint256.NewInt(1000), uint256.NewInt(900)) + builder.nonceChange(sender, 0, 1) + + // Enter contract scope + builder.enterScope() + builder.storageRead(contract, common.HexToHash("0x01")) + + // Precompile STATICCALL (empty scope) + builder.enterScope() + builder.exitScope(false) + + // Another precompile call + builder.enterScope() + builder.exitScope(false) + + // Contract writes storage + builder.storageWrite(contract, common.HexToHash("0x02"), common.Hash{}, common.HexToHash("0xff")) + + // Precompile that reverts (still empty scope, reverted) + builder.enterScope() + builder.exitScope(true) + + // Nested call to another contract + builder.enterScope() + builder.balanceChange(precompile, uint256.NewInt(0), uint256.NewInt(100)) + builder.exitScope(false) + + // Exit contract scope + builder.exitScope(false) + + diff, accesses := builder.finalise() + + // Verify sender mutations + senderMut, ok := diff.Mutations[sender] + if !ok { + t.Fatal("sender not in mutations") + } + if senderMut.Balance == nil || !senderMut.Balance.Eq(uint256.NewInt(900)) { + t.Fatalf("sender balance mismatch: got %v", senderMut.Balance) + } + if senderMut.Nonce == nil || *senderMut.Nonce != 1 { + t.Fatalf("sender nonce mismatch: got %v", senderMut.Nonce) + } + + // Verify contract mutations (storage write) + contractMut, ok := diff.Mutations[contract] + if !ok { + t.Fatal("contract not in mutations") + } + if contractMut.StorageWrites == nil { + t.Fatal("contract has no storage writes") + } + if contractMut.StorageWrites[common.HexToHash("0x02")] != common.HexToHash("0xff") { + t.Fatal("contract storage write mismatch") + } + + // Verify precompile balance change + precompileMut, ok := diff.Mutations[precompile] + if !ok { + t.Fatal("precompile not in mutations") + } + if precompileMut.Balance == nil || !precompileMut.Balance.Eq(uint256.NewInt(100)) { + t.Fatalf("precompile balance mismatch: got %v", precompileMut.Balance) + } + + // Verify contract storage read is in accesses + contractAccesses, ok := accesses[contract] + if !ok { + t.Fatal("contract not in accesses") + } + if _, ok := contractAccesses[common.HexToHash("0x01")]; !ok { + t.Fatal("contract storage read not in accesses") + } +} + +// BenchmarkPrecompileScopes simulates a precompile-heavy transaction where +// STATICCALL is invoked thousands of times against a precompile (e.g. bn128_add). +// Each call creates a scope (EnterScope) that records no state changes (precompiles +// don't touch state), then exits (ExitScope). This benchmark measures the overhead +// of scope tracking for such workloads. +func BenchmarkPrecompileScopes(b *testing.B) { + for _, numCalls := range []int{100, 1000, 10000, 100000} { + b.Run(fmt.Sprintf("calls=%d", numCalls), func(b *testing.B) { + for i := 0; i < b.N; i++ { + builder := newAccessListBuilder() + // Simulate a transaction: sender balance/nonce change at depth 0, + // then thousands of precompile STATICCALL scopes that touch no state. + sender := common.HexToAddress("0x1234") + builder.balanceChange(sender, uint256.NewInt(1000), uint256.NewInt(900)) + builder.nonceChange(sender, 0, 1) + + for j := 0; j < numCalls; j++ { + builder.enterScope() + // Precompile call: no state hooks fire + builder.exitScope(false) + } + builder.finalise() + } + }) + } +} + // BALReader test ideas // * BAL which doesn't have any pre-tx system contracts should return an empty state diff at idx 0