From 485e7fda541a3b853e479bcee449c0da7aa67122 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 16:15:49 +0200 Subject: [PATCH 01/16] core/vm: put account in BAL before transfer Might not actually be needed if spec is changed, see https://github.com/ethereum/EIPs/pull/11699 --- core/vm/evm.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/vm/evm.go b/core/vm/evm.go index 50d9e8ab0c..e66dace3e0 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -261,6 +261,12 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } syscall := isSystemCall(caller) + // EIP-7928: per the Amsterdam spec, delegation resolution happens before + // the value-transfer check, so the delegated-to must appear in the BAL + // even when the call later reverts with ErrInsufficientBalance. Touch the + // target's code here (a no-op for non-delegated accounts) to record it. + evm.resolveCode(addr) + // Fail if we're trying to transfer more than the available balance. if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance @@ -356,6 +362,12 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } + + // EIP-7928: per the Amsterdam spec, delegation resolution happens before + // the value-transfer check, so the delegated-to must appear in the BAL + // even when the call later reverts with ErrInsufficientBalance. + evm.resolveCode(addr) + // Fail if we're trying to transfer more than the available balance if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance From 3a0a2c6c944f6ae7ccf63ab4459693ae6d0e3c4a Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 16:16:13 +0200 Subject: [PATCH 02/16] tests: add BAL specific tests --- build/checksums.txt | 5 +++++ build/ci.go | 18 ++++++++++++++++++ tests/block_test.go | 15 ++++++++++++--- tests/init_test.go | 7 ++++--- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/build/checksums.txt b/build/checksums.txt index 454efa93c4..d6a9f2b803 100644 --- a/build/checksums.txt +++ b/build/checksums.txt @@ -5,6 +5,11 @@ # https://github.com/ethereum/execution-spec-tests/releases/download/v5.1.0 a3192784375acec7eaec492799d5c5d0c47a2909a3cc40178898e4ecd20cc416 fixtures_develop.tar.gz +# version:spec-tests-bal v7.2.0 +# https://github.com/ethereum/execution-specs/releases +# https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0 +fc1d9ae174cdd5db789068839999e6f83666cc79f7dac36e973d7616d9a2e2cf fixtures_bal.tar.gz + # version:golang 1.25.10 # https://go.dev/dl/ 20cf04a92e5af99748e341bc8996fa28090c9ac98765fa115ec5ddf41d7af41d go1.25.10.src.tar.gz diff --git a/build/ci.go b/build/ci.go index 53ade2e1bf..2db6c22616 100644 --- a/build/ci.go +++ b/build/ci.go @@ -160,6 +160,9 @@ var ( // This is where the tests should be unpacked. executionSpecTestsDir = "tests/spec-tests" + + // This is where the bal-specific release of the tests should be unpacked. + executionSpecTestsBALDir = "tests/spec-tests-bal" ) var GOBIN, _ = filepath.Abs(filepath.Join("build", "bin")) @@ -398,6 +401,7 @@ func doTest(cmdline []string) { // Get test fixtures. if !*short { downloadSpecTestFixtures(csdb, *cachedir) + downloadBALSpecTestFixtures(csdb, *cachedir) } // Configure the toolchain. @@ -463,6 +467,20 @@ func downloadSpecTestFixtures(csdb *download.ChecksumDB, cachedir string) string return filepath.Join(cachedir, base) } +// downloadBALSpecTestFixtures downloads and extracts the bal-specific execution-spec-tests fixtures. +func downloadBALSpecTestFixtures(csdb *download.ChecksumDB, cachedir string) string { + ext := ".tar.gz" + base := "fixtures_bal" + archivePath := filepath.Join(cachedir, base+ext) + if err := csdb.DownloadFileFromKnownURL(archivePath); err != nil { + log.Fatal(err) + } + if err := build.ExtractArchive(archivePath, executionSpecTestsBALDir); err != nil { + log.Fatal(err) + } + return filepath.Join(cachedir, base) +} + // doCheckGenerate ensures that re-generating generated files does not cause // any mutations in the source file tree. func doCheckGenerate() { diff --git a/tests/block_test.go b/tests/block_test.go index 0f087967bb..31bb4f9d36 100644 --- a/tests/block_test.go +++ b/tests/block_test.go @@ -82,8 +82,17 @@ func TestBlockchain(t *testing.T) { // TestExecutionSpecBlocktests runs the test fixtures from execution-spec-tests. func TestExecutionSpecBlocktests(t *testing.T) { - if !common.FileExist(executionSpecBlockchainTestDir) { - t.Skipf("directory %s does not exist", executionSpecBlockchainTestDir) + testExecutionSpecBlocktests(t, executionSpecBlockchainTestDir) +} + +// TestExecutionSpecBlocktestsBAL runs the BAL release test fixtures from execution-spec-tests. +func TestExecutionSpecBlocktestsBAL(t *testing.T) { + testExecutionSpecBlocktests(t, executionSpecBALBlockchainTestDir) +} + +func testExecutionSpecBlocktests(t *testing.T, testDir string) { + if !common.FileExist(testDir) { + t.Skipf("directory %s does not exist", testDir) } bt := new(testMatcher) @@ -97,7 +106,7 @@ func TestExecutionSpecBlocktests(t *testing.T) { bt.skipLoad(`dynamicAccountOverwriteEmpty_Paris`) bt.skipLoad(`create2collisionStorageParis`) - bt.walk(t, executionSpecBlockchainTestDir, func(t *testing.T, name string, test *BlockTest) { + bt.walk(t, testDir, func(t *testing.T, name string, test *BlockTest) { execBlockTest(t, bt, test) }) } diff --git a/tests/init_test.go b/tests/init_test.go index b933c9808c..20c9e8e1c9 100644 --- a/tests/init_test.go +++ b/tests/init_test.go @@ -41,9 +41,10 @@ var ( transactionTestDir = filepath.Join(baseDir, "TransactionTests") rlpTestDir = filepath.Join(baseDir, "RLPTests") difficultyTestDir = filepath.Join(baseDir, "BasicTests") - executionSpecBlockchainTestDir = filepath.Join(".", "spec-tests", "fixtures", "blockchain_tests") - executionSpecStateTestDir = filepath.Join(".", "spec-tests", "fixtures", "state_tests") - executionSpecTransactionTestDir = filepath.Join(".", "spec-tests", "fixtures", "transaction_tests") + executionSpecBlockchainTestDir = filepath.Join(".", "spec-tests", "fixtures", "blockchain_tests") + executionSpecBALBlockchainTestDir = filepath.Join(".", "spec-tests-bal", "fixtures", "blockchain_tests") + executionSpecStateTestDir = filepath.Join(".", "spec-tests", "fixtures", "state_tests") + executionSpecTransactionTestDir = filepath.Join(".", "spec-tests", "fixtures", "transaction_tests") benchmarksDir = filepath.Join(".", "evm-benchmarks", "benchmarks") ) From d5ae30203c88151a666a903bc6dcd661cc783cc6 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 18:43:14 +0200 Subject: [PATCH 03/16] beacon/engine: fix marshalling --- beacon/engine/ed_codec.go | 7 +++---- beacon/engine/types.go | 20 +++++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/beacon/engine/ed_codec.go b/beacon/engine/ed_codec.go index 02a1fd3805..83603616a7 100644 --- a/beacon/engine/ed_codec.go +++ b/beacon/engine/ed_codec.go @@ -10,7 +10,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" ) var _ = (*executableDataMarshaling)(nil) @@ -36,7 +35,7 @@ func (e ExecutableData) MarshalJSON() ([]byte, error) { BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed"` ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas"` SlotNumber *hexutil.Uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } var enc ExecutableData enc.ParentHash = e.ParentHash @@ -87,7 +86,7 @@ func (e *ExecutableData) UnmarshalJSON(input []byte) error { BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed"` ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas"` SlotNumber *hexutil.Uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList *hexutil.Bytes `json:"blockAccessList,omitempty"` } var dec ExecutableData if err := json.Unmarshal(input, &dec); err != nil { @@ -165,7 +164,7 @@ func (e *ExecutableData) UnmarshalJSON(input []byte) error { e.SlotNumber = (*uint64)(dec.SlotNumber) } if dec.BlockAccessList != nil { - e.BlockAccessList = dec.BlockAccessList + e.BlockAccessList = *dec.BlockAccessList } return nil } diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 980e8f9771..58ecd1d780 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -17,6 +17,7 @@ package engine import ( + "bytes" "fmt" "math/big" "slices" @@ -24,8 +25,9 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" ) @@ -101,7 +103,7 @@ type ExecutableData struct { BlobGasUsed *uint64 `json:"blobGasUsed"` ExcessBlobGas *uint64 `json:"excessBlobGas"` SlotNumber *uint64 `json:"slotNumber,omitempty"` - BlockAccessList *bal.BlockAccessList `json:"blockAccessList,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } // JSON type overrides for executableData. @@ -314,13 +316,14 @@ func ExecutableDataToBlockNoHash(data ExecutableData, versionedHashes []common.H } // If Amsterdam is enabled, data.BlockAccessList is always non-nil, - // even for empty blocks with no state transitions. + // even for empty blocks with no state transitions. The wire format is + // the RLP-encoded access list; the header hash is keccak256(rlp). // // If Amsterdam is not enabled yet, blockAccessListHash is expected // to be nil. var blockAccessListHash *common.Hash if data.BlockAccessList != nil { - hash := data.BlockAccessList.Hash() + hash := crypto.Keccak256Hash(data.BlockAccessList) blockAccessListHash = &hash } header := &types.Header{ @@ -372,7 +375,14 @@ func BlockToExecutableData(block *types.Block, fees *big.Int, sidecars []*types. BlobGasUsed: block.BlobGasUsed(), ExcessBlobGas: block.ExcessBlobGas(), SlotNumber: block.SlotNumber(), - BlockAccessList: block.AccessList(), + } + // Per Engine API spec (Amsterdam): blockAccessList is the RLP-encoded + // access list, serialized as a hex string. Encode it to bytes here. + if al := block.AccessList(); al != nil { + var buf bytes.Buffer + if err := rlp.Encode(&buf, al); err == nil { + data.BlockAccessList = buf.Bytes() + } } // Add blobs. From 469be4e266f322af4652e5d16b8b5aacade2af54 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Thu, 21 May 2026 20:57:34 +0200 Subject: [PATCH 04/16] cmd: add --bal.executionmode flag (still noop) --- cmd/geth/main.go | 1 + cmd/utils/flags.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 5e90164aaa..a14e78172c 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -91,6 +91,7 @@ var ( utils.BinTrieGroupDepthFlag, utils.LightKDFFlag, utils.EthRequiredBlocksFlag, + utils.BALExecutionModeFlag, utils.CacheFlag, utils.CacheDatabaseFlag, utils.CacheTrieFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index a248d4fa8a..d941141db0 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -243,6 +243,11 @@ var ( Usage: "Comma separated block number-to-hash mappings to require for peering (=)", Category: flags.EthCategory, } + BALExecutionModeFlag = &cli.StringFlag{ + Name: "bal.executionmode", + Usage: "EIP-7928 block-access-list execution mode (no-op placeholder)", + Category: flags.EthCategory, + } BloomFilterSizeFlag = &cli.Uint64Flag{ Name: "bloomfilter.size", Usage: "Megabytes of memory allocated to bloom-filter for pruning", From 92d4446da418563fb3d28e911a2517fad8aa0ba2 Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 1 Jun 2026 18:41:58 -0400 Subject: [PATCH 05/16] core: implement optimized bal execution path --- cmd/evm/blockrunner.go | 2 +- cmd/geth/config.go | 23 ++ cmd/geth/main.go | 2 + cmd/utils/flags.go | 18 + core/block_validator.go | 13 +- core/blockchain.go | 150 +++++++- core/parallel_state_processor.go | 329 ++++++++++++++++++ core/state/bal_state_transition.go | 530 +++++++++++++++++++++++++++++ core/state/database.go | 10 + core/state/database_history.go | 4 + core/state/database_mpt.go | 19 ++ core/state/database_ubt.go | 4 + core/state/reader.go | 9 + core/state/reader_eip_7928.go | 136 +++++--- core/state/statedb.go | 82 ++++- core/types.go | 4 +- core/types/bal/bal.go | 135 ++++++++ core/types/bal/bal_encoding.go | 8 +- core/types/bal/bal_reader.go | 114 +++++++ eth/backend.go | 4 + eth/ethconfig/config.go | 5 + tests/block_test.go | 2 +- tests/block_test_util.go | 80 ++++- trie/bintrie/trie.go | 8 + trie/secure_trie.go | 46 +++ trie/tracer.go | 19 +- trie/transitiontrie/transition.go | 29 ++ trie/trie.go | 63 ++++ trie/trie_test.go | 54 +++ 29 files changed, 1818 insertions(+), 84 deletions(-) create mode 100644 core/parallel_state_processor.go create mode 100644 core/state/bal_state_transition.go create mode 100644 core/types/bal/bal_reader.go diff --git a/cmd/evm/blockrunner.go b/cmd/evm/blockrunner.go index c6fac5396e..070bdf4c55 100644 --- a/cmd/evm/blockrunner.go +++ b/cmd/evm/blockrunner.go @@ -117,7 +117,7 @@ func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) { test := tests[name] result := &testResult{Name: name, Pass: true} var finalRoot *common.Hash - if err := test.Run(false, rawdb.PathScheme, ctx.Bool(WitnessCrossCheckFlag.Name), tracer, func(res error, chain *core.BlockChain) { + if err := test.Run(false, rawdb.PathScheme, ctx.Bool(WitnessCrossCheckFlag.Name), true, tracer, func(res error, chain *core.BlockChain) { if ctx.Bool(DumpFlag.Name) { if s, _ := chain.State(); s != nil { result.State = dump(s) diff --git a/cmd/geth/config.go b/cmd/geth/config.go index ab9a336349..d40ee08587 100644 --- a/cmd/geth/config.go +++ b/cmd/geth/config.go @@ -20,6 +20,7 @@ import ( "bufio" "errors" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "os" "reflect" "runtime" @@ -241,6 +242,28 @@ func makeFullNode(ctx *cli.Context) *node.Node { cfg.Eth.OverrideUBT = &v } + if ctx.IsSet(utils.BALExecutionModeFlag.Name) { + val := ctx.String(utils.BALExecutionModeFlag.Name) + switch val { + case utils.BalExecutionModeOptimized: + cfg.Eth.BALExecutionMode = bal.BALExecutionOptimized + case utils.BalExecutionModeNoBatchIO: + cfg.Eth.BALExecutionMode = bal.BALExecutionNoBatchIO + case utils.BalExecutionModeSequential: + cfg.Eth.BALExecutionMode = bal.BALExecutionSequential + default: + utils.Fatalf("invalid option for --bal.executionmode: %s. acceptable values are full|nobatchio|sequential", val) + } + } + cfg.Eth.BlockingPrefetch = ctx.Bool(utils.BlockingPrefetchFlag.Name) + + prefetchWorkers := ctx.Uint(utils.PrefetchWorkersFlag.Name) + if ctx.IsSet(utils.PrefetchWorkersFlag.Name) && prefetchWorkers == 0 { + prefetchWorkers = uint(runtime.NumCPU()) + log.Warn(fmt.Sprintf("invalid value for --bal.prefetchworkers. got 0. sanitizing to %d", prefetchWorkers)) + } + cfg.Eth.PrefetchWorkers = prefetchWorkers + // Start metrics export if enabled. utils.SetupMetrics(&cfg.Metrics) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index a14e78172c..80a65e5fe3 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -92,6 +92,8 @@ var ( utils.LightKDFFlag, utils.EthRequiredBlocksFlag, utils.BALExecutionModeFlag, + utils.PrefetchWorkersFlag, + utils.BlockingPrefetchFlag, utils.CacheFlag, utils.CacheDatabaseFlag, utils.CacheTrieFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index d941141db0..8e9842d62c 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -28,6 +28,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" godebug "runtime/debug" "strconv" "strings" @@ -248,6 +249,17 @@ var ( Usage: "EIP-7928 block-access-list execution mode (no-op placeholder)", Category: flags.EthCategory, } + PrefetchWorkersFlag = &cli.UintFlag{ + Name: "bal.prefetchworkers", + Usage: "The number of concurrent state loading tasks to perform when prefetching BAL state. Default to the number of cpus", + Value: uint(runtime.NumCPU()), + Category: flags.MiscCategory, + } + BlockingPrefetchFlag = &cli.BoolFlag{ + Name: "bal.blockingprefetch", + Usage: "only relevant when executing in parallel with a BAL: if true, the prefetcher will block tx/state-root calculation until all scheduled fetching tasks have completed.", + Category: flags.MiscCategory, + } BloomFilterSizeFlag = &cli.Uint64Flag{ Name: "bloomfilter.size", Usage: "Megabytes of memory allocated to bloom-filter for pruning", @@ -1125,6 +1137,12 @@ Please note that --` + MetricsHTTPFlag.Name + ` must be set to start the server. } ) +const ( + BalExecutionModeOptimized = "full" + BalExecutionModeNoBatchIO = "nobatchio" + BalExecutionModeSequential = "sequential" +) + var ( // TestnetFlags is the flag group of all built-in supported testnets. TestnetFlags = []cli.Flag{ diff --git a/core/block_validator.go b/core/block_validator.go index 962fffb82a..f2df77a27d 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -19,9 +19,9 @@ package core import ( "errors" "fmt" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus" - "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/trie" @@ -143,9 +143,14 @@ func (v *BlockValidator) ValidateBody(block *types.Block) error { return nil } +type StateRootSource interface { + IntermediateRoot(deleteEmptyObjects bool) common.Hash + Error() error +} + // ValidateState validates the various changes that happen after a state transition, // such as amount of used gas, the receipt roots and the state root itself. -func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateDB, res *ProcessResult, stateless bool) error { +func (v *BlockValidator) ValidateState(block *types.Block, state StateRootSource, res *ProcessResult, stateless bool) error { if res == nil { return errors.New("nil ProcessResult value") } @@ -201,8 +206,8 @@ func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateD } // Validate the state root against the received state root and throw // an error if they don't match. - if root := statedb.IntermediateRoot(v.config.IsEIP158(header.Number)); header.Root != root { - return fmt.Errorf("invalid merkle root (remote: %x local: %x) dberr: %w", header.Root, root, statedb.Error()) + if root := state.IntermediateRoot(v.config.IsEIP158(header.Number)); header.Root != root { + return fmt.Errorf("invalid merkle root (remote: %x local: %x) dberr: %w", header.Root, root, state.Error()) } return nil } diff --git a/core/blockchain.go b/core/blockchain.go index acf2da1921..b8acea6f4a 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "io" "math/big" "runtime" @@ -225,6 +226,10 @@ type BlockChainConfig struct { // Execution configs StatelessSelfValidation bool // Generate execution witnesses and self-check against them (testing purpose) EnableWitnessStats bool // Whether trie access statistics collection is enabled + + BALExecutionMode bal.BALExecutionMode + BlockingPrefetch bool + PrefetchWorkers int } // DefaultConfig returns the default config. @@ -365,12 +370,13 @@ type BlockChain struct { stopping atomic.Bool // false if chain is running, true when stopped procInterrupt atomic.Bool // interrupt signaler for block processing - engine consensus.Engine - validator Validator // Block and state validator interface - prefetcher Prefetcher - processor Processor // Block transaction processor interface - logger *tracing.Hooks - stateSizer *state.SizeTracker // State size tracking + engine consensus.Engine + validator Validator // Block and state validator interface + prefetcher Prefetcher + processor Processor // Block transaction processor interface + parallelProcessor ParallelStateProcessor // block processor for use with access lists + logger *tracing.Hooks + stateSizer *state.SizeTracker // State size tracking lastForkReadyAlert time.Time // Last time there was a fork readiness print out slowBlockThreshold time.Duration // Block execution time threshold beyond which detailed statistics will be logged @@ -433,6 +439,7 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, bc.validator = NewBlockValidator(chainConfig, bc) bc.prefetcher = newStatePrefetcher(chainConfig, bc.hc) bc.processor = NewStateProcessor(bc.hc) + bc.parallelProcessor = NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) genesisHeader := bc.GetHeaderByNumber(0) if genesisHeader == nil { @@ -1657,7 +1664,7 @@ func (bc *BlockChain) writeKnownBlock(block *types.Block) error { // writeBlockWithState writes block, metadata and corresponding state data to the // database. -func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, statedb *state.StateDB) error { +func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.Receipt, statedb state.Committer) error { if !bc.HasHeader(block.ParentHash(), block.NumberU64()-1) { return consensus.ErrUnknownAncestor } @@ -1771,7 +1778,7 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. // writeBlockAndSetHead is the internal implementation of WriteBlockAndSetHead. // This function expects the chain mutex to be held. -func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state *state.StateDB, emitHeadEvent bool) (status WriteStatus, err error) { +func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types.Receipt, logs []*types.Log, state state.Committer, emitHeadEvent bool) (status WriteStatus, err error) { if err := bc.writeBlockWithState(block, receipts, state); err != nil { return NonStatTy, err } @@ -2126,16 +2133,133 @@ type ExecuteConfig struct { EnableWitnessStats bool } +func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block *types.Block, setHead bool) (procRes *blockProcessingResult, blockEndErr error) { + var ( + startTime = time.Now() + procTime time.Duration + statedb *state.StateDB + ) + + sdb := state.NewMPTDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + + useAsyncReads := bc.cfg.BALExecutionMode != bal.BALExecutionNoBatchIO + al := block.AccessList() + accessListReader := bal.NewAccessListReader(*al) + prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, accessListReader.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) + if err != nil { + return nil, err + } + + stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot) + if err != nil { + return nil, err + } + statedb, err = state.NewWithReader(parentRoot, sdb, prefetchReader) + if err != nil { + return nil, err + } + + if bc.logger != nil && bc.logger.OnBlockStart != nil { + bc.logger.OnBlockStart(tracing.BlockEvent{ + Block: block, + Finalized: bc.CurrentFinalBlock(), + Safe: bc.CurrentSafeBlock(), + }) + } + if bc.logger != nil && bc.logger.OnBlockEnd != nil { + defer func() { + bc.logger.OnBlockEnd(blockEndErr) + }() + } + + res, err := bc.parallelProcessor.Process(block, stateTransition, statedb, bc.cfg.VmConfig) + if err != nil { + return nil, err + } + + if err := bc.validator.ValidateState(block, stateTransition, res.ProcessResult, false); err != nil { + return nil, err + } + + procTime = time.Since(startTime) + writeStart := time.Now() + // Write the block to the chain and get the status. + var ( + //wstart = time.Now() + status WriteStatus + ) + if !setHead { + // Don't set the head, only insert the block + err = bc.writeBlockWithState(block, res.ProcessResult.Receipts, stateTransition) + } else { + status, err = bc.writeBlockAndSetHead(block, res.ProcessResult.Receipts, res.ProcessResult.Logs, stateTransition, false) + } + if err != nil { + return nil, err + } + writeTime := time.Since(writeStart) + var stats ExecuteStats + + wc := stateTransition.WrittenCounts() + d := stateTransition.Deletions() + //codeLoaded, codeLoadBytes := prefetchReader.(state.CodeLoadTracker).CodeLoads() + //stats.AccountLoaded = al.UniqueAccountCount() + stats.AccountUpdated = wc.Accounts - d.Accounts + stats.AccountDeleted = d.Accounts + //stats.StorageLoaded = al.UniqueStorageSlotCount() + stats.StorageUpdated = wc.StorageSlots - d.Storage + stats.StorageDeleted = d.Storage + //stats.CodeLoaded = codeLoaded + //stats.CodeLoadBytes = codeLoadBytes + stats.CodeUpdated = wc.Codes + stats.CodeUpdateBytes = wc.CodeBytes + + //stats.ExecWall = res.ExecTime + //stats.PostProcess = res.PostProcessTime + + if m := res.StateTransitionMetrics; m != nil { + stats.AccountHashes = m.AccountUpdate + m.StateUpdate + m.StateHash + stats.AccountCommits = m.AccountCommits + stats.StorageCommits = m.StorageCommits + stats.DatabaseCommit = m.TrieDBCommits + //stats.Prefetch = m.StatePrefetch + } + //stats.Prefetch = prefetchReader.(state.PrefetcherMetricer).Metrics().Elapsed + + stats.StateReadCacheStats = prefetchReader.(state.ReaderStater).GetStats() + + elapsed := time.Since(startTime) + 1 // prevent zero division + stats.TotalTime = elapsed + stats.MgasPerSecond = float64(res.ProcessResult.GasUsed) * 1000 / float64(elapsed) + stats.BlockWrite = writeTime + + // TODO: reinstate + //stats.balTransitionStats = res.StateTransitionMetrics + + return &blockProcessingResult{ + usedGas: res.ProcessResult.GasUsed, + procTime: procTime, + status: status, + witness: nil, + stats: &stats, + }, nil +} + // ProcessBlock executes and validates the given block. If there was no error // it writes the block and associated state to database. func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, block *types.Block, config ExecuteConfig) (result *blockProcessingResult, blockEndErr error) { var ( - err error - startTime = time.Now() - statedb *state.StateDB - interrupt atomic.Bool - sdb state.Database + err error + startTime = time.Now() + statedb *state.StateDB + interrupt atomic.Bool + sdb state.Database + blockHasAccessList = block.AccessList() != nil ) + + if blockHasAccessList && bc.cfg.BALExecutionMode != bal.BALExecutionSequential { + return bc.processBlockWithAccessList(parentRoot, block, config.WriteHead) + } defer interrupt.Store(true) // terminate the prefetch at the end if bc.chainConfig.IsUBT(block.Number(), block.Time()) { diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go new file mode 100644 index 0000000000..3407aabad7 --- /dev/null +++ b/core/parallel_state_processor.go @@ -0,0 +1,329 @@ +package core + +import ( + "cmp" + "context" + "fmt" + "runtime" + "slices" + "time" + + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/core/vm" + "golang.org/x/sync/errgroup" +) + +// ProcessResultWithMetrics wraps ProcessResult with timing breakdown for BAL block processing. +type ProcessResultWithMetrics struct { + ProcessResult *ProcessResult + PreProcessTime time.Duration + StateTransitionMetrics *state.BALStateTransitionMetrics + ExecTime time.Duration + PostProcessTime time.Duration +} + +// ParallelStateProcessor is used to execute and verify blocks containing +// access lists. +type ParallelStateProcessor struct { + *StateProcessor + vmCfg *vm.Config +} + +// NewParallelStateProcessor returns a new ParallelStateProcessor instance. +func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) ParallelStateProcessor { + res := NewStateProcessor(chain) + return ParallelStateProcessor{ + res, + vmConfig, + } +} + +// called by resultHandler when all transactions have successfully executed. +// performs post-tx state transition (system contracts and withdrawals) +// and calculates the ProcessResult, returning it to be sent on resCh +// by resultHandler +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { + tExec := time.Since(tExecStart) + tPostprocessStart := time.Now() + header := block.Header() + + vmContext := NewEVMBlockContext(header, p.chain, nil) + lastBALIdx := len(block.Transactions()) + 1 + postTxState := statedb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), lastBALIdx)) + + cfg := vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), + } + evm := vm.NewEVM(vmContext, postTxState, p.chainConfig(), cfg) + + // 1. order the receipts by tx index + // 2. correctly calculate the cumulative gas used per receipt, returning bad block error if it goes over the allowed + slices.SortFunc(results, func(a, b txExecResult) int { + return cmp.Compare(a.receipt.TransactionIndex, b.receipt.TransactionIndex) + }) + + var ( + // Per-dimension cumulative sums for 2D block gas (EIP-8037). + sumRegular uint64 + sumState uint64 + cumulativeReceipt uint64 // cumulative receipt gas (what users pay) + ) + + var allLogs []*types.Log + var allReceipts []*types.Receipt + for _, result := range results { + sumRegular += result.txRegular + sumState += result.txState + + cumulativeReceipt += result.execGas + result.receipt.CumulativeGasUsed = cumulativeReceipt + allLogs = append(allLogs, result.receipt.Logs...) + allReceipts = append(allReceipts, result.receipt) + } + // Block gas = max(sum_regular, sum_state) per EIP-8037. + blockGasUsed := max(sumRegular, sumState) + if blockGasUsed > header.GasLimit { + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: fmt.Errorf("gas limit exceeded")}, + } + } + + requests, postBal, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(len(block.Transactions())+1)) + if err != nil { + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: err}, + } + } + + p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(len(block.Transactions()))+1, postBal) + + blockAccessList := bal.NewConstructionBlockAccessList() + blockAccessList.Merge(preTxBal) + blockAccessList.Merge(postBal) + + for _, res := range results { + blockAccessList.Merge(res.blockAccessList) + } + + // TODO: do we move validation to ValidateState? + if block.AccessList().Hash() != blockAccessList.ToEncodingObj().Hash() { + // TODO: expose json string method on encoding block access list and log it here + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{Error: fmt.Errorf("invalid block access list: mismatch between local and remote block access list")}, + } + } + + tPostprocess := time.Since(tPostprocessStart) + + return &ProcessResultWithMetrics{ + ProcessResult: &ProcessResult{ + Receipts: allReceipts, + Requests: requests, + Logs: allLogs, + GasUsed: blockGasUsed, + Bal: blockAccessList, + }, + PostProcessTime: tPostprocess, + ExecTime: tExec, + } +} + +type txExecResult struct { + idx int // transaction index + receipt *types.Receipt + err error // non-EVM error which would render the block invalid + blockGas uint64 + execGas uint64 + + // Per-tx dimensional gas for Amsterdam 2D gas accounting (EIP-8037). + txRegular uint64 + txState uint64 + + blockAccessList *bal.ConstructionBlockAccessList +} + +// resultHandler polls until all transactions have finished executing and the +// state root calculation is complete. The result is emitted on resCh. +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { + // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd + // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) + var results []txExecResult + var cumulativeStateGas, cumulativeRegularGas uint64 + var execErr error + var numTxComplete int + + if len(block.Transactions()) > 0 { + loop: + for { + select { + case res := <-txResCh: + numTxComplete++ + if execErr == nil { + // short-circuit if invalid block was detected + if res.err != nil { + execErr = res.err + } else if bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState); bottleneck > block.GasLimit() { + execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) + } else { + cumulativeStateGas += res.txState + results = append(results, res) + } + } + if numTxComplete == len(block.Transactions()) { + break loop + } + } + } + + if execErr != nil { + // Drain stateRootCalcResCh so calcAndVerifyRoot goroutine can exit. + <-stateRootCalcResCh + resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: execErr}} + return + } + } + + execResults := p.prepareExecResult(block, tExecStart, preTxBAL, statedb, results) + rootCalcRes := <-stateRootCalcResCh + + if execResults.ProcessResult.Error != nil { + resCh <- execResults + } else if rootCalcRes.err != nil { + resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: rootCalcRes.err}} + } else { + execResults.StateTransitionMetrics = rootCalcRes.metrics + resCh <- execResults + } +} + +type stateRootCalculationResult struct { + err error + metrics *state.BALStateTransitionMetrics +} + +// calcAndVerifyRoot performs the post-state root hash calculation, verifying +// it against what is reported by the block and returning a result on resCh. +func (p *ParallelStateProcessor) calcAndVerifyRoot(block *types.Block, stateTransition *state.BALStateTransition, resCh chan stateRootCalculationResult) { + root := stateTransition.IntermediateRoot(false) + + res := stateRootCalculationResult{ + metrics: stateTransition.Metrics(), + } + + if root != block.Root() { + res.err = fmt.Errorf("state root mismatch. local: %x. remote: %x", root, block.Root()) + } + resCh <- res +} + +// execTx executes single transaction returning a result which includes state accessed/modified +func (p *ParallelStateProcessor) execTx(block *types.Block, tx *types.Transaction, balIdx int, db *state.StateDB, signer types.Signer) *txExecResult { + header := block.Header() + context := NewEVMBlockContext(header, p.chain, nil) + + cfg := vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), + } + evm := vm.NewEVM(context, db, p.chainConfig(), cfg) + + msg, err := TransactionToMessage(tx, signer, header.BaseFee) + if err != nil { + err = fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) + return &txExecResult{err: err} + } + gp := NewGasPool(block.GasLimit()) + sender, err := signer.Sender(tx) + if err != nil { + // TODO: can this even happen at this stage? + err = fmt.Errorf("could not recover sender for tx at bal idx %d: %v\n", balIdx, err) + } + // TODO: make precompiled addresses be resolvable from chain config + block + db.Prepare(evm.GetRules(), sender, block.Coinbase(), tx.To(), vm.PrecompiledAddressesCancun, tx.AccessList()) + + db.SetTxContext(tx.Hash(), balIdx-1, uint32(balIdx)) + + receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), context.Time, tx, evm) + if err != nil { + err := fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) + return &txExecResult{err: err} + } + + return &txExecResult{ + idx: balIdx, + receipt: receipt, + execGas: receipt.GasUsed, + blockGas: gp.Used(), + txRegular: gp.cumulativeRegular, + txState: gp.cumulativeState, + blockAccessList: txBAL, + } +} + +func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) (*bal.ConstructionBlockAccessList, error) { + var ( + header = block.Header() + ) + vmContext := NewEVMBlockContext(header, p.chain, nil) + evm := vm.NewEVM(vmContext, statedb, p.chainConfig(), cfg) + + accessList := PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) + return accessList, nil +} + +// Process performs EVM execution and state root computation for a block which is known +// to contain an access list. +func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *state.BALStateTransition, statedb *state.StateDB, cfg vm.Config) (*ProcessResultWithMetrics, error) { + var ( + header = block.Header() + resCh = make(chan *ProcessResultWithMetrics) + signer = types.MakeSigner(p.chainConfig(), header.Number, header.Time) + rootCalcResultCh = make(chan stateRootCalculationResult) + txResCh = make(chan txExecResult) + + pStart = time.Now() + tExecStart time.Time + tPreprocess time.Duration // time to create a set of prestates for parallel transaction execution + ) + + startingState := statedb.Copy() + preTxBal, err := p.processBlockPreTx(block, statedb, cfg) + if err != nil { + return nil, err + } + + // compute the reads/mutations at the last bal index + tPreprocess = time.Since(pStart) + + // execute transactions and state root calculation in parallel + tExecStart = time.Now() + go p.resultHandler(block, preTxBal, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + var workers errgroup.Group + workers.SetLimit(runtime.NumCPU()) + for i, t := range block.Transactions() { + tx := t + idx := i + sdb := startingState.Copy() + workers.Go(func() error { + startingState := sdb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), idx+1)) + res := p.execTx(block, tx, idx+1, startingState, signer) + txResCh <- *res + return nil + }) + } + + go p.calcAndVerifyRoot(block, stateTransition, rootCalcResultCh) + + res := <-resCh + if res.ProcessResult.Error != nil { + return nil, res.ProcessResult.Error + } + // TODO: remove preprocess metric ? + res.PreProcessTime = tPreprocess + return res, nil +} diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go new file mode 100644 index 0000000000..de643ab0d1 --- /dev/null +++ b/core/state/bal_state_transition.go @@ -0,0 +1,530 @@ +package state + +import ( + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/trie/trienode" + "github.com/holiman/uint256" + "golang.org/x/sync/errgroup" +) + +// BALStateTransition is responsible for performing the state root update +// and commit for EIP 7928 access-list-containing blocks. An instance of +// this object is only used for a single block. +type BALStateTransition struct { + accessList bal.AccessListReader + written bal.WrittenCounts + db Database + reader Reader + stateTrie Trie + parentRoot common.Hash + + // the computed state root of the block + rootHash common.Hash + // the state modifications performed by the block + diffs bal.StateMutations + + // a map of common.Address -> *types.StateAccount containing the block + // prestate of all accounts that will be modified + prestates sync.Map + + postStates map[common.Address]*types.StateAccount + // a map of common.Address -> Trie containing the account tries for all + // accounts with mutated storage + tries sync.Map //map[common.Address]Trie + deletions map[common.Address]struct{} + + // Deletion counters; not derivable from the BAL alone (selfdestruct vs + // balance drain is indistinguishable without prestate). + accountDeleted int + storageDeleted atomic.Int64 + + stateUpdate *StateUpdate + + metrics BALStateTransitionMetrics + maxBALIdx int + + err error +} + +func (s *BALStateTransition) Metrics() *BALStateTransitionMetrics { + return &s.metrics +} + +// DeletionCounts holds per-block deletion counters for accounts/storage +type DeletionCounts struct { + Accounts int + Storage int +} + +func (s *BALStateTransition) Deletions() DeletionCounts { + return DeletionCounts{ + Accounts: s.accountDeleted, + Storage: int(s.storageDeleted.Load()), + } +} + +type BALStateTransitionMetrics struct { + // trie hashing metrics + AccountUpdate time.Duration + StatePrefetch time.Duration + StateUpdate time.Duration + StateHash time.Duration + + // commit metrics + AccountCommits time.Duration + StorageCommits time.Duration + SnapshotCommits time.Duration + TrieDBCommits time.Duration + TotalCommitTime time.Duration +} + +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash) (*BALStateTransition, error) { + stateTrie, err := db.OpenTrie(parentRoot) + if err != nil { + return nil, err + } + + return &BALStateTransition{ + accessList: bal.NewAccessListReader(*block.AccessList()), + written: block.AccessList().WrittenCounts(), + db: db, + reader: prefetchReader, + stateTrie: stateTrie, + parentRoot: parentRoot, + rootHash: common.Hash{}, + diffs: make(bal.StateMutations), + prestates: sync.Map{}, + postStates: make(map[common.Address]*types.StateAccount), + tries: sync.Map{}, + deletions: make(map[common.Address]struct{}), + stateUpdate: nil, + maxBALIdx: len(block.Transactions()) + 1, + }, nil +} + +// WrittenCounts returns the cached BAL write counts (computed once per block). +func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { + return s.written +} + +func (s *BALStateTransition) Error() error { + return s.err +} + +func (s *BALStateTransition) setError(err error) { + if s.err == nil { + s.err = err + } +} + +// isAccountDeleted checks whether the state account was deleted in this block. Post selfdestruct-removal, +// deletions can only occur if an account which has a balance becomes the target of a CREATE2 initcode +// which calls SENDALL, clearing the account and marking it for deletion. +func isAccountDeleted(prestate *types.StateAccount, mutations bal.AccountMutations) bool { + // TODO: figure out how to simplify this method + if mutations.Code != nil && len(mutations.Code) != 0 { + return false + } + if mutations.Nonce != nil && *mutations.Nonce != 0 { + return false + } + if mutations.StorageWrites != nil && len(mutations.StorageWrites) > 0 { + return false + } + if mutations.Balance != nil { + if mutations.Balance.IsZero() { + if prestate.Nonce != 0 || prestate.Balance.IsZero() || common.BytesToHash(prestate.CodeHash) != types.EmptyCodeHash { + return false + } + // consider an empty account with storage to be deleted, so we don't check root here + return true + } + } + return false +} + +// updateAccount applies the block state mutations to a given account returning +// the updated state account and new code (if the account code changed) +func (s *BALStateTransition) updateAccount(addr common.Address) (*types.StateAccount, []byte) { + a, _ := s.prestates.Load(addr) + acct := a.(*types.StateAccount) + + acct, diff := acct.Copy(), s.diffs[addr] + code := diff.Code + + if diff.Nonce != nil { + acct.Nonce = *diff.Nonce + } + if diff.Balance != nil { + acct.Balance = new(uint256.Int).Set(diff.Balance) + } + if tr, ok := s.tries.Load(addr); ok { + acct.Root = tr.(Trie).Hash() + } + return acct, code +} + +func (s *BALStateTransition) commitAccount(addr common.Address) (*AccountUpdate, *trienode.NodeSet, error) { + op := &AccountUpdate{ + Address: addr, + Data: s.postStates[addr], // TODO: cache the updated state account somewhere + } + var prestate *types.StateAccount + if ps, exist := s.prestates.Load(addr); exist { + op.Origin = ps.(*types.StateAccount) + } + + if s.diffs[addr].Code != nil { + code := ContractCode{ + Hash: crypto.Keccak256Hash(s.diffs[addr].Code), + Blob: s.diffs[addr].Code, + } + if prestate == nil { + code.OriginHash = types.EmptyCodeHash + } else { + code.OriginHash = common.BytesToHash(prestate.CodeHash) + } + op.Code = &code + } + + if len(s.diffs[addr].StorageWrites) == 0 { + return op, nil, nil + } + + op.Storages = make(map[common.Hash]common.Hash) + op.StoragesOriginByHash = make(map[common.Hash]common.Hash) + op.StoragesOriginByKey = make(map[common.Hash]common.Hash) + + for key, value := range s.diffs[addr].StorageWrites { + hash := crypto.Keccak256Hash(key[:]) + op.Storages[hash] = value + origin, err := s.reader.Storage(addr, key) + if err != nil { + return nil, nil, err + } + op.StoragesOriginByHash[hash] = origin + op.StoragesOriginByKey[key] = origin + } + tr, _ := s.tries.Load(addr) + root, nodes := tr.(Trie).Commit(false) + s.postStates[addr].Root = root + return op, nodes, nil +} + +// CommitWithUpdate flushes mutated trie nodes and state accounts to disk. +func (s *BALStateTransition) CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, *StateUpdate, error) { + // 1) create a stateUpdate object + // Commit objects to the trie, measuring the elapsed time + var ( + //commitStart = time.Now() + accountTrieNodesUpdated int + accountTrieNodesDeleted int + storageTrieNodesUpdated int + storageTrieNodesDeleted int + + lock sync.Mutex // protect two maps below + nodes = trienode.NewMergedNodeSet() // aggregated trie nodes + updates = make(map[common.Hash]*AccountUpdate, len(s.diffs)) // aggregated account updates + + // merge aggregates the dirty trie nodes into the global set. + // + // Given that some accounts may be destroyed and then recreated within + // the same block, it's possible that a node set with the same owner + // may already exist. In such cases, these two sets are combined, with + // the later one overwriting the previous one if any nodes are modified + // or deleted in both sets. + // + // merge run concurrently across all the state objects and account trie. + merge = func(set *trienode.NodeSet) error { + if set == nil { + return nil + } + lock.Lock() + defer lock.Unlock() + + updates, deletes := set.Size() + if set.Owner == (common.Hash{}) { + accountTrieNodesUpdated += updates + accountTrieNodesDeleted += deletes + } else { + storageTrieNodesUpdated += updates + storageTrieNodesDeleted += deletes + } + return nodes.Merge(set) + } + ) + + destructedPrestates := make(map[common.Address]*types.StateAccount) + s.prestates.Range(func(key, value any) bool { + addr := key.(common.Address) + acct := value.(*types.StateAccount) + destructedPrestates[addr] = acct + return true + }) + + deletes, delNodes, err := handleDestruction(s.db, s.stateTrie, s.parentRoot, noStorageWiping, slices.Values(s.accessList.AllDestructions()), destructedPrestates) + if err != nil { + return common.Hash{}, nil, err + } + for _, set := range delNodes { + if err := merge(set); err != nil { + return common.Hash{}, nil, err + } + } + + // Handle all state updates afterwards, concurrently to one another to shave + // off some milliseconds from the commit operation. Also accumulate the code + // writes to run in parallel with the computations. + var ( + start = time.Now() + root common.Hash + workers errgroup.Group + ) + // Schedule the account trie first since that will be the biggest, so give + // it the most time to crunch. + // + // TODO(karalabe): This account trie commit is *very* heavy. 5-6ms at chain + // heads, which seems excessive given that it doesn't do hashing, it just + // shuffles some data. For comparison, the *hashing* at chain head is 2-3ms. + // We need to investigate what's happening as it seems something's wonky. + // Obviously it's not an end of the world issue, just something the original + // code didn't anticipate for. + workers.Go(func() error { + // Write the account trie changes, measuring the amount of wasted time + newroot, set := s.stateTrie.Commit(true) + root = newroot + + if err := merge(set); err != nil { + return err + } + s.metrics.AccountCommits = time.Since(start) + return nil + }) + + // Schedule each of the storage tries that need to be updated, so they can + // run concurrently to one another. + // + // TODO(karalabe): Experimentally, the account commit takes approximately the + // same time as all the storage commits combined, so we could maybe only have + // 2 threads in total. But that kind of depends on the account commit being + // more expensive than it should be, so let's fix that and revisit this todo. + for addr, _ := range s.diffs { + if _, isDeleted := s.deletions[addr]; isDeleted { + continue + } + + address := addr + // Run the storage updates concurrently to one another + workers.Go(func() error { + // Write any storage changes in the state object to its storage trie + update, set, err := s.commitAccount(address) + if err != nil { + return err + } + + if err := merge(set); err != nil { + return err + } + lock.Lock() + updates[crypto.Keccak256Hash(address[:])] = update + s.metrics.StorageCommits = time.Since(start) // overwrite with the longest storage commit runtime + lock.Unlock() + return nil + }) + } + // Wait for everything to finish and update the metrics + if err := workers.Wait(); err != nil { + return common.Hash{}, nil, err + } + + storageDeleted := s.storageDeleted.Load() + accountUpdatedMeter.Mark(int64(s.written.Accounts - s.accountDeleted)) + storageUpdatedMeter.Mark(int64(s.written.StorageSlots) - storageDeleted) + accountDeletedMeter.Mark(int64(s.accountDeleted)) + storageDeletedMeter.Mark(storageDeleted) + accountTrieUpdatedMeter.Mark(int64(accountTrieNodesUpdated)) + accountTrieDeletedMeter.Mark(int64(accountTrieNodesDeleted)) + storageTriesUpdatedMeter.Mark(int64(storageTrieNodesUpdated)) + storageTriesDeletedMeter.Mark(int64(storageTrieNodesDeleted)) + + storageKeyType := StorageKeyHashed + if noStorageWiping { + storageKeyType = StorageKeyPlain + } + update := NewStateUpdate(storageKeyType, s.parentRoot, root, block, deletes, updates, nodes) + + if err := s.db.Commit(update); err != nil { + return common.Hash{}, nil, err + } + // TODO: fix the following metrics: + /* + snapshotCommits, trieDBCommits, err := flushStateUpdate(s.db, block, ret) + if err != nil { + return common.Hash{}, nil, err + } + + s.metrics.SnapshotCommits, s.metrics.TrieDBCommits = snapshotCommits, trieDBCommits + s.metrics.TotalCommitTime = time.Since(commitStart) + */ + return root, update, nil +} + +func (s *BALStateTransition) Commit(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, error) { + hash, _, err := s.CommitWithUpdate(block, deleteEmptyObjects, noStorageWiping) + return hash, err +} + +// IntermediateRoot applies block state mutations and computes the updated state +// trie root. +func (s *BALStateTransition) IntermediateRoot(_ bool) common.Hash { + if s.rootHash != (common.Hash{}) { + return s.rootHash + } + + // State root calculation proceeds as follows: + + // 1 (a): load the origin storage values for all slots which were modified during the block (this is needed for computing the stateUpdate) + // 1 (b): update each mutated account, producing the post-block state object by applying the state mutations to the prestate (retrieved in 1a). + // 1 (c): prefetch the intermediate trie nodes of the mutated state set from the account trie. + // + // 2: compute the post-state root of the account trie + // + // Steps 1/2 are performed sequentially, with steps 1a-d performed in parallel + + start := time.Now() + + var wg sync.WaitGroup + + s.diffs = *s.accessList.Mutations(s.maxBALIdx + 1) + + for addr, d := range s.diffs { + wg.Add(1) + address := addr + diff := d + go func() { + defer wg.Done() + + // 1 (b): update each mutated account, producing the post-block state object by applying the state mutations to the prestate (retrieved in 1a). + acct, err := s.reader.Account(address) + if err != nil { + s.setError(err) + return + } + + if acct == nil { + acct = types.NewEmptyStateAccount() + } + s.prestates.Store(address, acct) + + if len(diff.StorageWrites) > 0 { + tr, err := s.db.OpenStorageTrie(s.parentRoot, address, acct.Root, s.stateTrie) + if err != nil { + s.setError(err) + return + } + s.tries.Store(address, tr) + + var ( + updateKeys, updateValues [][]byte + deleteKeys [][]byte + ) + for key, val := range diff.StorageWrites { + if val != (common.Hash{}) { + updateKeys = append(updateKeys, key[:]) + updateValues = append(updateValues, common.TrimLeftZeroes(val[:])) + } else { + deleteKeys = append(deleteKeys, key[:]) + } + } + + if err := tr.UpdateStorageBatch(address, updateKeys, updateValues); err != nil { + s.setError(err) + return + } + + for _, key := range deleteKeys { + if err := tr.DeleteStorage(address, key); err != nil { + s.setError(err) + return + } + } + + hashStart := time.Now() + tr.Hash() + s.metrics.StateHash = time.Since(hashStart) + } + }() + } + + wg.Add(1) + // 1 (c): prefetch the intermediate trie nodes of the mutated state set from the account trie. + go func() { + defer wg.Done() + prefetchStart := time.Now() + var prefetchAddrs []common.Address + for addr, _ := range s.diffs { + prefetchAddrs = append(prefetchAddrs, addr) + } + if err := s.stateTrie.PrefetchAccount(prefetchAddrs); err != nil { + s.setError(err) + return + } + s.metrics.StatePrefetch = time.Since(prefetchStart) + }() + + wg.Wait() + s.metrics.AccountUpdate = time.Since(start) + + // 2: compute the post-state root of the account trie + stateUpdateStart := time.Now() + for mutatedAddr, _ := range s.diffs { + p, _ := s.prestates.Load(mutatedAddr) + prestate := p.(*types.StateAccount) + + isDeleted := isAccountDeleted(prestate, s.diffs[mutatedAddr]) + if isDeleted { + if err := s.stateTrie.DeleteAccount(mutatedAddr); err != nil { + s.setError(err) + return common.Hash{} + } + s.deletions[mutatedAddr] = struct{}{} + s.accountDeleted++ + } else { + acct, code := s.updateAccount(mutatedAddr) + + if code != nil { + codeHash := crypto.Keccak256Hash(code) + acct.CodeHash = codeHash.Bytes() + if err := s.stateTrie.UpdateContractCode(mutatedAddr, codeHash, code); err != nil { + s.setError(err) + return common.Hash{} + } + } + if err := s.stateTrie.UpdateAccount(mutatedAddr, acct, len(code)); err != nil { + s.setError(err) + return common.Hash{} + } + s.postStates[mutatedAddr] = acct + } + } + + s.metrics.StateUpdate = time.Since(stateUpdateStart) + + stateTrieHashStart := time.Now() + s.rootHash = s.stateTrie.Hash() + s.metrics.StateHash = time.Since(stateTrieHashStart) + return s.rootHash +} + +func (s *BALStateTransition) Preimages() map[common.Hash][]byte { + // TODO: implement this + return make(map[common.Hash][]byte) +} diff --git a/core/state/database.go b/core/state/database.go index 3b1e627f28..3eebd18bac 100644 --- a/core/state/database.go +++ b/core/state/database.go @@ -54,6 +54,10 @@ type Database interface { // Reader returns a state reader associated with the specified state root. Reader(root common.Hash) (Reader, error) + // ReaderWithPrefetch returns a reader which asynchronously fetches block + // access list state in the background. + ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) + // Iteratee returns a state iteratee associated with the specified state root, // through which the account iterator and storage iterator can be created. Iteratee(root common.Hash) (Iteratee, error) @@ -107,12 +111,18 @@ type Trie interface { // in the trie with provided address. UpdateAccount(address common.Address, account *types.StateAccount, codeLen int) error + // UpdateAccountBatch attempts to update a list accounts in the batch manner. + UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, codeLengths []int) error + // UpdateStorage associates key with value in the trie. If value has length zero, // any existing value is deleted from the trie. The value bytes must not be modified // by the caller while they are stored in the trie. If a node was not found in the // database, a trie.MissingNodeError is returned. UpdateStorage(addr common.Address, key, value []byte) error + // UpdateStorageBatch attempts to update a list storages in the batch manner. + UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error + // DeleteAccount abstracts an account deletion from the trie. DeleteAccount(address common.Address) error diff --git a/core/state/database_history.go b/core/state/database_history.go index fbf4ab5f9c..ddc6d79238 100644 --- a/core/state/database_history.go +++ b/core/state/database_history.go @@ -223,6 +223,10 @@ type HistoricDB struct { codedb *CodeDB } +func (db *HistoricDB) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + panic("not implemented") +} + // Type returns the trie type of the underlying database. func (db *HistoricDB) Type() DatabaseType { // TODO(rjl493456442) support UBT in the future diff --git a/core/state/database_mpt.go b/core/state/database_mpt.go index 42c5f2e5ef..5e7d278232 100644 --- a/core/state/database_mpt.go +++ b/core/state/database_mpt.go @@ -185,3 +185,22 @@ func (db *MPTDatabase) Commit(update *StateUpdate) error { func (db *MPTDatabase) Iteratee(root common.Hash) (Iteratee, error) { return newStateIteratee(true, root, db.triedb, db.snap) } + +func (db *MPTDatabase) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + base, err := db.StateReader(stateRoot) + if err != nil { + return nil, err + } + // Construct the state reader with native cache and associated statistics + r := newStateReaderWithStats(newStateReaderWithCache(base)) + + // Construct the state reader with background prefetching + pr := newPrefetchStateReader(r, accessList, threads) + if block { + if err := pr.Wait(); err != nil { + panic("this should unreachable") + } + } + + return newReaderWithPrefetch(db.codedb.Reader(), pr, pr), nil +} diff --git a/core/state/database_ubt.go b/core/state/database_ubt.go index d9b2f07a77..0affdea2ed 100644 --- a/core/state/database_ubt.go +++ b/core/state/database_ubt.go @@ -96,6 +96,10 @@ func (db *UBTDatabase) Reader(stateRoot common.Hash) (Reader, error) { return newReader(db.codedb.Reader(), sr), nil } +func (db *UBTDatabase) ReaderWithPrefetch(stateRoot common.Hash, accessList map[common.Address][]common.Hash, threads int, block bool) (Reader, error) { + panic("not implemented") +} + // ReadersWithCacheStats creates a pair of state readers that share the same // underlying state reader and internal state cache, while maintaining separate // statistics respectively. diff --git a/core/state/reader.go b/core/state/reader.go index be07cec0f9..c485af96ed 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -560,6 +560,7 @@ func (r *stateReaderWithStats) GetStateStats() StateReaderStats { type reader struct { ContractCodeReader StateReader + PrefetcherMetricer } // newReader constructs a reader with the supplied code reader and state reader. @@ -570,6 +571,14 @@ func newReader(codeReader ContractCodeReader, stateReader StateReader) *reader { } } +func newReaderWithPrefetch(codeReader ContractCodeReader, stateReader StateReader, metricer PrefetcherMetricer) *reader { + return &reader{ + ContractCodeReader: codeReader, + StateReader: stateReader, + PrefetcherMetricer: metricer, + } +} + // GetCodeStats returns the statistics of code access. func (r *reader) GetCodeStats() ContractCodeReaderStats { if stater, ok := r.ContractCodeReader.(ContractCodeReaderStater); ok { diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index ff315ac5eb..72727d35c3 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -16,14 +16,6 @@ package state -import ( - "sync" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/core/types/bal" -) - // The EIP27928 reader utilizes a hierarchical architecture to optimize state // access during block execution: // @@ -39,15 +31,13 @@ import ( // This layer provides a "unified view" by merging the pre-transition state // with mutated states from preceding transactions in the block. // -// - Tracking Layer: Finally, the readerTracker wraps the execution reader to -// capture all state reads made during a specific transaction. These individual -// reads are subsequently merged to construct a comprehensive access list -// for the entire block. -// // The architecture can be illustrated by the diagram below: -// + +// [ Block Level Access List ] <────────────────┐ +// ▲ │ (Merge) +// │ │ // ┌──────────────┴──────────────┐ ┌──────────────┴──────────────┐ -// │ ReaderWithBlockLevelAL │ │ ReaderWithBlockLevelAL │ +// │ ReaderWithBlockLevelAL │ │ ReaderWithBlockLevelAL │ (Unified View) // │ (Pre-state + Mutations) │ │ (Pre-state + Mutations) │ // └──────────────┬──────────────┘ └──────────────┬──────────────┘ // │ │ @@ -63,11 +53,16 @@ import ( // │ (State & Contract Code) │ // └─────────────────────────────┘ -// Note: The block producer, which is responsible for generating the block -// along with the block-level access list, does not maintain the internal -// hierarchy (e.g., PrefetchStateReader or ReaderWithBlockLevelAL). -// Instead, it directly utilizes the readerTracker, wrapped around the -// base reader, to construct the access list. +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types/bal" +) type fetchTask struct { addr common.Address @@ -78,16 +73,27 @@ func (t *fetchTask) weight() int { return 1 + len(t.slots) } type prefetchStateReader struct { StateReader - tasks []*fetchTask nThreads int done chan struct{} term chan struct{} closeOnce sync.Once + start time.Time + metrics PrefetchMetrics } -// nolint:unused -func newPrefetchStateReader(reader StateReader, accessList map[common.Address][]common.Hash, nThreads int) *prefetchStateReader { +type PrefetchMetrics struct { + // the total amount of time it took to complete the scheduled workload + Elapsed time.Duration +} + +// PrefetcherMetricer is an object that can expose metrics related to the state +// prefetching. +type PrefetcherMetricer interface { + Metrics() PrefetchMetrics +} + +func newPrefetchStateReader(reader StateReader, accessList bal.StorageKeys, nThreads int) *prefetchStateReader { tasks := make([]*fetchTask, 0, len(accessList)) for addr, slots := range accessList { tasks = append(tasks, &fetchTask{ @@ -105,11 +111,16 @@ func newPrefetchStateReaderInternal(reader StateReader, tasks []*fetchTask, nThr nThreads: nThreads, done: make(chan struct{}), term: make(chan struct{}), + start: time.Now(), } go r.prefetch() return r } +func (r *prefetchStateReader) Metrics() PrefetchMetrics { + return r.metrics +} + func (r *prefetchStateReader) Close() { r.closeOnce.Do(func() { close(r.term) @@ -127,7 +138,10 @@ func (r *prefetchStateReader) Wait() error { } func (r *prefetchStateReader) prefetch() { - defer close(r.done) + defer func() { + r.metrics = PrefetchMetrics{time.Since(r.start)} + close(r.done) + }() if len(r.tasks) == 0 { return @@ -198,50 +212,88 @@ func (r *prefetchStateReader) process(start, limit int) { // prior to TxIndex. type ReaderWithBlockLevelAccessList struct { Reader - AccessList *bal.ConstructionBlockAccessList + AccessList bal.AccessListReader TxIndex int } -// NewReaderWithBlockLevelAccessList constructs a reader for accessing states -// with the mutations made by transactions prior to txIndex. -// -// The txIndex refers to the call frame as such: -// - 0 for pre‑execution system contract calls. -// - 1 … n for transactions (in block order). -// - n + 1 for post‑execution system contract calls. -func NewReaderWithBlockLevelAccessList(base Reader, accessList *bal.ConstructionBlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, - AccessList: accessList, + AccessList: bal.NewAccessListReader(accessList), TxIndex: txIndex, } } // Account implements Reader, returning the account with the specific address. -func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (*types.StateAccount, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *types.StateAccount, err error) { + acct, err = r.Reader.Account(addr) + if err != nil { + return nil, err + } + + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut == nil { + return + } + + if acct == nil { + acct = types.NewEmptyStateAccount() + } else { + // the account returned by the underlying reader is a reference + // copy it to avoid mutating the reader's instance + acct = acct.Copy() + } + + if mut.Balance != nil { + acct.Balance = mut.Balance + } + if mut.Code != nil { + codeHash := crypto.Keccak256Hash(mut.Code) + acct.CodeHash = codeHash[:] + } + if mut.Nonce != nil { + acct.Nonce = *mut.Nonce + } + return } // Storage implements Reader, returning the storage slot with the specific // address and slot key. func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { - panic("implement me") + val := r.AccessList.Storage(addr, slot, r.TxIndex) + if val != nil { + return *val, nil + } + return r.Reader.Storage(addr, slot) } // Has implements Reader, returning the flag indicating whether the contract // code with specified address and hash exists or not. func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash common.Hash) bool { - panic("implement me") + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil { + return crypto.Keccak256Hash(mut.Code) == codeHash + } + return r.Reader.Has(addr, codeHash) } // Code implements Reader, returning the contract code with specified address // and hash. -func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) ([]byte, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) []byte { + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { + // TODO: need to copy here? + return mut.Code + } + return r.Reader.Code(addr, codeHash) } // CodeSize implements Reader, returning the contract code size with specified // address and hash. -func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) (int, error) { - panic("implement me") +func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) int { + mut := r.AccessList.AccountMutations(addr, r.TxIndex) + if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { + return len(mut.Code) + } + return r.Reader.CodeSize(addr, codeHash) } diff --git a/core/state/statedb.go b/core/state/statedb.go index 1c49d46020..4d4e41bd67 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -21,6 +21,7 @@ import ( "bytes" "errors" "fmt" + "iter" "maps" "slices" "sort" @@ -182,6 +183,13 @@ func New(root common.Hash, db Database) (*StateDB, error) { return NewWithReader(root, db, reader) } +// WithReader returns a copy of the statedb instance with the specified reader. +func (s *StateDB) WithReader(reader Reader) *StateDB { + cpy := s.Copy() + cpy.reader = reader + return cpy +} + // NewWithReader creates a new state for the specified state root. Unlike New, // this function accepts an additional Reader which is bound to the given root. func NewWithReader(root common.Hash, db Database, reader Reader) (*StateDB, error) { @@ -1110,12 +1118,15 @@ func (s *StateDB) clearJournalAndRefund() { // deleteStorage is designed to delete the storage trie of a designated account. func (s *StateDB) deleteStorage(addrHash common.Hash, root common.Hash) (map[common.Hash]common.Hash, map[common.Hash]common.Hash, *trienode.NodeSet, error) { + return deleteStorage(s.db, s.originalRoot, addrHash, root) +} +func deleteStorage(db Database, originalRoot common.Hash, addrHash common.Hash, root common.Hash) (map[common.Hash]common.Hash, map[common.Hash]common.Hash, *trienode.NodeSet, error) { var ( nodes = trienode.NewNodeSet(addrHash) // the set for trie node mutations (value is nil) storages = make(map[common.Hash]common.Hash) // the set for storage mutations (value is nil) storageOrigins = make(map[common.Hash]common.Hash) // the set for tracking the original value of slot ) - iteratee, err := s.db.Iteratee(s.originalRoot) + iteratee, err := db.Iteratee(originalRoot) if err != nil { return nil, nil, nil, err } @@ -1544,3 +1555,72 @@ func (s *StateDB) Witness() *stateless.Witness { func (s *StateDB) AccessEvents() *AccessEvents { return s.accessEvents } + +// handleDestruction processes all destruction markers and deletes the account +// and associated storage slots if necessary. There are four potential scenarios +// as following: +// +// (a) the account was not existent and be marked as destructed +// (b) the account was not existent and be marked as destructed, +// however, it's resurrected later in the same block. +// (c) the account was existent and be marked as destructed +// (d) the account was existent and be marked as destructed, +// however it's resurrected later in the same block. +// +// In case (a), nothing needs be deleted, nil to nil transition can be ignored. +// In case (b), nothing needs be deleted, nil is used as the original value for +// newly created account and storages +// In case (c), **original** account along with its storages should be deleted, +// with their values be tracked as original value. +// In case (d), **original** account along with its storages should be deleted, +// with their values be tracked as original value. +func handleDestruction(db Database, trie Trie, root common.Hash, noStorageWiping bool, destructions iter.Seq[common.Address], prestates map[common.Address]*types.StateAccount) (map[common.Hash]*AccountDelete, []*trienode.NodeSet, error) { + var ( + nodes []*trienode.NodeSet + deletes = make(map[common.Hash]*AccountDelete) + ) + for addr := range destructions { + prestate := prestates[addr] + // The account was non-existent, and it's marked as destructed in the scope + // of block. It can be either case (a) or (b) and will be interpreted as + // null->null state transition. + // - for (a), skip it without doing anything + // - for (b), the resurrected account with nil as original will be handled afterwards + if prestate == nil { + continue + } + // The account was existent, it can be either case (c) or (d). + addrHash := crypto.Keccak256Hash(addr.Bytes()) + op := &AccountDelete{ + Address: addr, + Origin: prestate, + } + deletes[addrHash] = op + + // Short circuit if the origin storage was empty. + if prestate.Root == types.EmptyRootHash || db.TrieDB().IsUBT() { + continue + } + if noStorageWiping { + return nil, nil, fmt.Errorf("unexpected storage wiping, %x", addr) + } + // Remove storage slots belonging to the account. + storages, storagesOrigin, set, err := deleteStorage(db, prestate.Root, addrHash, root) + if err != nil { + return nil, nil, fmt.Errorf("failed to delete storage, err: %w", err) + } + op.Storages = storages + op.StoragesOrigin = storagesOrigin + + // Aggregate the associated trie node changes. + nodes = append(nodes, set) + } + return deletes, nodes, nil +} + +// TODO: find better location for this +type Committer interface { + Commit(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, error) + CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, *StateUpdate, error) + Preimages() map[common.Hash][]byte +} diff --git a/core/types.go b/core/types.go index f6f15101e0..98035262b1 100644 --- a/core/types.go +++ b/core/types.go @@ -34,7 +34,7 @@ type Validator interface { ValidateBody(block *types.Block) error // ValidateState validates the given statedb and optionally the process result. - ValidateState(block *types.Block, state *state.StateDB, res *ProcessResult, stateless bool) error + ValidateState(block *types.Block, state StateRootSource, res *ProcessResult, stateless bool) error } // Prefetcher is an interface for pre-caching transaction signatures and state. @@ -63,4 +63,6 @@ type ProcessResult struct { // BAL is only meaningful for post-Amsterdam blocks. Please ensure // fork validation is performed before accessing it. Bal *bal.ConstructionBlockAccessList + + Error error } diff --git a/core/types/bal/bal.go b/core/types/bal/bal.go index 2eb5fe93cd..9845916765 100644 --- a/core/types/bal/bal.go +++ b/core/types/bal/bal.go @@ -18,6 +18,7 @@ package bal import ( "bytes" + "encoding/json" "maps" "github.com/ethereum/go-ethereum/common" @@ -223,3 +224,137 @@ func (b *ConstructionBlockAccessList) Copy() *ConstructionBlockAccessList { } return res } + +type StorageMutations map[common.Hash]common.Hash + +// AccountMutations contains mutations that were made to an account across +// one or more access list indices. +type AccountMutations struct { + Balance *uint256.Int `json:"Balance,omitempty"` + Nonce *uint64 `json:"Nonce,omitempty"` + Code []byte `json:"Code,omitempty"` + StorageWrites StorageMutations `json:"StorageWrites,omitempty"` +} + +// String returns a human-readable JSON representation of the account mutations. +func (a *AccountMutations) String() string { + var res bytes.Buffer + enc := json.NewEncoder(&res) + enc.SetIndent("", " ") + enc.Encode(a) + return res.String() +} + +// Copy returns a deep-copy of the instance. +func (a *AccountMutations) Copy() *AccountMutations { + res := &AccountMutations{ + nil, + nil, + nil, + nil, + } + if a.Nonce != nil { + res.Nonce = new(uint64) + *res.Nonce = *a.Nonce + } + if a.Code != nil { + res.Code = bytes.Clone(a.Code) + } + if a.Balance != nil { + res.Balance = new(uint256.Int).Set(a.Balance) + } + if a.StorageWrites != nil { + res.StorageWrites = maps.Clone(a.StorageWrites) + } + return res +} + +// Eq returns whether the calling instance is equal to the provided one. +func (a *AccountMutations) Eq(other *AccountMutations) bool { + if a.Balance != nil || other.Balance != nil { + if a.Balance == nil || other.Balance == nil { + return false + } + + if !a.Balance.Eq(other.Balance) { + return false + } + } + + if (len(a.Code) != 0 || len(other.Code) != 0) && !bytes.Equal(a.Code, other.Code) { + return false + } + + if a.Nonce != nil || other.Nonce != nil { + if a.Nonce == nil || other.Nonce == nil { + return false + } + + if *a.Nonce != *other.Nonce { + return false + } + } + + if a.StorageWrites != nil || other.StorageWrites != nil { + if !maps.Equal(a.StorageWrites, other.StorageWrites) { + return false + } + } + return true +} + +type BALExecutionMode int + +const ( + BALExecutionOptimized BALExecutionMode = iota + BALExecutionNoBatchIO + BALExecutionSequential +) + +// WrittenCounts groups per-block aggregate write counts derived from the BAL. +type WrittenCounts struct { + Accounts int + StorageSlots int + Codes int + CodeBytes int +} + +// WrittenCounts walks the BAL once and returns the aggregate write counts. +func (e BlockAccessList) WrittenCounts() WrittenCounts { + var w WrittenCounts + for i := range e { + a := &e[i] + if len(a.StorageChanges) > 0 || len(a.BalanceChanges) > 0 || + len(a.NonceChanges) > 0 || len(a.CodeChanges) > 0 { + w.Accounts++ + } + w.StorageSlots += len(a.StorageChanges) + if n := len(a.CodeChanges); n > 0 { + w.Codes++ + w.CodeBytes += len(a.CodeChanges[n-1].NewCode) + } + } + return w +} + +type StateMutations map[common.Address]AccountMutations + +type StorageKeySet map[common.Hash]struct{} +type StateAccesses map[common.Address]StorageKeySet + +func (s StateAccesses) Eq(other StateAccesses) bool { + if len(s) != len(other) { + return false + } + + for addr, set := range s { + otherSet, ok := other[addr] + if !ok { + return false + } + if !maps.Equal(set, otherSet) { + return false + } + } + return true +} diff --git a/core/types/bal/bal_encoding.go b/core/types/bal/bal_encoding.go index 522192cee8..fc2d6f553f 100644 --- a/core/types/bal/bal_encoding.go +++ b/core/types/bal/bal_encoding.go @@ -400,7 +400,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc obj.SlotChanges = make([]encodingStorageWrite, 0, len(slotWrites)) indices := slices.Collect(maps.Keys(slotWrites)) - slices.SortFunc(indices, cmp.Compare) + slices.Sort(indices) for _, index := range indices { val := slotWrites[index] obj.SlotChanges = append(obj.SlotChanges, encodingStorageWrite{ @@ -420,7 +420,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert balance changes balanceIndices := slices.Collect(maps.Keys(a.BalanceChanges)) - slices.SortFunc(balanceIndices, cmp.Compare) + slices.Sort(balanceIndices) for _, idx := range balanceIndices { res.BalanceChanges = append(res.BalanceChanges, encodingBalanceChange{ BlockAccessIndex: idx, @@ -430,7 +430,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert nonce changes nonceIndices := slices.Collect(maps.Keys(a.NonceChanges)) - slices.SortFunc(nonceIndices, cmp.Compare) + slices.Sort(nonceIndices) for _, idx := range nonceIndices { res.NonceChanges = append(res.NonceChanges, encodingAccountNonce{ BlockAccessIndex: idx, @@ -440,7 +440,7 @@ func (a *ConstructionAccountAccess) toEncodingObj(addr common.Address) AccountAc // Convert code change codeIndices := slices.Collect(maps.Keys(a.CodeChange)) - slices.SortFunc(codeIndices, cmp.Compare) + slices.Sort(codeIndices) for _, idx := range codeIndices { res.CodeChanges = append(res.CodeChanges, encodingCodeChange{ BlockAccessIndex: idx, diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go new file mode 100644 index 0000000000..e6cffc922e --- /dev/null +++ b/core/types/bal/bal_reader.go @@ -0,0 +1,114 @@ +package bal + +import ( + "bytes" + "github.com/ethereum/go-ethereum/common" +) + +// AccessListReader exposes utilities to read state mutations and accesses from an access list +type AccessListReader map[common.Address]*AccountAccess + +func NewAccessListReader(bal BlockAccessList) (reader AccessListReader) { + reader = make(AccessListReader) + for _, accountAccess := range bal { + reader[accountAccess.Address] = &accountAccess + } + return +} + +// AccountMutations returns the aggregate mutation for an account up until (and not including) the given block access +// list index. +func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *AccountMutations) { + diff, exist := a[addr] + if !exist { + return nil + } + + res = &AccountMutations{} + + for i := 0; i < len(diff.BalanceChanges) && diff.BalanceChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Balance = diff.BalanceChanges[i].PostBalance.Clone() + } + + for i := 0; i < len(diff.CodeChanges) && diff.CodeChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Code = bytes.Clone(diff.CodeChanges[i].NewCode) + } + + for i := 0; i < len(diff.NonceChanges) && diff.NonceChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.Nonce = new(uint64) + *res.Nonce = diff.NonceChanges[i].PostNonce + } + + if len(diff.StorageChanges) > 0 { + res.StorageWrites = make(map[common.Hash]common.Hash) + for _, slotWrites := range diff.StorageChanges { + for i := 0; i < len(slotWrites.SlotChanges) && slotWrites.SlotChanges[i].BlockAccessIndex < uint32(idx); i++ { + res.StorageWrites[slotWrites.Slot.Bytes32()] = slotWrites.SlotChanges[i].PostValue.Bytes32() + } + } + } + + if res.Code == nil && res.Nonce == nil && len(res.StorageWrites) == 0 && res.Balance == nil { + return nil + } + return res +} + +type StorageKeys map[common.Address][]common.Hash + +// StorageKeys returns the set of accounts and storage keys mutated in the access list. +// If reads is set, the un-mutated accounts/keys are included in the result. +func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { + keys = make(StorageKeys) + for addr, acct := range a { + for _, storageChange := range acct.StorageChanges { + keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) + } + if !(reads && len(acct.StorageReads) > 0) { + continue + } + for _, storageRead := range acct.StorageReads { + keys[addr] = append(keys[addr], storageRead.Bytes32()) + } + } + return +} + +// Storage returns the value of a storage key at the start of executing an index. +// If the slot has no mutations in the access list, it returns nil. +func (a AccessListReader) Storage(addr common.Address, key common.Hash, idx int) (val *common.Hash) { + storageMuts := a.AccountMutations(addr, idx) + if storageMuts != nil { + res, ok := storageMuts.StorageWrites[key] + if ok { + return &res + } + } + return nil +} + +// Mutations returns the aggregate state mutations from bal indices [0, idx) +func (a AccessListReader) Mutations(idx int) *StateMutations { + res := make(StateMutations) + for addr := range a { + if mut := a.AccountMutations(addr, idx); mut != nil { + res[addr] = *mut + } + } + return &res +} + +// AllDestructions returns all accounts that experienced a destruction, regardless of whether +// they were later resurrected and exist after the block. It excludes ephemeral contracts from +// the result. +func (a AccessListReader) AllDestructions() (res []common.Address) { + for addr, access := range a { + for _, nonce := range access.NonceChanges { + if nonce.PostNonce == 0 { + res = append(res, addr) + break + } + } + } + return res +} diff --git a/eth/backend.go b/eth/backend.go index 7438486696..7867f9b5ef 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -291,6 +291,10 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) { } options.Overrides = &overrides + options.BALExecutionMode = config.BALExecutionMode + options.BlockingPrefetch = config.BlockingPrefetch + options.PrefetchWorkers = int(config.PrefetchWorkers) + eth.blockchain, err = core.NewBlockChain(chainDb, config.Genesis, eth.engine, options) if err != nil { return nil, err diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index 99a0fa89f7..b5dada60e2 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -19,6 +19,7 @@ package ethconfig import ( "errors" + "github.com/ethereum/go-ethereum/core/types/bal" "time" "github.com/ethereum/go-ethereum/common" @@ -221,6 +222,10 @@ type Config struct { // RangeLimit restricts the maximum range (end - start) for range queries. RangeLimit uint64 `toml:",omitempty"` + + BALExecutionMode bal.BALExecutionMode + PrefetchWorkers uint + BlockingPrefetch bool } // CreateConsensusEngine creates a consensus engine for the given chain config. diff --git a/tests/block_test.go b/tests/block_test.go index 31bb4f9d36..d309d81670 100644 --- a/tests/block_test.go +++ b/tests/block_test.go @@ -127,7 +127,7 @@ func execBlockTest(t *testing.T, bt *testMatcher, test *BlockTest) { } for _, snapshot := range snapshotConf { for _, dbscheme := range dbschemeConf { - if err := bt.checkFailure(t, test.Run(snapshot, dbscheme, true, nil, nil)); err != nil { + if err := bt.checkFailure(t, test.Run(snapshot, dbscheme, true, true, nil, nil)); err != nil { t.Errorf("test with config {snapshotter:%v, scheme:%v} failed: %v", snapshot, dbscheme, err) return } diff --git a/tests/block_test_util.go b/tests/block_test_util.go index bece8ae610..ab0c908470 100644 --- a/tests/block_test_util.go +++ b/tests/block_test_util.go @@ -113,27 +113,20 @@ type btHeaderMarshaling struct { SlotNumber *math.HexOrDecimal64 } -func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *tracing.Hooks, postCheck func(error, *core.BlockChain)) (result error) { - config, ok := Forks[t.json.Network] - if !ok { - return UnsupportedForkError{t.json.Network} - } - +func (t *BlockTest) createTestBlockChain(config *params.ChainConfig, snapshotter bool, scheme string, witness, createAndVerifyBAL bool, tracer *tracing.Hooks) (*core.BlockChain, error) { // import pre accounts & construct test genesis block & state root - // Commit genesis state var ( - gspec = t.genesis(config) db = rawdb.NewMemoryDatabase() tconf = &triedb.Config{ Preimages: true, - IsUBT: gspec.Config.UBTTime != nil && *gspec.Config.UBTTime <= gspec.Timestamp, } ) - if scheme == rawdb.PathScheme || tconf.IsUBT { + if scheme == rawdb.PathScheme { tconf.PathDB = pathdb.Defaults } else { tconf.HashDB = hashdb.Defaults } + gspec := t.genesis(config) // if ttd is not specified, set an arbitrary huge value if gspec.Config.TerminalTotalDifficulty == nil { @@ -142,15 +135,15 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t triedb := triedb.NewDatabase(db, tconf) gblock, err := gspec.Commit(db, triedb, nil) if err != nil { - return err + return nil, err } triedb.Close() // close the db to prevent memory leak if gblock.Hash() != t.json.Genesis.Hash { - return fmt.Errorf("genesis block hash doesn't match test: computed=%x, test=%x", gblock.Hash().Bytes()[:6], t.json.Genesis.Hash[:6]) + return nil, fmt.Errorf("genesis block hash doesn't match test: computed=%x, test=%x", gblock.Hash().Bytes()[:6], t.json.Genesis.Hash[:6]) } if gblock.Root() != t.json.Genesis.StateRoot { - return fmt.Errorf("genesis block state root does not match test: computed=%x, test=%x", gblock.Root().Bytes()[:6], t.json.Genesis.StateRoot[:6]) + return nil, fmt.Errorf("genesis block state root does not match test: computed=%x, test=%x", gblock.Root().Bytes()[:6], t.json.Genesis.StateRoot[:6]) } // Wrap the original engine within the beacon-engine engine := beacon.New(ethash.NewFaker()) @@ -164,12 +157,28 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t Tracer: tracer, }, StatelessSelfValidation: witness, + NoPrefetch: true, + BlockingPrefetch: true, + PrefetchWorkers: 100, // note: this is totally unrelated to NoPrefetch, just for BAL execution } if snapshotter { options.SnapshotLimit = 1 options.SnapshotWait = true } chain, err := core.NewBlockChain(db, gspec, engine, options) + if err != nil { + return nil, err + } + return chain, nil +} + +func (t *BlockTest) Run(snapshotter bool, scheme string, witness, createAndVerifyBAL bool, tracer *tracing.Hooks, postCheck func(error, *core.BlockChain)) (result error) { + config, ok := Forks[t.json.Network] + if !ok { + return UnsupportedForkError{t.json.Network} + } + + chain, err := t.createTestBlockChain(config, snapshotter, scheme, witness, createAndVerifyBAL, tracer) if err != nil { return err } @@ -203,7 +212,50 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t } } } - return t.validateImportedHeaders(chain, validBlocks) + err = t.validateImportedHeaders(chain, validBlocks) + if err != nil { + return err + } + + if createAndVerifyBAL { + newChain, _ := t.createTestBlockChain(config, snapshotter, scheme, witness, createAndVerifyBAL, tracer) + defer newChain.Stop() + + var blocksWithBAL types.Blocks + for i := uint64(1); i <= chain.CurrentBlock().Number.Uint64(); i++ { + block := chain.GetBlockByNumber(i) + if chain.Config().IsAmsterdam(block.Number(), block.Time()) && block.AccessList() == nil { + return fmt.Errorf("block %d missing BAL", block.NumberU64()) + } + blocksWithBAL = append(blocksWithBAL, block) + } + + amt, err := newChain.InsertChain(blocksWithBAL) + if err != nil { + return err + } + _ = amt + newDB, err := newChain.State() + if err != nil { + return err + } + if err = t.validatePostState(newDB); err != nil { + return fmt.Errorf("post state validation failed: %v", err) + } + // Cross-check the snapshot-to-hash against the trie hash + if snapshotter { + if newChain.Snapshots() != nil { + if err := chain.Snapshots().Verify(chain.CurrentBlock().Root); err != nil { + return err + } + } + } + err = t.validateImportedHeaders(newChain, validBlocks) + if err != nil { + return err + } + } + return nil } // Network returns the network/fork name for this test. diff --git a/trie/bintrie/trie.go b/trie/bintrie/trie.go index 0d0c0e0e70..ac9b19a2e1 100644 --- a/trie/bintrie/trie.go +++ b/trie/bintrie/trie.go @@ -451,3 +451,11 @@ func (t *BinaryTrie) PrefetchStorage(addr common.Address, keys [][]byte) error { func (t *BinaryTrie) Witness() map[string][]byte { return t.tracer.Values() } + +func (t *BinaryTrie) UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error { + panic("not implemented") +} + +func (t *BinaryTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, _ []int) error { + panic("not implemented") +} diff --git a/trie/secure_trie.go b/trie/secure_trie.go index 4d03ca45f0..f2176310d0 100644 --- a/trie/secure_trie.go +++ b/trie/secure_trie.go @@ -210,6 +210,29 @@ func (t *StateTrie) UpdateStorage(_ common.Address, key, value []byte) error { return nil } +// UpdateStorageBatch attempts to update a list storages in the batch manner. +func (t *StateTrie) UpdateStorageBatch(_ common.Address, keys [][]byte, values [][]byte) error { + var ( + hkeys = make([][]byte, 0, len(keys)) + evals = make([][]byte, 0, len(values)) + ) + for _, key := range keys { + hk := crypto.Keccak256(key) + if t.preimages != nil { + t.secKeyCache[common.Hash(hk)] = key + } + hkeys = append(hkeys, hk) + } + for _, val := range values { + data, err := rlp.EncodeToBytes(val) + if err != nil { + return err + } + evals = append(evals, data) + } + return t.trie.UpdateBatch(hkeys, evals) +} + // UpdateAccount will abstract the write of an account to the secure trie. func (t *StateTrie) UpdateAccount(address common.Address, acc *types.StateAccount, _ int) error { hk := crypto.Keccak256(address.Bytes()) @@ -226,6 +249,29 @@ func (t *StateTrie) UpdateAccount(address common.Address, acc *types.StateAccoun return nil } +// UpdateAccountBatch attempts to update a list accounts in the batch manner. +func (t *StateTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, _ []int) error { + var ( + hkeys = make([][]byte, 0, len(addresses)) + values = make([][]byte, 0, len(accounts)) + ) + for _, addr := range addresses { + hk := crypto.Keccak256(addr.Bytes()) + if t.preimages != nil { + t.secKeyCache[common.Hash(hk)] = addr.Bytes() + } + hkeys = append(hkeys, hk) + } + for _, acc := range accounts { + data, err := rlp.EncodeToBytes(acc) + if err != nil { + return err + } + values = append(values, data) + } + return t.trie.UpdateBatch(hkeys, values) +} + func (t *StateTrie) UpdateContractCode(_ common.Address, _ common.Hash, _ []byte) error { return nil } diff --git a/trie/tracer.go b/trie/tracer.go index 04122d1384..042fa468bf 100644 --- a/trie/tracer.go +++ b/trie/tracer.go @@ -33,12 +33,10 @@ import ( // while the latter is inserted/deleted in order to follow the rule of trie. // This tool can track all of them no matter the node is embedded in its // parent or not, but valueNode is never tracked. -// -// Note opTracer is not thread-safe, callers should be responsible for handling -// the concurrency issues by themselves. type opTracer struct { inserts map[string]struct{} deletes map[string]struct{} + lock sync.RWMutex } // newOpTracer initializes the tracer for capturing trie changes. @@ -53,6 +51,9 @@ func newOpTracer() *opTracer { // in the deletion set (resurrected node), then just wipe it from // the deletion set as it's "untouched". func (t *opTracer) onInsert(path []byte) { + t.lock.Lock() + defer t.lock.Unlock() + if _, present := t.deletes[string(path)]; present { delete(t.deletes, string(path)) return @@ -64,6 +65,9 @@ func (t *opTracer) onInsert(path []byte) { // in the addition set, then just wipe it from the addition set // as it's untouched. func (t *opTracer) onDelete(path []byte) { + t.lock.Lock() + defer t.lock.Unlock() + if _, present := t.inserts[string(path)]; present { delete(t.inserts, string(path)) return @@ -73,12 +77,18 @@ func (t *opTracer) onDelete(path []byte) { // reset clears the content tracked by tracer. func (t *opTracer) reset() { + t.lock.Lock() + defer t.lock.Unlock() + clear(t.inserts) clear(t.deletes) } // copy returns a deep copied tracer instance. func (t *opTracer) copy() *opTracer { + t.lock.RLock() + defer t.lock.RUnlock() + return &opTracer{ inserts: maps.Clone(t.inserts), deletes: maps.Clone(t.deletes), @@ -87,6 +97,9 @@ func (t *opTracer) copy() *opTracer { // deletedList returns a list of node paths which are deleted from the trie. func (t *opTracer) deletedList() [][]byte { + t.lock.RLock() + defer t.lock.RUnlock() + paths := make([][]byte, 0, len(t.deletes)) for path := range t.deletes { paths = append(paths, []byte(path)) diff --git a/trie/transitiontrie/transition.go b/trie/transitiontrie/transition.go index 3e5511be9e..d939e804e3 100644 --- a/trie/transitiontrie/transition.go +++ b/trie/transitiontrie/transition.go @@ -144,6 +144,19 @@ func (t *TransitionTrie) UpdateStorage(address common.Address, key []byte, value return t.overlay.UpdateStorage(address, key, v) } +// UpdateStorageBatch attempts to update a list storages in the batch manner. +func (t *TransitionTrie) UpdateStorageBatch(address common.Address, keys [][]byte, values [][]byte) error { + if len(keys) != len(values) { + return fmt.Errorf("keys and values length mismatch: %d != %d", len(keys), len(values)) + } + for i, key := range keys { + if err := t.UpdateStorage(address, key, values[i]); err != nil { + return err + } + } + return nil +} + // UpdateAccount abstract an account write to the trie. func (t *TransitionTrie) UpdateAccount(addr common.Address, account *types.StateAccount, codeLen int) error { // NOTE: before the rebase, this was saving the state root, so that OpenStorageTrie @@ -152,6 +165,22 @@ func (t *TransitionTrie) UpdateAccount(addr common.Address, account *types.State return t.overlay.UpdateAccount(addr, account, codeLen) } +// UpdateAccountBatch attempts to update a list accounts in the batch manner. +func (t *TransitionTrie) UpdateAccountBatch(addresses []common.Address, accounts []*types.StateAccount, codeLens []int) error { + if len(addresses) != len(accounts) { + return fmt.Errorf("address and accounts length mismatch: %d != %d", len(addresses), len(accounts)) + } + if len(addresses) != len(codeLens) { + return fmt.Errorf("address and code length mismatch: %d != %d", len(addresses), len(codeLens)) + } + for i, addr := range addresses { + if err := t.UpdateAccount(addr, accounts[i], codeLens[i]); err != nil { + return err + } + } + return nil +} + // DeleteStorage removes any existing value for key from the trie. If a node was not // found in the database, a trie.MissingNodeError is returned. func (t *TransitionTrie) DeleteStorage(addr common.Address, key []byte) error { diff --git a/trie/trie.go b/trie/trie.go index 1ef2c2f1a6..7e69a90823 100644 --- a/trie/trie.go +++ b/trie/trie.go @@ -480,6 +480,69 @@ func (t *Trie) insert(n node, prefix, key []byte, value node) (bool, node, error } } +// UpdateBatch updates a batch of entries concurrently. +func (t *Trie) UpdateBatch(keys [][]byte, values [][]byte) error { + // Short circuit if the trie is already committed and unusable. + if t.committed { + return ErrCommitted + } + if len(keys) != len(values) { + return fmt.Errorf("keys and values length mismatch: %d != %d", len(keys), len(values)) + } + // Insert the entries sequentially if there are not too many + // trie nodes in the trie. + fn, ok := t.root.(*fullNode) + if !ok || len(keys) < 4 { // TODO(rjl493456442) the parallelism threshold should be twisted + for i, key := range keys { + err := t.Update(key, values[i]) + if err != nil { + return err + } + } + return nil + } + var ( + ikeys = make(map[byte][][]byte) + ivals = make(map[byte][][]byte) + eg errgroup.Group + ) + for i, key := range keys { + hkey := keybytesToHex(key) + ikeys[hkey[0]] = append(ikeys[hkey[0]], hkey) + ivals[hkey[0]] = append(ivals[hkey[0]], values[i]) + } + if len(keys) > 0 { + fn.flags = t.newFlag() + } + for pos, ks := range ikeys { + eg.Go(func() error { + vs := ivals[pos] + for i, k := range ks { + if len(vs[i]) != 0 { + _, n, err := t.insert(fn.Children[pos], []byte{pos}, k[1:], valueNode(vs[i])) + if err != nil { + return err + } + fn.Children[pos] = n + } else { + _, n, err := t.delete(fn.Children[pos], []byte{pos}, k[1:]) + if err != nil { + return err + } + fn.Children[pos] = n + } + } + return nil + }) + } + if err := eg.Wait(); err != nil { + return err + } + t.unhashed += len(keys) + t.uncommitted += len(keys) + return nil +} + // MustDelete is a wrapper of Delete and will omit any encountered error but // just print out an error message. func (t *Trie) MustDelete(key []byte) { diff --git a/trie/trie_test.go b/trie/trie_test.go index 3661933e22..949f381f07 100644 --- a/trie/trie_test.go +++ b/trie/trie_test.go @@ -1580,3 +1580,57 @@ func BenchmarkTrieSeqPrefetch(b *testing.B) { } } } + +func TestUpdateBatch(t *testing.T) { + testUpdateBatch(t, []kv{ + {k: []byte("do"), v: []byte("verb")}, + {k: []byte("ether"), v: []byte("wookiedoo")}, + {k: []byte("horse"), v: []byte("stallion")}, + {k: []byte("shaman"), v: []byte("horse")}, + {k: []byte("doge"), v: []byte("coin")}, + {k: []byte("dog"), v: []byte("puppy")}, + }) + + var entries []kv + for i := 0; i < 256; i++ { + entries = append(entries, kv{k: testrand.Bytes(32), v: testrand.Bytes(32)}) + } + testUpdateBatch(t, entries) +} + +func testUpdateBatch(t *testing.T, entries []kv) { + var ( + base = NewEmpty(nil) + keys [][]byte + vals [][]byte + ) + for _, entry := range entries { + base.Update(entry.k, entry.v) + keys = append(keys, entry.k) + vals = append(vals, entry.v) + } + for i := 0; i < 10; i++ { + k, v := testrand.Bytes(32), testrand.Bytes(32) + base.Update(k, v) + keys = append(keys, k) + vals = append(vals, v) + } + + cmp := NewEmpty(nil) + if err := cmp.UpdateBatch(keys, vals); err != nil { + t.Fatalf("Failed to update batch, %v", err) + } + + // Traverse the original tree, the changes made on the copy one shouldn't + // affect the old one + for _, key := range keys { + v1, _ := base.Get(key) + v2, _ := cmp.Get(key) + if !bytes.Equal(v1, v2) { + t.Errorf("Unexpected data, key: %v, want: %v, got: %v", key, v1, v2) + } + } + if base.Hash() != cmp.Hash() { + t.Errorf("Hash mismatch: want %x, got %x", base.Hash(), cmp.Hash()) + } +} From 1d1607e8a7cc5c2460f5348bb338ca910f3f8779 Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 1 Jun 2026 22:13:18 -0400 Subject: [PATCH 06/16] attach block access list in ExecutableDataToBlockNoHash --- beacon/engine/types.go | 88 ++++++++++++++++++++++++------------------ eth/catalyst/api.go | 6 ++- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 58ecd1d780..93aeb5527a 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -19,6 +19,7 @@ package engine import ( "bytes" "fmt" + "github.com/ethereum/go-ethereum/core/types/bal" "math/big" "slices" @@ -85,25 +86,25 @@ type payloadAttributesMarshaling struct { // ExecutableData is the data necessary to execute an EL payload. type ExecutableData struct { - ParentHash common.Hash `json:"parentHash" gencodec:"required"` - FeeRecipient common.Address `json:"feeRecipient" gencodec:"required"` - StateRoot common.Hash `json:"stateRoot" gencodec:"required"` - ReceiptsRoot common.Hash `json:"receiptsRoot" gencodec:"required"` - LogsBloom []byte `json:"logsBloom" gencodec:"required"` - Random common.Hash `json:"prevRandao" gencodec:"required"` - Number uint64 `json:"blockNumber" gencodec:"required"` - GasLimit uint64 `json:"gasLimit" gencodec:"required"` - GasUsed uint64 `json:"gasUsed" gencodec:"required"` - Timestamp uint64 `json:"timestamp" gencodec:"required"` - ExtraData []byte `json:"extraData" gencodec:"required"` - BaseFeePerGas *big.Int `json:"baseFeePerGas" gencodec:"required"` - BlockHash common.Hash `json:"blockHash" gencodec:"required"` - Transactions [][]byte `json:"transactions" gencodec:"required"` - Withdrawals []*types.Withdrawal `json:"withdrawals"` - BlobGasUsed *uint64 `json:"blobGasUsed"` - ExcessBlobGas *uint64 `json:"excessBlobGas"` - SlotNumber *uint64 `json:"slotNumber,omitempty"` - BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` + ParentHash common.Hash `json:"parentHash" gencodec:"required"` + FeeRecipient common.Address `json:"feeRecipient" gencodec:"required"` + StateRoot common.Hash `json:"stateRoot" gencodec:"required"` + ReceiptsRoot common.Hash `json:"receiptsRoot" gencodec:"required"` + LogsBloom []byte `json:"logsBloom" gencodec:"required"` + Random common.Hash `json:"prevRandao" gencodec:"required"` + Number uint64 `json:"blockNumber" gencodec:"required"` + GasLimit uint64 `json:"gasLimit" gencodec:"required"` + GasUsed uint64 `json:"gasUsed" gencodec:"required"` + Timestamp uint64 `json:"timestamp" gencodec:"required"` + ExtraData []byte `json:"extraData" gencodec:"required"` + BaseFeePerGas *big.Int `json:"baseFeePerGas" gencodec:"required"` + BlockHash common.Hash `json:"blockHash" gencodec:"required"` + Transactions [][]byte `json:"transactions" gencodec:"required"` + Withdrawals []*types.Withdrawal `json:"withdrawals"` + BlobGasUsed *uint64 `json:"blobGasUsed"` + ExcessBlobGas *uint64 `json:"excessBlobGas"` + SlotNumber *uint64 `json:"slotNumber,omitempty"` + BlockAccessList hexutil.Bytes `json:"blockAccessList,omitempty"` } // JSON type overrides for executableData. @@ -350,31 +351,42 @@ func ExecutableDataToBlockNoHash(data ExecutableData, versionedHashes []common.H SlotNumber: data.SlotNumber, BlockAccessListHash: blockAccessListHash, } - return types.NewBlockWithHeader(header).WithBody(types.Body{Transactions: txs, Uncles: nil, Withdrawals: data.Withdrawals}), nil + body := types.Body{Transactions: txs, Uncles: nil, Withdrawals: data.Withdrawals} + if data.BlockAccessList != nil { + balHash := crypto.Keccak256Hash(data.BlockAccessList) + header.BlockAccessListHash = &balHash + var accessList bal.BlockAccessList + if err := rlp.DecodeBytes(data.BlockAccessList, &accessList); err != nil { + return nil, fmt.Errorf("failed to decode BAL: %w\n", err) + } + block := types.NewBlockWithHeader(header).WithBody(body).WithAccessList(&accessList) + return block, nil + } + return types.NewBlockWithHeader(header).WithBody(body), nil } // BlockToExecutableData constructs the ExecutableData structure by filling the // fields from the given block. It assumes the given block is post-merge block. func BlockToExecutableData(block *types.Block, fees *big.Int, sidecars []*types.BlobTxSidecar, requests [][]byte) *ExecutionPayloadEnvelope { data := &ExecutableData{ - BlockHash: block.Hash(), - ParentHash: block.ParentHash(), - FeeRecipient: block.Coinbase(), - StateRoot: block.Root(), - Number: block.NumberU64(), - GasLimit: block.GasLimit(), - GasUsed: block.GasUsed(), - BaseFeePerGas: block.BaseFee(), - Timestamp: block.Time(), - ReceiptsRoot: block.ReceiptHash(), - LogsBloom: block.Bloom().Bytes(), - Transactions: encodeTransactions(block.Transactions()), - Random: block.MixDigest(), - ExtraData: block.Extra(), - Withdrawals: block.Withdrawals(), - BlobGasUsed: block.BlobGasUsed(), - ExcessBlobGas: block.ExcessBlobGas(), - SlotNumber: block.SlotNumber(), + BlockHash: block.Hash(), + ParentHash: block.ParentHash(), + FeeRecipient: block.Coinbase(), + StateRoot: block.Root(), + Number: block.NumberU64(), + GasLimit: block.GasLimit(), + GasUsed: block.GasUsed(), + BaseFeePerGas: block.BaseFee(), + Timestamp: block.Time(), + ReceiptsRoot: block.ReceiptHash(), + LogsBloom: block.Bloom().Bytes(), + Transactions: encodeTransactions(block.Transactions()), + Random: block.MixDigest(), + ExtraData: block.Extra(), + Withdrawals: block.Withdrawals(), + BlobGasUsed: block.BlobGasUsed(), + ExcessBlobGas: block.ExcessBlobGas(), + SlotNumber: block.SlotNumber(), } // Per Engine API spec (Amsterdam): blockAccessList is the RLP-encoded // access list, serialized as a hex string. Encode it to bytes here. diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 4b0c3f6ea3..54e5e45c7a 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -804,10 +804,12 @@ func (api *ConsensusAPI) NewPayloadV5(ctx context.Context, params engine.Executa return invalidStatus, paramsErr("nil beaconRoot post-cancun") case executionRequests == nil: return invalidStatus, paramsErr("nil executionRequests post-prague") - case params.SlotNumber == nil: - return invalidStatus, paramsErr("nil slotnumber post-amsterdam") case !api.checkFork(params.Timestamp, forks.Amsterdam): return invalidStatus, unsupportedForkErr("newPayloadV5 must only be called for amsterdam payloads") + case params.SlotNumber == nil: + return invalidStatus, paramsErr("nil slotnumber post-amsterdam") + case params.BlockAccessList == nil: + return invalidStatus, paramsErr("nil block access list post-amsterdam") } requests := convertRequests(executionRequests) if err := validateRequests(requests); err != nil { From 60a0ebf540ddc2fc14db34ff6d04784613d30a3c Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Tue, 2 Jun 2026 18:37:57 +0200 Subject: [PATCH 07/16] core/types/bal: faster bal reader --- core/blockchain.go | 9 +- core/parallel_state_processor.go | 13 +- core/state/bal_state_transition.go | 13 +- core/state/reader_eip_7928.go | 70 +++++---- core/types/bal/bal_reader.go | 235 +++++++++++++++++++++-------- 5 files changed, 238 insertions(+), 102 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index b8acea6f4a..ad107d0822 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2144,13 +2144,16 @@ func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block * useAsyncReads := bc.cfg.BALExecutionMode != bal.BALExecutionNoBatchIO al := block.AccessList() - accessListReader := bal.NewAccessListReader(*al) - prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, accessListReader.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) + // Preprocess the access list once for the whole block; the resulting + // structure is read-only and shared by the prefetch reader, the state + // transition and every per-transaction execution reader. + prepared := bal.NewPreparedAccessList(*al) + prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, prepared.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) if err != nil { return nil, err } - stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot) + stateTransition, err := state.NewBALStateTransition(block, prefetchReader, sdb, parentRoot, prepared) if err != nil { return nil, err } diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index 3407aabad7..d2b67a6a86 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -44,14 +44,14 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() vmContext := NewEVMBlockContext(header, p.chain, nil) lastBALIdx := len(block.Transactions()) + 1 - postTxState := statedb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), lastBALIdx)) + postTxState := statedb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, lastBALIdx)) cfg := vm.Config{ NoBaseFee: p.vmCfg.NoBaseFee, @@ -148,7 +148,7 @@ type txExecResult struct { // resultHandler polls until all transactions have finished executing and the // state root calculation is complete. The result is emitted on resCh. -func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) var results []txExecResult @@ -187,7 +187,7 @@ func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal } } - execResults := p.prepareExecResult(block, tExecStart, preTxBAL, statedb, results) + execResults := p.prepareExecResult(block, tExecStart, preTxBAL, prepared, statedb, results) rootCalcRes := <-stateRootCalcResCh if execResults.ProcessResult.Error != nil { @@ -292,6 +292,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st ) startingState := statedb.Copy() + prepared := stateTransition.PreparedAccessList() preTxBal, err := p.processBlockPreTx(block, statedb, cfg) if err != nil { return nil, err @@ -302,7 +303,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st // execute transactions and state root calculation in parallel tExecStart = time.Now() - go p.resultHandler(block, preTxBal, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + go p.resultHandler(block, preTxBal, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) var workers errgroup.Group workers.SetLimit(runtime.NumCPU()) for i, t := range block.Transactions() { @@ -310,7 +311,7 @@ func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *st idx := i sdb := startingState.Copy() workers.Go(func() error { - startingState := sdb.WithReader(state.NewReaderWithBlockLevelAccessList(statedb.Reader(), *block.AccessList(), idx+1)) + startingState := sdb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, idx+1)) res := p.execTx(block, tx, idx+1, startingState, signer) txResCh <- *res return nil diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go index de643ab0d1..7e44496f7f 100644 --- a/core/state/bal_state_transition.go +++ b/core/state/bal_state_transition.go @@ -19,7 +19,7 @@ import ( // and commit for EIP 7928 access-list-containing blocks. An instance of // this object is only used for a single block. type BALStateTransition struct { - accessList bal.AccessListReader + accessList *bal.PreparedAccessList written bal.WrittenCounts db Database reader Reader @@ -86,14 +86,14 @@ type BALStateTransitionMetrics struct { TotalCommitTime time.Duration } -func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash) (*BALStateTransition, error) { +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.PreparedAccessList) (*BALStateTransition, error) { stateTrie, err := db.OpenTrie(parentRoot) if err != nil { return nil, err } return &BALStateTransition{ - accessList: bal.NewAccessListReader(*block.AccessList()), + accessList: prepared, written: block.AccessList().WrittenCounts(), db: db, reader: prefetchReader, @@ -115,6 +115,13 @@ func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { return s.written } +// PreparedAccessList returns the shared, read-only preprocessed access list for +// the block. It is built once per block and reused by the parallel execution +// readers so the preprocessing is not repeated per transaction. +func (s *BALStateTransition) PreparedAccessList() *bal.PreparedAccessList { + return s.accessList +} + func (s *BALStateTransition) Error() error { return s.err } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index 72727d35c3..b3390ad63c 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -210,20 +210,34 @@ func (r *prefetchStateReader) process(start, limit int) { // ReaderWithBlockLevelAccessList provides state access that reflects the // pre-transition state combined with the mutations made by transactions // prior to TxIndex. +// +// It is a cheap, per-transaction view over a shared, read-only +// bal.PreparedAccessList: constructing one is O(1) and every lookup is an +// allocation-free binary search. type ReaderWithBlockLevelAccessList struct { Reader - AccessList bal.AccessListReader - TxIndex int + prepared *bal.PreparedAccessList + TxIndex int } -func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +// NewReaderWithPreparedAccessList wraps a base reader with a shared, already +// preprocessed access list. This is the cheap constructor used on the hot path: +// the prepared list is built once per block and borrowed by every per-tx reader. +func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessList, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ - Reader: base, - AccessList: bal.NewAccessListReader(accessList), - TxIndex: txIndex, + Reader: base, + prepared: prepared, + TxIndex: txIndex, } } +// NewReaderWithBlockLevelAccessList wraps a base reader with a raw access list, +// preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the +// prepared list can be built once and shared across multiple readers. +func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { + return NewReaderWithPreparedAccessList(base, bal.NewPreparedAccessList(accessList), txIndex) +} + // Account implements Reader, returning the account with the specific address. func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *types.StateAccount, err error) { acct, err = r.Reader.Account(addr) @@ -231,9 +245,11 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ return nil, err } - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut == nil { - return + balance := r.prepared.Balance(addr, r.TxIndex) + code := r.prepared.Code(addr, r.TxIndex) + nonce, hasNonce := r.prepared.Nonce(addr, r.TxIndex) + if balance == nil && code == nil && !hasNonce { + return acct, nil } if acct == nil { @@ -244,15 +260,18 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ acct = acct.Copy() } - if mut.Balance != nil { - acct.Balance = mut.Balance + // balance and code alias the shared access list; this is safe because the + // EVM never mutates them in place (it replaces the pointer/slice wholesale, + // and the journal clones before stashing). + if balance != nil { + acct.Balance = balance } - if mut.Code != nil { - codeHash := crypto.Keccak256Hash(mut.Code) + if code != nil { + codeHash := crypto.Keccak256Hash(code) acct.CodeHash = codeHash[:] } - if mut.Nonce != nil { - acct.Nonce = *mut.Nonce + if hasNonce { + acct.Nonce = nonce } return } @@ -260,9 +279,8 @@ func (r *ReaderWithBlockLevelAccessList) Account(addr common.Address) (acct *typ // Storage implements Reader, returning the storage slot with the specific // address and slot key. func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { - val := r.AccessList.Storage(addr, slot, r.TxIndex) - if val != nil { - return *val, nil + if val, ok := r.prepared.StorageAt(addr, slot, r.TxIndex); ok { + return val, nil } return r.Reader.Storage(addr, slot) } @@ -270,9 +288,8 @@ func (r *ReaderWithBlockLevelAccessList) Storage(addr common.Address, slot commo // Has implements Reader, returning the flag indicating whether the contract // code with specified address and hash exists or not. func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash common.Hash) bool { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil { - return crypto.Keccak256Hash(mut.Code) == codeHash + if code := r.prepared.Code(addr, r.TxIndex); code != nil { + return crypto.Keccak256Hash(code) == codeHash } return r.Reader.Has(addr, codeHash) } @@ -280,10 +297,8 @@ func (r *ReaderWithBlockLevelAccessList) Has(addr common.Address, codeHash commo // Code implements Reader, returning the contract code with specified address // and hash. func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash common.Hash) []byte { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { - // TODO: need to copy here? - return mut.Code + if code := r.prepared.Code(addr, r.TxIndex); code != nil && crypto.Keccak256Hash(code) == codeHash { + return code } return r.Reader.Code(addr, codeHash) } @@ -291,9 +306,8 @@ func (r *ReaderWithBlockLevelAccessList) Code(addr common.Address, codeHash comm // CodeSize implements Reader, returning the contract code size with specified // address and hash. func (r *ReaderWithBlockLevelAccessList) CodeSize(addr common.Address, codeHash common.Hash) int { - mut := r.AccessList.AccountMutations(addr, r.TxIndex) - if mut != nil && mut.Code != nil && crypto.Keccak256Hash(mut.Code) == codeHash { - return len(mut.Code) + if code := r.prepared.Code(addr, r.TxIndex); code != nil && crypto.Keccak256Hash(code) == codeHash { + return len(code) } return r.Reader.CodeSize(addr, codeHash) } diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go index e6cffc922e..07b32c8ab1 100644 --- a/core/types/bal/bal_reader.go +++ b/core/types/bal/bal_reader.go @@ -1,53 +1,176 @@ package bal import ( - "bytes" + "sort" + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" ) -// AccessListReader exposes utilities to read state mutations and accesses from an access list -type AccessListReader map[common.Address]*AccountAccess - -func NewAccessListReader(bal BlockAccessList) (reader AccessListReader) { - reader = make(AccessListReader) - for _, accountAccess := range bal { - reader[accountAccess.Address] = &accountAccess - } - return +// PreparedAccessList is an immutable, per-block preprocessed view of a +// BlockAccessList optimized for repeated point-in-time reads. +// +// It is built once per block (NewPreparedAccessList) before parallel +// transaction execution begins. The change slices it holds are the +// already-sorted slices decoded from the BlockAccessList, borrowed by +// reference (never copied, never mutated). After construction the structure +// is read-only and therefore safe for concurrent use by all per-transaction +// readers without any synchronization. +// +// Each lookup binary-searches the relevant change slice for the last mutation +// strictly before the queried block-access index, which is O(log K) and +// allocation-free, in contrast to the previous map-backed reader that +// re-walked every change array from index 0 and re-allocated an aggregate +// mutation object on every call. +type PreparedAccessList struct { + accounts map[common.Address]*preparedAccount } -// AccountMutations returns the aggregate mutation for an account up until (and not including) the given block access -// list index. -func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *AccountMutations) { - diff, exist := a[addr] - if !exist { - return nil - } +type preparedAccount struct { + // The following slices are borrowed directly from the decoded + // AccountAccess. They are validated to be strictly sorted ascending by + // BlockAccessIndex (see bal_encoding.go), which is exactly the key we + // binary-search on. + balances []encodingBalanceChange + nonces []encodingAccountNonce + codes []encodingCodeChange + storage map[common.Hash]*preparedSlot - res = &AccountMutations{} + // access is retained to back the once-per-block aggregate helpers + // (StorageKeys, AllDestructions) without re-deriving anything. + access *AccountAccess +} - for i := 0; i < len(diff.BalanceChanges) && diff.BalanceChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.Balance = diff.BalanceChanges[i].PostBalance.Clone() - } +type preparedSlot struct { + changes []encodingStorageWrite // borrowed, sorted asc by BlockAccessIndex +} - for i := 0; i < len(diff.CodeChanges) && diff.CodeChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.Code = bytes.Clone(diff.CodeChanges[i].NewCode) - } - - for i := 0; i < len(diff.NonceChanges) && diff.NonceChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.Nonce = new(uint64) - *res.Nonce = diff.NonceChanges[i].PostNonce - } - - if len(diff.StorageChanges) > 0 { - res.StorageWrites = make(map[common.Hash]common.Hash) - for _, slotWrites := range diff.StorageChanges { - for i := 0; i < len(slotWrites.SlotChanges) && slotWrites.SlotChanges[i].BlockAccessIndex < uint32(idx); i++ { - res.StorageWrites[slotWrites.Slot.Bytes32()] = slotWrites.SlotChanges[i].PostValue.Bytes32() +// NewPreparedAccessList preprocesses a BlockAccessList into a PreparedAccessList. +// It performs a single linear pass and borrows the underlying change slices by +// reference; the provided list must not be mutated afterwards. +func NewPreparedAccessList(list BlockAccessList) *PreparedAccessList { + accounts := make(map[common.Address]*preparedAccount, len(list)) + for i := range list { + a := &list[i] // index; do not range-copy the AccountAccess + pa := &preparedAccount{ + balances: a.BalanceChanges, + nonces: a.NonceChanges, + codes: a.CodeChanges, + access: a, + } + if len(a.StorageChanges) > 0 { + pa.storage = make(map[common.Hash]*preparedSlot, len(a.StorageChanges)) + for j := range a.StorageChanges { + sc := &a.StorageChanges[j] + pa.storage[sc.Slot.Bytes32()] = &preparedSlot{changes: sc.SlotChanges} } } + accounts[a.Address] = pa } + return &PreparedAccessList{accounts: accounts} +} +// lastBefore returns the position of the last element in a slice of n elements +// sorted ascending by BlockAccessIndex whose key is strictly less than idx, or +// -1 if no such element exists. keyAt returns the BlockAccessIndex at position k. +func lastBefore(n int, idx uint32, keyAt func(k int) uint32) int { + // sort.Search returns the smallest position whose key is >= idx; everything + // before it is strictly less than idx, so the answer is that position - 1. + return sort.Search(n, func(k int) bool { return keyAt(k) >= idx }) - 1 +} + +// Balance returns the post-balance in effect immediately before the given block +// access index, or nil if the account's balance was not changed before idx. +// The returned pointer aliases the access list and must not be mutated. +func (p *PreparedAccessList) Balance(addr common.Address, idx int) *uint256.Int { + a := p.accounts[addr] + if a == nil { + return nil + } + k := lastBefore(len(a.balances), uint32(idx), func(i int) uint32 { return a.balances[i].BlockAccessIndex }) + if k < 0 { + return nil + } + return a.balances[k].PostBalance +} + +// Nonce returns the post-nonce in effect immediately before the given block +// access index. The boolean is false if the nonce was not changed before idx. +func (p *PreparedAccessList) Nonce(addr common.Address, idx int) (uint64, bool) { + a := p.accounts[addr] + if a == nil { + return 0, false + } + k := lastBefore(len(a.nonces), uint32(idx), func(i int) uint32 { return a.nonces[i].BlockAccessIndex }) + if k < 0 { + return 0, false + } + return a.nonces[k].PostNonce, true +} + +// Code returns the contract code in effect immediately before the given block +// access index, or nil if the code was not changed before idx. The returned +// slice aliases the access list and must not be mutated. +func (p *PreparedAccessList) Code(addr common.Address, idx int) []byte { + a := p.accounts[addr] + if a == nil { + return nil + } + k := lastBefore(len(a.codes), uint32(idx), func(i int) uint32 { return a.codes[i].BlockAccessIndex }) + if k < 0 { + return nil + } + return a.codes[k].NewCode +} + +// StorageAt returns the post-value of a storage slot immediately before the +// given block access index. The boolean is false if the slot was not written +// before idx. +func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { + a := p.accounts[addr] + if a == nil { + return common.Hash{}, false + } + s := a.storage[slot] + if s == nil { + return common.Hash{}, false + } + k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) + if k < 0 { + return common.Hash{}, false + } + return s.changes[k].PostValue.Bytes32(), true +} + +// AccountMutations returns the aggregate mutation for an account up until (and +// not including) the given block access list index, or nil if the account was +// not mutated before idx. +func (p *PreparedAccessList) AccountMutations(addr common.Address, idx int) *AccountMutations { + a := p.accounts[addr] + if a == nil { + return nil + } + res := &AccountMutations{} + if bal := p.Balance(addr, idx); bal != nil { + res.Balance = bal.Clone() + } + if code := p.Code(addr, idx); code != nil { + res.Code = code + } + if nonce, ok := p.Nonce(addr, idx); ok { + res.Nonce = new(uint64) + *res.Nonce = nonce + } + for slot, s := range a.storage { + k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) + if k < 0 { + continue + } + if res.StorageWrites == nil { + res.StorageWrites = make(map[common.Hash]common.Hash) + } + res.StorageWrites[slot] = s.changes[k].PostValue.Bytes32() + } if res.Code == nil && res.Nonce == nil && len(res.StorageWrites) == 0 && res.Balance == nil { return nil } @@ -56,11 +179,12 @@ func (a AccessListReader) AccountMutations(addr common.Address, idx int) (res *A type StorageKeys map[common.Address][]common.Hash -// StorageKeys returns the set of accounts and storage keys mutated in the access list. -// If reads is set, the un-mutated accounts/keys are included in the result. -func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { +// StorageKeys returns the set of accounts and storage keys mutated in the access +// list. If reads is set, the un-mutated accounts/keys are included in the result. +func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { keys = make(StorageKeys) - for addr, acct := range a { + for addr, a := range p.accounts { + acct := a.access for _, storageChange := range acct.StorageChanges { keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) } @@ -74,36 +198,23 @@ func (a AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { return } -// Storage returns the value of a storage key at the start of executing an index. -// If the slot has no mutations in the access list, it returns nil. -func (a AccessListReader) Storage(addr common.Address, key common.Hash, idx int) (val *common.Hash) { - storageMuts := a.AccountMutations(addr, idx) - if storageMuts != nil { - res, ok := storageMuts.StorageWrites[key] - if ok { - return &res - } - } - return nil -} - -// Mutations returns the aggregate state mutations from bal indices [0, idx) -func (a AccessListReader) Mutations(idx int) *StateMutations { +// Mutations returns the aggregate state mutations from bal indices [0, idx). +func (p *PreparedAccessList) Mutations(idx int) *StateMutations { res := make(StateMutations) - for addr := range a { - if mut := a.AccountMutations(addr, idx); mut != nil { + for addr := range p.accounts { + if mut := p.AccountMutations(addr, idx); mut != nil { res[addr] = *mut } } return &res } -// AllDestructions returns all accounts that experienced a destruction, regardless of whether -// they were later resurrected and exist after the block. It excludes ephemeral contracts from -// the result. -func (a AccessListReader) AllDestructions() (res []common.Address) { - for addr, access := range a { - for _, nonce := range access.NonceChanges { +// AllDestructions returns all accounts that experienced a destruction, regardless +// of whether they were later resurrected and exist after the block. It excludes +// ephemeral contracts from the result. +func (p *PreparedAccessList) AllDestructions() (res []common.Address) { + for addr, a := range p.accounts { + for _, nonce := range a.access.NonceChanges { if nonce.PostNonce == 0 { res = append(res, addr) break From 849d30d58c8e2bce18c5daf19e97676d5795c5a4 Mon Sep 17 00:00:00 2001 From: Jared Wasinger Date: Mon, 8 Jun 2026 18:21:57 -0400 Subject: [PATCH 08/16] clean up BAL state diff reader logic --- core/blockchain.go | 2 +- core/parallel_state_processor.go | 4 +- core/state/bal_state_transition.go | 6 +- core/state/reader_eip_7928.go | 8 +-- core/types/bal/bal_reader.go | 93 ++++++++++-------------------- 5 files changed, 42 insertions(+), 71 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index ad107d0822..a81d435fea 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2147,7 +2147,7 @@ func (bc *BlockChain) processBlockWithAccessList(parentRoot common.Hash, block * // Preprocess the access list once for the whole block; the resulting // structure is read-only and shared by the prefetch reader, the state // transition and every per-transaction execution reader. - prepared := bal.NewPreparedAccessList(*al) + prepared := bal.NewAccessListReader(*al) prefetchReader, err := sdb.ReaderWithPrefetch(parentRoot, prepared.StorageKeys(useAsyncReads), bc.cfg.PrefetchWorkers, bc.cfg.BlockingPrefetch) if err != nil { return nil, err diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index d2b67a6a86..be2af36dbf 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -44,7 +44,7 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() @@ -148,7 +148,7 @@ type txExecResult struct { // resultHandler polls until all transactions have finished executing and the // state root calculation is complete. The result is emitted on resCh. -func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.PreparedAccessList, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { +func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) var results []txExecResult diff --git a/core/state/bal_state_transition.go b/core/state/bal_state_transition.go index 7e44496f7f..5cf761d647 100644 --- a/core/state/bal_state_transition.go +++ b/core/state/bal_state_transition.go @@ -19,7 +19,7 @@ import ( // and commit for EIP 7928 access-list-containing blocks. An instance of // this object is only used for a single block. type BALStateTransition struct { - accessList *bal.PreparedAccessList + accessList *bal.AccessListReader written bal.WrittenCounts db Database reader Reader @@ -86,7 +86,7 @@ type BALStateTransitionMetrics struct { TotalCommitTime time.Duration } -func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.PreparedAccessList) (*BALStateTransition, error) { +func NewBALStateTransition(block *types.Block, prefetchReader Reader, db Database, parentRoot common.Hash, prepared *bal.AccessListReader) (*BALStateTransition, error) { stateTrie, err := db.OpenTrie(parentRoot) if err != nil { return nil, err @@ -118,7 +118,7 @@ func (s *BALStateTransition) WrittenCounts() bal.WrittenCounts { // PreparedAccessList returns the shared, read-only preprocessed access list for // the block. It is built once per block and reused by the parallel execution // readers so the preprocessing is not repeated per transaction. -func (s *BALStateTransition) PreparedAccessList() *bal.PreparedAccessList { +func (s *BALStateTransition) PreparedAccessList() *bal.AccessListReader { return s.accessList } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index b3390ad63c..b89636870d 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -212,18 +212,18 @@ func (r *prefetchStateReader) process(start, limit int) { // prior to TxIndex. // // It is a cheap, per-transaction view over a shared, read-only -// bal.PreparedAccessList: constructing one is O(1) and every lookup is an +// bal.AccessListReader: constructing one is O(1) and every lookup is an // allocation-free binary search. type ReaderWithBlockLevelAccessList struct { Reader - prepared *bal.PreparedAccessList + prepared *bal.AccessListReader TxIndex int } // NewReaderWithPreparedAccessList wraps a base reader with a shared, already // preprocessed access list. This is the cheap constructor used on the hot path: // the prepared list is built once per block and borrowed by every per-tx reader. -func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessList, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, prepared: prepared, @@ -235,7 +235,7 @@ func NewReaderWithPreparedAccessList(base Reader, prepared *bal.PreparedAccessLi // preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the // prepared list can be built once and shared across multiple readers. func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { - return NewReaderWithPreparedAccessList(base, bal.NewPreparedAccessList(accessList), txIndex) + return NewReaderWithPreparedAccessList(base, bal.NewAccessListReader(accessList), txIndex) } // Account implements Reader, returning the account with the specific address. diff --git a/core/types/bal/bal_reader.go b/core/types/bal/bal_reader.go index 07b32c8ab1..bb4a97c8d4 100644 --- a/core/types/bal/bal_reader.go +++ b/core/types/bal/bal_reader.go @@ -7,67 +7,39 @@ import ( "github.com/holiman/uint256" ) -// PreparedAccessList is an immutable, per-block preprocessed view of a -// BlockAccessList optimized for repeated point-in-time reads. -// -// It is built once per block (NewPreparedAccessList) before parallel -// transaction execution begins. The change slices it holds are the -// already-sorted slices decoded from the BlockAccessList, borrowed by -// reference (never copied, never mutated). After construction the structure -// is read-only and therefore safe for concurrent use by all per-transaction -// readers without any synchronization. -// -// Each lookup binary-searches the relevant change slice for the last mutation -// strictly before the queried block-access index, which is O(log K) and -// allocation-free, in contrast to the previous map-backed reader that -// re-walked every change array from index 0 and re-allocated an aggregate -// mutation object on every call. -type PreparedAccessList struct { +// AccessListReader enables efficient state diff lookups from a block access +// list during block execution. +type AccessListReader struct { accounts map[common.Address]*preparedAccount } type preparedAccount struct { - // The following slices are borrowed directly from the decoded - // AccountAccess. They are validated to be strictly sorted ascending by - // BlockAccessIndex (see bal_encoding.go), which is exactly the key we - // binary-search on. - balances []encodingBalanceChange - nonces []encodingAccountNonce - codes []encodingCodeChange - storage map[common.Hash]*preparedSlot - - // access is retained to back the once-per-block aggregate helpers - // (StorageKeys, AllDestructions) without re-deriving anything. - access *AccountAccess + storage map[common.Hash]preparedSlot + AccountAccess } type preparedSlot struct { changes []encodingStorageWrite // borrowed, sorted asc by BlockAccessIndex } -// NewPreparedAccessList preprocesses a BlockAccessList into a PreparedAccessList. -// It performs a single linear pass and borrows the underlying change slices by -// reference; the provided list must not be mutated afterwards. -func NewPreparedAccessList(list BlockAccessList) *PreparedAccessList { +// NewAccessListReader instantiates an access list reader. +func NewAccessListReader(list BlockAccessList) *AccessListReader { accounts := make(map[common.Address]*preparedAccount, len(list)) for i := range list { - a := &list[i] // index; do not range-copy the AccountAccess + a := list[i] // index; do not range-copy the AccountAccess pa := &preparedAccount{ - balances: a.BalanceChanges, - nonces: a.NonceChanges, - codes: a.CodeChanges, - access: a, + AccountAccess: a, } if len(a.StorageChanges) > 0 { - pa.storage = make(map[common.Hash]*preparedSlot, len(a.StorageChanges)) + pa.storage = make(map[common.Hash]preparedSlot, len(a.StorageChanges)) for j := range a.StorageChanges { sc := &a.StorageChanges[j] - pa.storage[sc.Slot.Bytes32()] = &preparedSlot{changes: sc.SlotChanges} + pa.storage[sc.Slot.Bytes32()] = preparedSlot{changes: sc.SlotChanges} } } accounts[a.Address] = pa } - return &PreparedAccessList{accounts: accounts} + return &AccessListReader{accounts: accounts} } // lastBefore returns the position of the last element in a slice of n elements @@ -82,57 +54,57 @@ func lastBefore(n int, idx uint32, keyAt func(k int) uint32) int { // Balance returns the post-balance in effect immediately before the given block // access index, or nil if the account's balance was not changed before idx. // The returned pointer aliases the access list and must not be mutated. -func (p *PreparedAccessList) Balance(addr common.Address, idx int) *uint256.Int { +func (p *AccessListReader) Balance(addr common.Address, idx int) *uint256.Int { a := p.accounts[addr] if a == nil { return nil } - k := lastBefore(len(a.balances), uint32(idx), func(i int) uint32 { return a.balances[i].BlockAccessIndex }) + k := lastBefore(len(a.BalanceChanges), uint32(idx), func(i int) uint32 { return a.BalanceChanges[i].BlockAccessIndex }) if k < 0 { return nil } - return a.balances[k].PostBalance + return a.BalanceChanges[k].PostBalance } // Nonce returns the post-nonce in effect immediately before the given block // access index. The boolean is false if the nonce was not changed before idx. -func (p *PreparedAccessList) Nonce(addr common.Address, idx int) (uint64, bool) { +func (p *AccessListReader) Nonce(addr common.Address, idx int) (uint64, bool) { a := p.accounts[addr] if a == nil { return 0, false } - k := lastBefore(len(a.nonces), uint32(idx), func(i int) uint32 { return a.nonces[i].BlockAccessIndex }) + k := lastBefore(len(a.NonceChanges), uint32(idx), func(i int) uint32 { return a.NonceChanges[i].BlockAccessIndex }) if k < 0 { return 0, false } - return a.nonces[k].PostNonce, true + return a.NonceChanges[k].PostNonce, true } // Code returns the contract code in effect immediately before the given block // access index, or nil if the code was not changed before idx. The returned // slice aliases the access list and must not be mutated. -func (p *PreparedAccessList) Code(addr common.Address, idx int) []byte { +func (p *AccessListReader) Code(addr common.Address, idx int) []byte { a := p.accounts[addr] if a == nil { return nil } - k := lastBefore(len(a.codes), uint32(idx), func(i int) uint32 { return a.codes[i].BlockAccessIndex }) + k := lastBefore(len(a.CodeChanges), uint32(idx), func(i int) uint32 { return a.CodeChanges[i].BlockAccessIndex }) if k < 0 { return nil } - return a.codes[k].NewCode + return a.CodeChanges[k].NewCode } // StorageAt returns the post-value of a storage slot immediately before the // given block access index. The boolean is false if the slot was not written // before idx. -func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { +func (p *AccessListReader) StorageAt(addr common.Address, slot common.Hash, idx int) (common.Hash, bool) { a := p.accounts[addr] if a == nil { return common.Hash{}, false } - s := a.storage[slot] - if s == nil { + s, ok := a.storage[slot] + if !ok { return common.Hash{}, false } k := lastBefore(len(s.changes), uint32(idx), func(i int) uint32 { return s.changes[i].BlockAccessIndex }) @@ -145,7 +117,7 @@ func (p *PreparedAccessList) StorageAt(addr common.Address, slot common.Hash, id // AccountMutations returns the aggregate mutation for an account up until (and // not including) the given block access list index, or nil if the account was // not mutated before idx. -func (p *PreparedAccessList) AccountMutations(addr common.Address, idx int) *AccountMutations { +func (p *AccessListReader) AccountMutations(addr common.Address, idx int) *AccountMutations { a := p.accounts[addr] if a == nil { return nil @@ -181,17 +153,16 @@ type StorageKeys map[common.Address][]common.Hash // StorageKeys returns the set of accounts and storage keys mutated in the access // list. If reads is set, the un-mutated accounts/keys are included in the result. -func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { +func (p *AccessListReader) StorageKeys(reads bool) (keys StorageKeys) { keys = make(StorageKeys) for addr, a := range p.accounts { - acct := a.access - for _, storageChange := range acct.StorageChanges { + for _, storageChange := range a.StorageChanges { keys[addr] = append(keys[addr], storageChange.Slot.Bytes32()) } - if !(reads && len(acct.StorageReads) > 0) { + if !(reads && len(a.StorageReads) > 0) { continue } - for _, storageRead := range acct.StorageReads { + for _, storageRead := range a.StorageReads { keys[addr] = append(keys[addr], storageRead.Bytes32()) } } @@ -199,7 +170,7 @@ func (p *PreparedAccessList) StorageKeys(reads bool) (keys StorageKeys) { } // Mutations returns the aggregate state mutations from bal indices [0, idx). -func (p *PreparedAccessList) Mutations(idx int) *StateMutations { +func (p *AccessListReader) Mutations(idx int) *StateMutations { res := make(StateMutations) for addr := range p.accounts { if mut := p.AccountMutations(addr, idx); mut != nil { @@ -212,9 +183,9 @@ func (p *PreparedAccessList) Mutations(idx int) *StateMutations { // AllDestructions returns all accounts that experienced a destruction, regardless // of whether they were later resurrected and exist after the block. It excludes // ephemeral contracts from the result. -func (p *PreparedAccessList) AllDestructions() (res []common.Address) { +func (p *AccessListReader) AllDestructions() (res []common.Address) { for addr, a := range p.accounts { - for _, nonce := range a.access.NonceChanges { + for _, nonce := range a.NonceChanges { if nonce.PostNonce == 0 { res = append(res, addr) break From 6aa174111e1fcbabce796b6923eadaa4c48d337a Mon Sep 17 00:00:00 2001 From: jwasinger Date: Wed, 10 Jun 2026 14:52:08 -0400 Subject: [PATCH 09/16] core: clean up parallel state processor (#35143) --- core/blockchain.go | 2 +- core/parallel_state_processor.go | 222 +++++++++++++++---------------- core/state/reader_eip_7928.go | 8 +- 3 files changed, 110 insertions(+), 122 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index a81d435fea..d8cdd237b7 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -439,7 +439,7 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine, bc.validator = NewBlockValidator(chainConfig, bc) bc.prefetcher = newStatePrefetcher(chainConfig, bc.hc) bc.processor = NewStateProcessor(bc.hc) - bc.parallelProcessor = NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) + bc.parallelProcessor = *NewParallelStateProcessor(bc.hc, bc.GetVMConfig()) genesisHeader := bc.GetHeaderByNumber(0) if genesisHeader == nil { diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go index be2af36dbf..4c4d09b747 100644 --- a/core/parallel_state_processor.go +++ b/core/parallel_state_processor.go @@ -24,6 +24,11 @@ type ProcessResultWithMetrics struct { PostProcessTime time.Duration } +// errResult wraps an error into a new ProcessResultWithMetrics instance +func errResult(err error) *ProcessResultWithMetrics { + return &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: err}} +} + // ParallelStateProcessor is used to execute and verify blocks containing // access lists. type ParallelStateProcessor struct { @@ -32,11 +37,23 @@ type ParallelStateProcessor struct { } // NewParallelStateProcessor returns a new ParallelStateProcessor instance. -func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) ParallelStateProcessor { - res := NewStateProcessor(chain) - return ParallelStateProcessor{ - res, - vmConfig, +func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) *ParallelStateProcessor { + return &ParallelStateProcessor{ + StateProcessor: NewStateProcessor(chain), + vmCfg: vmConfig, + } +} + +// execVMConfig returns the subset of the configured VM options that is safe to +// reuse across the parallel per-transaction and post-transaction executions. +// Only the fields explicitly copied here are propagated (mirroring the original +// per-tx behaviour); notably the full caller-supplied config is used only for +// pre-execution in processBlockPreTx. +func (p *ParallelStateProcessor) execVMConfig() vm.Config { + return vm.Config{ + NoBaseFee: p.vmCfg.NoBaseFee, + EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, + ExtraEips: slices.Clone(p.vmCfg.ExtraEips), } } @@ -44,21 +61,17 @@ func NewParallelStateProcessor(chain *HeaderChain, vmConfig *vm.Config) Parallel // performs post-tx state transition (system contracts and withdrawals) // and calculates the ProcessResult, returning it to be sent on resCh // by resultHandler -func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBal *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { +func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStart time.Time, preTxBAL *bal.ConstructionBlockAccessList, accessList *bal.AccessListReader, statedb *state.StateDB, results []txExecResult) *ProcessResultWithMetrics { tExec := time.Since(tExecStart) tPostprocessStart := time.Now() header := block.Header() - vmContext := NewEVMBlockContext(header, p.chain, nil) + // The post-execution changes are recorded at the BAL index immediately + // following the last transaction. lastBALIdx := len(block.Transactions()) + 1 - postTxState := statedb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, lastBALIdx)) + postTxState := statedb.WithReader(state.NewReaderWithAccessList(statedb.Reader(), accessList, lastBALIdx)) - cfg := vm.Config{ - NoBaseFee: p.vmCfg.NoBaseFee, - EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, - ExtraEips: slices.Clone(p.vmCfg.ExtraEips), - } - evm := vm.NewEVM(vmContext, postTxState, p.chainConfig(), cfg) + evm := vm.NewEVM(NewEVMBlockContext(header, p.chain, nil), postTxState, p.chainConfig(), p.execVMConfig()) // 1. order the receipts by tx index // 2. correctly calculate the cumulative gas used per receipt, returning bad block error if it goes over the allowed @@ -71,10 +84,10 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar sumRegular uint64 sumState uint64 cumulativeReceipt uint64 // cumulative receipt gas (what users pay) - ) - var allLogs []*types.Log - var allReceipts []*types.Receipt + allLogs []*types.Log + allReceipts []*types.Receipt + ) for _, result := range results { sumRegular += result.txRegular sumState += result.txState @@ -87,24 +100,19 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar // Block gas = max(sum_regular, sum_state) per EIP-8037. blockGasUsed := max(sumRegular, sumState) if blockGasUsed > header.GasLimit { - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: fmt.Errorf("gas limit exceeded")}, - } + return errResult(fmt.Errorf("gas limit exceeded")) } - requests, postBal, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(len(block.Transactions())+1)) + requests, postBAL, err := PostExecution(context.Background(), p.chainConfig(), block.Number(), block.Time(), allLogs, evm, uint32(lastBALIdx)) if err != nil { - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: err}, - } + return errResult(err) } - p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(len(block.Transactions()))+1, postBal) + p.chain.Engine().Finalize(p.chain, block.Header(), evm.StateDB, block.Body(), uint32(lastBALIdx), postBAL) blockAccessList := bal.NewConstructionBlockAccessList() - blockAccessList.Merge(preTxBal) - blockAccessList.Merge(postBal) - + blockAccessList.Merge(preTxBAL) + blockAccessList.Merge(postBAL) for _, res := range results { blockAccessList.Merge(res.blockAccessList) } @@ -112,9 +120,7 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar // TODO: do we move validation to ValidateState? if block.AccessList().Hash() != blockAccessList.ToEncodingObj().Hash() { // TODO: expose json string method on encoding block access list and log it here - return &ProcessResultWithMetrics{ - ProcessResult: &ProcessResult{Error: fmt.Errorf("invalid block access list: mismatch between local and remote block access list")}, - } + return errResult(fmt.Errorf("invalid block access list: mismatch between local and remote block access list")) } tPostprocess := time.Since(tPostprocessStart) @@ -133,11 +139,9 @@ func (p *ParallelStateProcessor) prepareExecResult(block *types.Block, tExecStar } type txExecResult struct { - idx int // transaction index - receipt *types.Receipt - err error // non-EVM error which would render the block invalid - blockGas uint64 - execGas uint64 + receipt *types.Receipt + err error // non-EVM error which would render the block invalid + execGas uint64 // gas reported on the receipt (what the user pays) // Per-tx dimensional gas for Amsterdam 2D gas accounting (EIP-8037). txRegular uint64 @@ -151,38 +155,40 @@ type txExecResult struct { func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal.ConstructionBlockAccessList, prepared *bal.AccessListReader, statedb *state.StateDB, tExecStart time.Time, txResCh <-chan txExecResult, stateRootCalcResCh <-chan stateRootCalculationResult, resCh chan *ProcessResultWithMetrics) { // 1. if the block has transactions, receive the execution results from all of them and return an error on resCh if any txs err'd // 2. once all txs are executed, compute the post-tx state transition and produce the ProcessResult sending it on resCh (or an error if the post-tx state didn't match what is reported in the BAL) - var results []txExecResult - var cumulativeStateGas, cumulativeRegularGas uint64 - var execErr error - var numTxComplete int + var ( + results []txExecResult + cumulativeStateGas uint64 + cumulativeRegularGas uint64 + execErr error + ) - if len(block.Transactions()) > 0 { - loop: - for { - select { - case res := <-txResCh: - numTxComplete++ - if execErr == nil { - // short-circuit if invalid block was detected - if res.err != nil { - execErr = res.err - } else if bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState); bottleneck > block.GasLimit() { - execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) - } else { - cumulativeStateGas += res.txState - results = append(results, res) - } - } - if numTxComplete == len(block.Transactions()) { - break loop + if numTx := len(block.Transactions()); numTx > 0 { + for completed := 0; completed < numTx; completed++ { + res := <-txResCh + if execErr != nil { + // A block-invalidating result was already seen; keep draining so + // the worker goroutines don't block on their sends. + continue + } + switch { + case res.err != nil: + execErr = res.err + default: + bottleneck := max(cumulativeRegularGas+res.txRegular, cumulativeStateGas+res.txState) + if bottleneck > block.GasLimit() { + execErr = fmt.Errorf("block used too much gas in bottleneck dimension: %d. block gas limit is %d", bottleneck, block.GasLimit()) + continue } + cumulativeRegularGas += res.txRegular + cumulativeStateGas += res.txState + results = append(results, res) } } if execErr != nil { - // Drain stateRootCalcResCh so calcAndVerifyRoot goroutine can exit. + // Drain stateRootCalcResCh so the calcAndVerifyRoot goroutine can exit. <-stateRootCalcResCh - resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: execErr}} + resCh <- errResult(execErr) return } } @@ -190,11 +196,12 @@ func (p *ParallelStateProcessor) resultHandler(block *types.Block, preTxBAL *bal execResults := p.prepareExecResult(block, tExecStart, preTxBAL, prepared, statedb, results) rootCalcRes := <-stateRootCalcResCh - if execResults.ProcessResult.Error != nil { + switch { + case execResults.ProcessResult.Error != nil: resCh <- execResults - } else if rootCalcRes.err != nil { - resCh <- &ProcessResultWithMetrics{ProcessResult: &ProcessResult{Error: rootCalcRes.err}} - } else { + case rootCalcRes.err != nil: + resCh <- errResult(rootCalcRes.err) + default: execResults.StateTransitionMetrics = rootCalcRes.metrics resCh <- execResults } @@ -213,107 +220,88 @@ func (p *ParallelStateProcessor) calcAndVerifyRoot(block *types.Block, stateTran res := stateRootCalculationResult{ metrics: stateTransition.Metrics(), } - if root != block.Root() { res.err = fmt.Errorf("state root mismatch. local: %x. remote: %x", root, block.Root()) } resCh <- res } -// execTx executes single transaction returning a result which includes state accessed/modified +// execTx executes a single transaction returning a result which includes state accessed/modified. func (p *ParallelStateProcessor) execTx(block *types.Block, tx *types.Transaction, balIdx int, db *state.StateDB, signer types.Signer) *txExecResult { header := block.Header() - context := NewEVMBlockContext(header, p.chain, nil) - - cfg := vm.Config{ - NoBaseFee: p.vmCfg.NoBaseFee, - EnablePreimageRecording: p.vmCfg.EnablePreimageRecording, - ExtraEips: slices.Clone(p.vmCfg.ExtraEips), - } - evm := vm.NewEVM(context, db, p.chainConfig(), cfg) + evmContext := NewEVMBlockContext(header, p.chain, nil) + evm := vm.NewEVM(evmContext, db, p.chainConfig(), p.execVMConfig()) msg, err := TransactionToMessage(tx, signer, header.BaseFee) if err != nil { - err = fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) - return &txExecResult{err: err} + return &txExecResult{err: fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err)} } - gp := NewGasPool(block.GasLimit()) sender, err := signer.Sender(tx) if err != nil { - // TODO: can this even happen at this stage? - err = fmt.Errorf("could not recover sender for tx at bal idx %d: %v\n", balIdx, err) + return &txExecResult{err: fmt.Errorf("could not recover sender for tx at bal idx %d: %w", balIdx, err)} } + + gp := NewGasPool(block.GasLimit()) // TODO: make precompiled addresses be resolvable from chain config + block db.Prepare(evm.GetRules(), sender, block.Coinbase(), tx.To(), vm.PrecompiledAddressesCancun, tx.AccessList()) - db.SetTxContext(tx.Hash(), balIdx-1, uint32(balIdx)) - receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), context.Time, tx, evm) + receipt, txBAL, err := ApplyTransactionWithEVM(msg, gp, db, block.Number(), block.Hash(), evmContext.Time, tx, evm) if err != nil { - err := fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err) - return &txExecResult{err: err} + return &txExecResult{err: fmt.Errorf("could not apply tx %d [%v]: %w", balIdx, tx.Hash().Hex(), err)} } return &txExecResult{ - idx: balIdx, receipt: receipt, execGas: receipt.GasUsed, - blockGas: gp.Used(), txRegular: gp.cumulativeRegular, txState: gp.cumulativeState, blockAccessList: txBAL, } } -func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) (*bal.ConstructionBlockAccessList, error) { - var ( - header = block.Header() - ) - vmContext := NewEVMBlockContext(header, p.chain, nil) - evm := vm.NewEVM(vmContext, statedb, p.chainConfig(), cfg) - - accessList := PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) - return accessList, nil +func (p *ParallelStateProcessor) processBlockPreTx(block *types.Block, statedb *state.StateDB, cfg vm.Config) *bal.ConstructionBlockAccessList { + header := block.Header() + evm := vm.NewEVM(NewEVMBlockContext(header, p.chain, nil), statedb, p.chainConfig(), cfg) + return PreExecution(context.Background(), block.BeaconRoot(), block.ParentHash(), p.chainConfig(), evm, block.Number(), block.Time()) } // Process performs EVM execution and state root computation for a block which is known // to contain an access list. func (p *ParallelStateProcessor) Process(block *types.Block, stateTransition *state.BALStateTransition, statedb *state.StateDB, cfg vm.Config) (*ProcessResultWithMetrics, error) { + header := block.Header() + signer := types.MakeSigner(p.chainConfig(), header.Number, header.Time) + var ( - header = block.Header() resCh = make(chan *ProcessResultWithMetrics) - signer = types.MakeSigner(p.chainConfig(), header.Number, header.Time) rootCalcResultCh = make(chan stateRootCalculationResult) txResCh = make(chan txExecResult) - - pStart = time.Now() - tExecStart time.Time - tPreprocess time.Duration // time to create a set of prestates for parallel transaction execution ) + // Pre-transaction processing: system-contract updates and the pre-tx BAL. + pStart := time.Now() startingState := statedb.Copy() prepared := stateTransition.PreparedAccessList() - preTxBal, err := p.processBlockPreTx(block, statedb, cfg) - if err != nil { - return nil, err - } + preTxBAL := p.processBlockPreTx(block, statedb, cfg) + tPreprocess := time.Since(pStart) - // compute the reads/mutations at the last bal index - tPreprocess = time.Since(pStart) + // Execute transactions and the state-root calculation in parallel. + tExecStart := time.Now() + go p.resultHandler(block, preTxBAL, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) - // execute transactions and state root calculation in parallel - tExecStart = time.Now() - go p.resultHandler(block, preTxBal, prepared, statedb, tExecStart, txResCh, rootCalcResultCh, resCh) + // Workers execute transactions concurrently against per-tx state copies. + // Each worker reports completion (and any block-invalidating error) on + // txResCh, which resultHandler drains. Worker errors therefore flow through + // the channel rather than the errgroup, so the group is used purely to bound + // concurrency and Wait() is intentionally not called. var workers errgroup.Group workers.SetLimit(runtime.NumCPU()) - for i, t := range block.Transactions() { - tx := t - idx := i - sdb := startingState.Copy() + for i, tx := range block.Transactions() { + balIdx := i + 1 + prestate := startingState.Copy() workers.Go(func() error { - startingState := sdb.WithReader(state.NewReaderWithPreparedAccessList(statedb.Reader(), prepared, idx+1)) - res := p.execTx(block, tx, idx+1, startingState, signer) - txResCh <- *res + prestate = prestate.WithReader(state.NewReaderWithAccessList(statedb.Reader(), prepared, balIdx)) + txResCh <- *p.execTx(block, tx, balIdx, prestate, signer) return nil }) } diff --git a/core/state/reader_eip_7928.go b/core/state/reader_eip_7928.go index b89636870d..72d9672b19 100644 --- a/core/state/reader_eip_7928.go +++ b/core/state/reader_eip_7928.go @@ -220,10 +220,10 @@ type ReaderWithBlockLevelAccessList struct { TxIndex int } -// NewReaderWithPreparedAccessList wraps a base reader with a shared, already +// NewReaderWithAccessList wraps a base reader with a shared, already // preprocessed access list. This is the cheap constructor used on the hot path: // the prepared list is built once per block and borrowed by every per-tx reader. -func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { +func NewReaderWithAccessList(base Reader, prepared *bal.AccessListReader, txIndex int) *ReaderWithBlockLevelAccessList { return &ReaderWithBlockLevelAccessList{ Reader: base, prepared: prepared, @@ -232,10 +232,10 @@ func NewReaderWithPreparedAccessList(base Reader, prepared *bal.AccessListReader } // NewReaderWithBlockLevelAccessList wraps a base reader with a raw access list, -// preprocessing it on the spot. Prefer NewReaderWithPreparedAccessList when the +// preprocessing it on the spot. Prefer NewReaderWithAccessList when the // prepared list can be built once and shared across multiple readers. func NewReaderWithBlockLevelAccessList(base Reader, accessList bal.BlockAccessList, txIndex int) *ReaderWithBlockLevelAccessList { - return NewReaderWithPreparedAccessList(base, bal.NewAccessListReader(accessList), txIndex) + return NewReaderWithAccessList(base, bal.NewAccessListReader(accessList), txIndex) } // Account implements Reader, returning the account with the specific address. From ff799721ff52c677d8c67a517a77fd14569858d2 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 12:59:53 +0200 Subject: [PATCH 10/16] core/vm: align with current spec (claude) All these should be removed once we updated the spec --- build/checksums.txt | 6 ++--- core/state_transition.go | 17 ++++++------ core/vm/contract.go | 4 +-- core/vm/eips.go | 2 ++ core/vm/evm.go | 44 ++++++++++++------------------- core/vm/gas_table.go | 56 ++++++++++++++++++++++++++++++++++++++++ core/vm/gascosts.go | 42 ++++++++++-------------------- core/vm/instructions.go | 26 ++++++++++++++----- 8 files changed, 123 insertions(+), 74 deletions(-) diff --git a/build/checksums.txt b/build/checksums.txt index d6a9f2b803..e6f4d3857e 100644 --- a/build/checksums.txt +++ b/build/checksums.txt @@ -5,10 +5,10 @@ # https://github.com/ethereum/execution-spec-tests/releases/download/v5.1.0 a3192784375acec7eaec492799d5c5d0c47a2909a3cc40178898e4ecd20cc416 fixtures_develop.tar.gz -# version:spec-tests-bal v7.2.0 +# version:spec-tests-bal v7.3.1 # https://github.com/ethereum/execution-specs/releases -# https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0 -fc1d9ae174cdd5db789068839999e6f83666cc79f7dac36e973d7616d9a2e2cf fixtures_bal.tar.gz +# https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.1 +3c9bd8799a506a96f74162863efdf5eaa00226e645db6523346fdb7c5ba0bf62 fixtures_bal.tar.gz # version:golang 1.25.10 # https://go.dev/dl/ diff --git a/core/state_transition.go b/core/state_transition.go index dac8123530..8dae706c8b 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -674,11 +674,6 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { ret []byte vmerr error // vm errors do not effect consensus and are therefore not assigned to err result vm.GasBudget - - // Capture the forwarded regular-gas amount BEFORE ForwardAll consumes - // it, so Absorb can back out state-gas spillover from UsedRegularGas - // per EIP-8037. - forwarded = st.gasRemaining.RegularGas ) if contractCreation { // Check whether the init code size has been exceeded. @@ -687,7 +682,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } // Execute the transaction's creation. ret, _, result, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) - st.gasRemaining.Absorb(result, forwarded) + st.gasRemaining.Absorb(result) // If the contract creation failed, refund the account-creation state // gas pre-charged in IntrinsicGas. @@ -711,7 +706,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } // Execute the transaction's call. ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) - st.gasRemaining.Absorb(result, forwarded) + st.gasRemaining.Absorb(result) } // Settle down the gas usage and refund the ETH back if any remaining @@ -821,7 +816,13 @@ func (st *stateTransition) settleGas(rules params.Rules, floorDataGas uint64) (g } if rules.IsAmsterdam { - if err = st.gp.ChargeGasAmsterdam(txRegularGas, txStateGas, gasUsed); err != nil { + // EIP-7623/7976: the calldata floor applies to the block-level regular + // gas dimension as well, mirroring its effect on the receipt gas. The + // spec accumulates max(tx_regular_gas, calldata_floor) into + // block_gas_used, so the block must never count fewer regular units + // than the floor the sender was charged. + blockRegularGas := max(txRegularGas, floorDataGas) + if err = st.gp.ChargeGasAmsterdam(blockRegularGas, txStateGas, gasUsed); err != nil { return 0, 0, err } } else { diff --git a/core/vm/contract.go b/core/vm/contract.go index 9155e9f84a..8b4114b84d 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -153,9 +153,9 @@ func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.G } // refundGas absorbs a sub-call's leftover GasBudget into this contract's gas state. -func (c *Contract) refundGas(child GasBudget, forwarded uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { +func (c *Contract) refundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { prior := c.Gas - c.Gas.Absorb(child, forwarded) + c.Gas.Absorb(child) if logger.HasGasHook() && reason != tracing.GasChangeIgnored { logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } diff --git a/core/vm/eips.go b/core/vm/eips.go index ba7cbd7461..82ccf33df0 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -596,6 +596,8 @@ func enable7843(jt *JumpTable) { func enable8037(jt *JumpTable) { jt[CREATE].constantGas = params.CreateGasAmsterdam jt[CREATE2].constantGas = params.CreateGasAmsterdam + jt[CREATE].dynamicGas = gasCreateEip8037 + jt[CREATE2].dynamicGas = gasCreate2Eip8037 jt[SELFDESTRUCT].dynamicGas = gasSelfdestruct8037 jt[SSTORE].dynamicGas = gasSStore8037 } diff --git a/core/vm/evm.go b/core/vm/evm.go index e66dace3e0..7133047d6f 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -271,7 +271,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() p, isPrecompile := evm.precompile(addr) if !evm.StateDB.Exist(addr) { if !isPrecompile && evm.chainRules.IsEIP4762 && !isSystemCall(caller) { @@ -285,7 +285,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, gas.ExitHalt(), ErrOutOfGas } } @@ -299,7 +299,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) if !ok { evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, gas.ExitHalt(), ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) @@ -330,7 +330,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -372,7 +372,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -387,7 +387,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -418,7 +418,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -431,7 +431,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -465,7 +465,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b // after all empty accounts were deleted, so this is not required. However, if we omit this, // then certain tests start failing; stRevertTest/RevertPrecompiledTouchExactOOG.json. // We could change this, but for now it's left for legacy reasons - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // We do an AddBalance of zero here, just in order to trigger a touch. // This doesn't matter on Mainnet, where all empties are gone at the time of Byzantium, @@ -483,7 +483,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { @@ -521,14 +521,13 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value } // Increment the caller's nonce after passing all validations evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) - reservoir := gas.StateGas // Charge the contract creation init gas in verkle mode if evm.chainRules.IsEIP4762 { statelessGas := evm.AccessEvents.ContractCreatePreCheckGas(address, gas.RegularGas) prior, ok := gas.Charge(GasCosts{RegularGas: statelessGas}) if !ok { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -549,7 +548,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) || // non-empty code isEIP7610RejectedAccount(evm.ChainConfig().ChainID, address, evm.chainRules.IsEIP158) { - halt := gas.ExitHalt(reservoir) + halt := gas.ExitHalt() if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } @@ -563,18 +562,9 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value snapshot := evm.StateDB.Snapshot() if !evm.StateDB.Exist(address) { evm.StateDB.CreateAccount(address) - - if evm.chainRules.IsAmsterdam && evm.depth > 0 { - // Only charge state gas if we are not doing a create transaction. - // Prevents double charging with IntrinsicGas. - prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) - if !ok { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas - } - if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) - } - } + // EIP-8037: the account-creation state gas is charged before the + // opcode runs (gasCreateEip8037) for CREATE/CREATE2 opcodes, and in + // IntrinsicGas for creation transactions, so there is no charge here. } // CreateContract means that regardless of whether the account previously existed // in the state trie or not, it _now_ becomes created as a _contract_ account. @@ -589,7 +579,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.ContractCreateInitGas(address, gas.RegularGas) if consumed < wanted { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -614,7 +604,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) - exit := contract.Gas.Exit(err, reservoir) + exit := contract.Gas.Exit(err) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 550375c9c0..5cf64a9844 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -367,6 +367,62 @@ func gasCreate2Eip3860(evm *EVM, contract *Contract, stack *Stack, mem *Memory, return GasCosts{RegularGas: gas}, nil } +// gasCreateEip8037 is the CREATE gas calculator for Amsterdam. It charges the +// account-creation cost as state gas (EIP-8037) here, before the opcode runs, +// so the 63/64 gas-forwarding split sees the post-charge regular gas. The +// charge is refunded to the reservoir in opCreate on any failure path that +// does not create an account. +func gasCreateEip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow. + wordGas := params.InitCodeWordGas * ((size + 31) / 32) + stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: stateGas, + }, nil +} + +// gasCreate2Eip8037 is the CREATE2 gas calculator for Amsterdam. See +// gasCreateEip8037; CREATE2 additionally charges Keccak256WordGas for hashing +// the init code. +func gasCreate2Eip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow. + wordGas := (params.InitCodeWordGas + params.Keccak256WordGas) * ((size + 31) / 32) + stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: stateGas, + }, nil +} + func gasExpFrontier(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { expByteLen := uint64((stack.back(1).BitLen() + 7) / 8) diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index b1756ab5fe..98130b9ce5 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -226,9 +226,11 @@ func (g GasBudget) ExitRevert() GasBudget { // ExitHalt produces the leftover for an exceptional halt. // -// - state_gas_reservoir is reset back to its value at the start of the child frame -// - the gas_left initially given to the child is consumed (set to zero) -func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { +// Per the updated EIP-8037, only the regular gas_left is burned (folded into +// UsedRegularGas); the entire state-gas reservoir — including any portion that +// spilled into the regular pool during execution — is refunded to the caller's +// reservoir rather than reclassified as burned regular gas. +func (g GasBudget) ExitHalt() GasBudget { reservoir := int64(g.StateGas) + g.UsedStateGas if reservoir < 0 { // Reservoir should never be negative. By construction it equals @@ -237,17 +239,10 @@ func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { reservoir = 0 log.Warn("Negative reservoir at halt", "remaining", g.StateGas, "used", g.UsedStateGas) } - // The portion of state gas charged from regular gas is also burned - // together with the regular gas, rather than being returned to the - // parent's state-gas reservoir. - var spilled uint64 - if uint64(reservoir) > initStateReservoir { - spilled = uint64(reservoir) - initStateReservoir - } return GasBudget{ RegularGas: 0, - StateGas: initStateReservoir, - UsedRegularGas: g.UsedRegularGas + g.RegularGas + spilled, + StateGas: uint64(reservoir), + UsedRegularGas: g.UsedRegularGas + g.RegularGas, UsedStateGas: 0, } } @@ -261,33 +256,24 @@ func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { // // Soft validation failures (occurring BEFORE evm.Run) should call Preserved // directly instead of going through this dispatcher. -func (g GasBudget) Exit(err error, initStateReservoir uint64) GasBudget { +func (g GasBudget) Exit(err error) GasBudget { switch { case err == nil: return g.ExitSuccess() case err == ErrExecutionReverted: return g.ExitRevert() default: - return g.ExitHalt(initStateReservoir) + return g.ExitHalt() } } // Absorb merges a sub-call's leftover GasBudget into this (caller's) running -// budget. Additionally, it does an EIP-8037 spillover correction: -// state-gas that spilled into the regular pool inside the child frame is -// excluded from the UsedRegularGas. -// -// spillover = forwarded - child.RegularGas - child.UsedRegularGas -// -// forwarded is the regular-gas amount that was passed to the child at call -// entry (i.e., the regular initial of the child's GasBudget). -func (g *GasBudget) Absorb(child GasBudget, forwarded uint64) { - spillover := forwarded - child.RegularGas - child.UsedRegularGas - - g.UsedRegularGas -= child.RegularGas +// budget. Under the updated EIP-8037, state-gas no longer spills into the +// child's burned regular gas on halt, so the child's UsedRegularGas can be +// folded in directly without a spillover correction. +func (g *GasBudget) Absorb(child GasBudget) { g.RegularGas += child.RegularGas + g.UsedRegularGas += child.UsedRegularGas g.StateGas = child.StateGas g.UsedStateGas += child.UsedStateGas - - g.UsedRegularGas -= spillover } diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 92c363a356..672a731358 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -677,7 +677,14 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + + // EIP-8037: no account was created on any failure path, so refund the + // account-creation state gas charged before the opcode ran (gasCreateEip8037) + // back to the reservoir. + if evm.chainRules.IsAmsterdam && suberr != nil { + scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer @@ -711,7 +718,14 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + + // EIP-8037: no account was created on any failure path, so refund the + // account-creation state gas charged before the opcode ran (gasCreate2Eip8037) + // back to the reservoir. + if evm.chainRules.IsAmsterdam && suberr != nil { + scope.Contract.Gas.RefundState(params.AccountCreationSize * evm.Context.CostPerStateByte) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer @@ -756,7 +770,7 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -792,7 +806,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -824,7 +838,7 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -857,7 +871,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil From ed7c83a28cd1b31a8acc9220007119ab5bd4467d Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 13:50:22 +0200 Subject: [PATCH 11/16] core/vm: align with current spec (claude) All these should be removed once we updated the spec --- core/state_transition.go | 34 ++++++++++++++++++++------ core/vm/evm.go | 52 ++++++++++++++++++++++++++++------------ core/vm/gas_table.go | 16 +++++++++++++ core/vm/gascosts.go | 20 ++++++++++++++++ 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/core/state_transition.go b/core/state_transition.go index 8dae706c8b..0b0b34d59f 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -352,6 +352,7 @@ type stateTransition struct { gp *GasPool msg *Message gasRemaining vm.GasBudget + initReservoir uint64 // initial state-gas reservoir carved out of GasLimit (EIP-8037) state vm.StateDB evm *vm.EVM } @@ -462,7 +463,8 @@ func (st *stateTransition) buyGas() error { if isAmsterdam { limit = min(st.msg.GasLimit, params.MaxTxGas) } - st.gasRemaining = vm.NewGasBudget(limit, st.msg.GasLimit-limit) + st.initReservoir = st.msg.GasLimit - limit + st.gasRemaining = vm.NewGasBudget(limit, st.initReservoir) if st.evm.Config.Tracer.HasGasHook() { st.evm.Config.Tracer.EmitGasChange(tracing.Gas{}, st.gasRemaining.AsTracing(), tracing.GasChangeTxInitialBalance) @@ -683,12 +685,6 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { // Execute the transaction's creation. ret, _, result, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) st.gasRemaining.Absorb(result) - - // If the contract creation failed, refund the account-creation state - // gas pre-charged in IntrinsicGas. - if rules.IsAmsterdam && vmerr != nil { - st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) - } } else { // Increment the nonce for the next transaction. st.state.SetNonce(msg.From, st.state.GetNonce(msg.From)+1, tracing.NonceChangeEoACall) @@ -709,6 +705,29 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { st.gasRemaining.Absorb(result) } + // EIP-8037 (fork.py:1086): on any transaction error, the state gas + // consumed during *execution* is discarded — those state changes are + // reverted, so the charge is restored to the reservoir and not counted + // toward block_state_gas_used. The intrinsic state gas (CREATE new-account + // and EIP-7702 authorization charges) is tracked separately by the spec and + // is NOT discarded here; the CREATE new-account portion is refunded above + // via its dedicated RefundState. The frame-level Exit forms already refund + // state gas on a reverting/halting sub-call, but a top-level frame that + // ends in a code-deposit halt (or any other tx-level vmerr) can leave + // accumulated execution UsedStateGas that must be discarded here. + if rules.IsAmsterdam && vmerr != nil { + executionStateGas := st.gasRemaining.UsedStateGas - int64(cost.StateGas) + if executionStateGas > 0 { + st.gasRemaining.RefundState(uint64(executionStateGas)) + } + // Additionally, a failed CREATE transaction refunds the intrinsic + // account-creation state gas pre-charged in IntrinsicGas (fork.py:1093: + // when tx.to is Bytes0 the NEW_ACCOUNT charge is added to state_refund). + if contractCreation { + st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) + } + } + // Settle down the gas usage and refund the ETH back if any remaining gasUsed, peakUsed, err := st.settleGas(rules, floorDataGas) if err != nil { @@ -781,6 +800,7 @@ func (st *stateTransition) settleGas(rules params.Rules, floorDataGas uint64) (g if st.gasRemaining.UsedStateGas < 0 { return 0, 0, fmt.Errorf("negative topmost frame state gas usage, %d", st.gasRemaining.UsedStateGas) } + txStateGas := uint64(st.gasRemaining.UsedStateGas) // EIP-8037: diff --git a/core/vm/evm.go b/core/vm/evm.go index 7133047d6f..74b51716e0 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -295,7 +295,14 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } evm.StateDB.CreateAccount(addr) } - if evm.chainRules.IsAmsterdam && !value.IsZero() && evm.StateDB.Empty(addr) { + // EIP-8037: a value-bearing CALL to an empty account pays NEW_ACCOUNT state + // gas. For nested calls this is charged on the caller frame by the dynamic + // gas table (gasCallIntrinsic), matching the spec's inline charge_state_gas + // in system.call. Only the top-most call (depth 0) — which is dispatched + // straight to evm.Call without passing through that gas table — needs the + // charge applied here, against the forwarded budget. Charging in both places + // would double-count the new account. + if evm.depth == 0 && evm.chainRules.IsAmsterdam && !value.IsZero() && evm.StateDB.Empty(addr) { prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) if !ok { evm.StateDB.RevertToSnapshot(snapshot) @@ -597,14 +604,23 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value contract.SetCallCode(common.Hash{}, code) contract.IsDeployment = true - ret, err = evm.initNewContract(contract, address) + var depositHalt bool + ret, depositHalt, err = evm.initNewContract(contract, address) // Special case: ErrCodeStoreOutOfGas pre-Homestead does NOT roll back // state and gas is preserved (i.e., treated as success). if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) - exit := contract.Gas.Exit(err) + // EIP-8037: a code-deposit halt (initcode body succeeded, deposit step + // failed) keeps the state gas the body consumed for discard at the tx + // level, rather than refunding the reservoir like a mid-execution halt. + var exit GasBudget + if depositHalt && evm.chainRules.IsAmsterdam { + exit = contract.Gas.ExitCodeDepositHalt() + } else { + exit = contract.Gas.Exit(err) + } if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) @@ -619,54 +635,60 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value // initNewContract runs a new contract's creation code, performs checks on the // resulting code that is to be deployed, and consumes necessary gas. -func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]byte, error) { - ret, err := evm.Run(contract, nil, false) +// +// The returned depositHalt flag is true when the initcode body itself ran to +// completion successfully but a subsequent code-deposit check failed (oversized +// code, 0xEF prefix, or insufficient gas for the hash/deposit charge). Under +// EIP-8037 this halt is metered differently from a mid-execution halt: the +// state gas consumed by the (successful) body is kept rather than refunded. +func (evm *EVM) initNewContract(contract *Contract, address common.Address) (ret []byte, depositHalt bool, err error) { + ret, err = evm.Run(contract, nil, false) if err != nil { - return ret, err + return ret, false, err } // Check prefix before gas calculation. // Reject code starting with 0xEF if EIP-3541 is enabled. if len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon { - return ret, ErrInvalidCode + return ret, true, ErrInvalidCode } if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.CodeChunksRangeGas(address, 0, uint64(len(ret)), uint64(len(ret)), true, contract.Gas.RegularGas) contract.chargeRegular(consumed, evm.Config.Tracer, tracing.GasChangeWitnessCodeChunk) if len(ret) > 0 && (consumed < wanted) { - return ret, ErrCodeStoreOutOfGas + return ret, true, ErrCodeStoreOutOfGas } if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { - return ret, err + return ret, true, err } } else if evm.chainRules.IsAmsterdam { // Check max code size BEFORE charging gas so over-max code // does not consume state gas (which would inflate tx_state). if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { - return ret, err + return ret, true, err } // Charge regular gas (hash cost) before state gas. regularCost := toWordSize(uint64(len(ret))) * params.Keccak256WordGas if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { - return ret, ErrCodeStoreOutOfGas + return ret, true, ErrCodeStoreOutOfGas } // Charge state gas (code-deposit) afterwards. stateCost := uint64(len(ret)) * evm.Context.CostPerStateByte if !contract.chargeState(stateCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { - return ret, ErrCodeStoreOutOfGas + return ret, true, ErrCodeStoreOutOfGas } } else { createDataCost := uint64(len(ret)) * params.CreateDataGas if !contract.chargeRegular(createDataCost, evm.Config.Tracer, tracing.GasChangeCallCodeStorage) { - return ret, ErrCodeStoreOutOfGas + return ret, true, ErrCodeStoreOutOfGas } if err := CheckMaxCodeSize(&evm.chainRules, uint64(len(ret))); err != nil { - return ret, err + return ret, true, err } } if len(ret) > 0 { evm.StateDB.SetCode(address, ret, tracing.CodeChangeContractCreation) } - return ret, nil + return ret, false, nil } // Create creates a new contract using code as deployment code. diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 5cf64a9844..2e8128e108 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -21,6 +21,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/params" ) @@ -502,6 +503,21 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m return 0, ErrOutOfGas } // Stateful check + if evm.chainRules.IsAmsterdam { + // EIP-8037: the cost of creating a new account via a value-bearing + // CALL is metered as state gas (NEW_ACCOUNT * CostPerStateByte), + // not the legacy regular CallNewAccountGas. Charge it directly here + // so it drains the state reservoir (spilling into regular gas only + // when the reservoir is exhausted), mirroring the spec's inline + // charge_state_gas call in system.call. + if transfersValue && evm.StateDB.Empty(address) { + stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + if !contract.chargeState(stateGas, evm.Config.Tracer, tracing.GasChangeAccountCreation) { + return 0, ErrOutOfGas + } + } + return gas, nil + } var stateGas uint64 if evm.chainRules.IsEIP158 { if transfersValue && evm.StateDB.Empty(address) { diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index 98130b9ce5..f4469804f0 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -247,6 +247,26 @@ func (g GasBudget) ExitHalt() GasBudget { } } +// ExitCodeDepositHalt produces the leftover for a CREATE/CREATE2 frame whose +// initcode body ran to completion but then failed during the code-deposit step +// (oversized code, 0xEF prefix, or insufficient gas for the hash/deposit +// charge). Per the spec's process_create_message exception handler, this path +// differs from a mid-execution ExceptionalHalt: only the remaining regular gas +// is burned, while the state gas the (successful) body consumed is KEPT in +// UsedStateGas rather than refunded to the reservoir. The state changes are +// reverted by the caller, but the state-gas accounting is propagated upward so +// the top-level tx settlement can discard it as the state dimension (rather +// than folding it back into the combined balance, which would wrongly deflate +// the regular dimension). +func (g GasBudget) ExitCodeDepositHalt() GasBudget { + return GasBudget{ + RegularGas: 0, + StateGas: g.StateGas, + UsedRegularGas: g.UsedRegularGas + g.RegularGas, + UsedStateGas: g.UsedStateGas, + } +} + // Exit dispatches on err to the appropriate exit-form constructor // for the post-evm.Run path: // From 1e456b20f047b29a78e4b670b6fffad9911d712c Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 13:53:48 +0200 Subject: [PATCH 12/16] core/vm: align with current spec (claude), do not charge for invalid auth --- core/state_transition.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/state_transition.go b/core/state_transition.go index 0b0b34d59f..6947f283b4 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -907,9 +907,12 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, delegates map[common.Address]bool) error { authority, err := st.validateAuthorization(auth) if err != nil { - if rules.IsAmsterdam { - st.gasRemaining.RefundState((params.AccountCreationSize + params.AuthorizationCreationSize) * st.evm.Context.CostPerStateByte) - } + // EIP-8037 (spec apply_authorization): an invalid authorization is + // skipped without any state-gas refund. The per-auth intrinsic state + // charge ((NEW_ACCOUNT + AUTH_BASE) * CPSB) was levied for every + // authorization in the list regardless of validity, and only a + // successfully-applied authorization that avoids creating new state + // earns a refund below. Invalid auths therefore pay in full. return err } prevDelegation, curDelegated := types.ParseDelegation(st.state.GetCode(authority)) From 175feb379878ee3e84afffa1c9ea5ec8333471e5 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 14:08:08 +0200 Subject: [PATCH 13/16] core/vm: make inclusion criteria more strict --- core/state_transition.go | 52 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/core/state_transition.go b/core/state_transition.go index 6947f283b4..2015f1795f 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -349,12 +349,12 @@ func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, err // 5. Run Script section // 6. Derive new state root type stateTransition struct { - gp *GasPool - msg *Message - gasRemaining vm.GasBudget + gp *GasPool + msg *Message + gasRemaining vm.GasBudget initReservoir uint64 // initial state-gas reservoir carved out of GasLimit (EIP-8037) - state vm.StateDB - evm *vm.EVM + state vm.StateDB + evm *vm.EVM } // newStateTransition initialises and returns a new state transition object. @@ -393,7 +393,7 @@ func (st *stateTransition) to() common.Address { // - Amsterdam+ (EIP-8037): two-dimensional budget. Regular gas is // capped at `MaxTxGas` (EIP-7825, 16_777_216); any excess from // `msg.GasLimit` above that cap becomes the state-gas reservoir. -func (st *stateTransition) buyGas() error { +func (st *stateTransition) buyGas(intrinsic vm.GasCosts) error { mgval := new(uint256.Int).SetUint64(st.msg.GasLimit) _, overflow := mgval.MulOverflow(mgval, st.msg.GasPrice) if overflow { @@ -447,10 +447,20 @@ func (st *stateTransition) buyGas() error { } isAmsterdam := st.evm.ChainConfig().IsAmsterdam(st.evm.Context.BlockNumber, st.evm.Context.Time) - // Reserve the gas budget in the block gas pool + // Reserve the gas budget in the block gas pool. This block-inclusion check + // must run before the sender's balance is debited below, so it cannot be + // deferred past buyGas. var err error if isAmsterdam { - err = st.gp.CheckGasAmsterdam(min(st.msg.GasLimit, params.MaxTxGas), st.msg.GasLimit) + // EIP-8037 per-tx 2D block-inclusion check (fork.py): the worst-case + // regular contribution is min(MaxTxGas, tx.gas - intrinsic.state) and + // the worst-case state contribution is tx.gas - intrinsic.regular. + // Each dimension subtracts the other's intrinsic counterpart. The + // intrinsic gas is computed once by execute() and passed in, so it is + // shared with the charge below rather than recomputed. + regularReservation := min(st.msg.GasLimit-min(st.msg.GasLimit, intrinsic.StateGas), params.MaxTxGas) + stateReservation := st.msg.GasLimit - min(st.msg.GasLimit, intrinsic.RegularGas) + err = st.gp.CheckGasAmsterdam(regularReservation, stateReservation) } else { err = st.gp.CheckGasLegacy(st.msg.GasLimit) } @@ -493,7 +503,7 @@ func (st *stateTransition) buyGas() error { // // The SkipNonceChecks / SkipTransactionChecks / NoBaseFee flags bypass // subsets of these checks for simulation paths (eth_call, eth_estimateGas). -func (st *stateTransition) preCheck() error { +func (st *stateTransition) preCheck(intrinsic vm.GasCosts) error { // Only check transactions that are not fake msg := st.msg if !msg.SkipNonceChecks { @@ -587,7 +597,7 @@ func (st *stateTransition) preCheck() error { return fmt.Errorf("%w (sender %v)", ErrEmptyAuthList, msg.From) } } - return st.buyGas() + return st.buyGas(intrinsic) } // execute transitions the state by applying the current message and @@ -602,14 +612,10 @@ func (st *stateTransition) preCheck() error { // If a consensus error is encountered, it is returned directly with a // nil EVM execution result. func (st *stateTransition) execute() (*ExecutionResult, error) { - // Validate the message and pre-pay gas. - if err := st.preCheck(); err != nil { - return nil, err - } - - // Charge intrinsic gas (with overflow detection inside IntrinsicGas). - // Under Amsterdam the cost is two-dimensional and Charge debits both - // regular and state in one step. + // Compute the intrinsic gas once up front. It is a pure function of the + // message and rules (no state access), and is needed both by the EIP-8037 + // block-inclusion check in preCheck/buyGas and by the intrinsic charge + // below, so it is computed here and threaded through. var ( msg = st.msg rules = st.evm.ChainConfig().Rules(st.evm.Context.BlockNumber, st.evm.Context.Random != nil, st.evm.Context.Time) @@ -620,6 +626,14 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { if err != nil { return nil, err } + + // Validate the message and pre-pay gas. + if err := st.preCheck(cost); err != nil { + return nil, err + } + + // Charge intrinsic gas. Under Amsterdam the cost is two-dimensional and + // Charge debits both regular and state in one step. prior, sufficient := st.gasRemaining.Charge(cost) if !sufficient { return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, st.gasRemaining.RegularGas, cost.RegularGas) @@ -842,7 +856,7 @@ func (st *stateTransition) settleGas(rules params.Rules, floorDataGas uint64) (g // block_gas_used, so the block must never count fewer regular units // than the floor the sender was charged. blockRegularGas := max(txRegularGas, floorDataGas) - if err = st.gp.ChargeGasAmsterdam(blockRegularGas, txStateGas, gasUsed); err != nil { + if err = st.gp.ChargeGasAmsterdam(blockRegularGas, txStateGas, gasUsed); err != nil { return 0, 0, err } } else { From ab4e75ab768d3871efd709318948752d22b05536 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 14:13:48 +0200 Subject: [PATCH 14/16] core/vm: align with current spec (claude), do not charge for invalid auth --- core/vm/evm.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/core/vm/evm.go b/core/vm/evm.go index 74b51716e0..8fd02a09ac 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -295,23 +295,6 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } evm.StateDB.CreateAccount(addr) } - // EIP-8037: a value-bearing CALL to an empty account pays NEW_ACCOUNT state - // gas. For nested calls this is charged on the caller frame by the dynamic - // gas table (gasCallIntrinsic), matching the spec's inline charge_state_gas - // in system.call. Only the top-most call (depth 0) — which is dispatched - // straight to evm.Call without passing through that gas table — needs the - // charge applied here, against the forwarded budget. Charging in both places - // would double-count the new account. - if evm.depth == 0 && evm.chainRules.IsAmsterdam && !value.IsZero() && evm.StateDB.Empty(addr) { - prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) - if !ok { - evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(), ErrOutOfGas - } - if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) - } - } // Perform the value transfer only in non-syscall mode. // Calling this is required even for zero-value transfers, // to ensure the state clearing mechanism is applied. From ee951fe471dce9786c09cbac5c7965410cffbeac Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 14:17:11 +0200 Subject: [PATCH 15/16] core/vm: align with current spec (claude), do not charge for invalid auth --- core/state_transition.go | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/core/state_transition.go b/core/state_transition.go index 2015f1795f..07d8055322 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -916,9 +916,9 @@ func (st *stateTransition) validateAuthorization(auth *types.SetCodeAuthorizatio // once, and only when the account did not exist before the tx // // - the delegation-indicator portion (AuthorizationCreationSize × CPSB) is -// charged at most once, and only when the authority ends the tx delegated -// having started it undelegated. -func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization, delegates map[common.Address]bool) error { +// refunded when this auth writes no new indicator bytes (the authority is +// already delegated, or the auth clears the delegation). +func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.SetCodeAuthorization) error { authority, err := st.validateAuthorization(auth) if err != nil { // EIP-8037 (spec apply_authorization): an invalid authorization is @@ -936,29 +936,20 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se st.state.AddRefund(params.CallNewAccountGas - params.TxAuthTupleGas) } } else { + // EIP-8037 (spec apply_authorization): refund the per-auth intrinsic + // state charge for state that does not actually get newly created. + // + // - NEW_ACCOUNT is refunded when the authority account already exists + // (account_exists), since no new account is created. if st.state.Exist(authority) { st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) } - authBase := params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte - - preDelegated, ok := delegates[authority] - if !ok { - preDelegated = curDelegated - delegates[authority] = preDelegated - } - if auth.Address == (common.Address{}) { - // Clearing writes no indicator, refill this auth's state charge. - st.gasRemaining.RefundState(authBase) - - // The indicator was created by an earlier auth within the same - // transaction, refill the state charge as it's no longer justified. - if curDelegated && !preDelegated { - st.gasRemaining.RefundState(authBase) - } - } else if curDelegated || preDelegated { - // The 23-byte slot is already occupied, overwriting it writes no - // new bytes, refill the state charge. - st.gasRemaining.RefundState(authBase) + // - AUTH_BASE is refunded when no new delegation-indicator bytes are + // written: either the authority already carries code/delegation + // (code_hash != EMPTY, i.e. curDelegated) or this auth clears the + // delegation (auth.address == 0). Exactly one refund per auth. + if curDelegated || auth.Address == (common.Address{}) { + st.gasRemaining.RefundState(params.AuthorizationCreationSize * st.evm.Context.CostPerStateByte) } } @@ -981,9 +972,8 @@ func (st *stateTransition) applyAuthorization(rules params.Rules, auth *types.Se // applyAuthorizations applies an EIP-7702 code delegation to the state. func (st *stateTransition) applyAuthorizations(rules params.Rules, auths []types.SetCodeAuthorization) { - preDelegated := make(map[common.Address]bool) for _, auth := range auths { - st.applyAuthorization(rules, &auth, preDelegated) + st.applyAuthorization(rules, &auth) } } From 9e04883a859fedaf6ac8ccdfc70a2acbe690cbd2 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Sun, 14 Jun 2026 14:35:03 +0200 Subject: [PATCH 16/16] core/vm: check that we have enough gas to pay for state gas before charge --- core/vm/gas_table.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 2e8128e108..7fcfe4f595 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -506,12 +506,16 @@ func gasCallIntrinsic(evm *EVM, contract *Contract, stack *Stack, mem *Memory, m if evm.chainRules.IsAmsterdam { // EIP-8037: the cost of creating a new account via a value-bearing // CALL is metered as state gas (NEW_ACCOUNT * CostPerStateByte), - // not the legacy regular CallNewAccountGas. Charge it directly here - // so it drains the state reservoir (spilling into regular gas only - // when the reservoir is exhausted), mirroring the spec's inline - // charge_state_gas call in system.call. + // not the legacy regular CallNewAccountGas. It drains the state + // reservoir, spilling into regular gas only when the reservoir is + // exhausted, mirroring the spec's inline charge_state_gas in + // system.call. if transfersValue && evm.StateDB.Empty(address) { stateGas := params.AccountCreationSize * evm.Context.CostPerStateByte + regularAfterCall := contract.Gas.RegularGas - gas + if stateGas > contract.Gas.StateGas && stateGas-contract.Gas.StateGas > regularAfterCall { + return 0, ErrOutOfGas + } if !contract.chargeState(stateGas, evm.Config.Tracer, tracing.GasChangeAccountCreation) { return 0, ErrOutOfGas }