diff --git a/core/blockchain.go b/core/blockchain.go index 35b2d35dc7..db26eba418 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -72,11 +72,10 @@ var ( accountReadTimer = metrics.NewRegisteredResettingTimer("chain/account/reads", nil) accountHashTimer = metrics.NewRegisteredResettingTimer("chain/account/hashes", nil) accountUpdateTimer = metrics.NewRegisteredResettingTimer("chain/account/updates", nil) - accountCommitTimer = metrics.NewRegisteredResettingTimer("chain/account/commits", nil) + hasherCommitTimer = metrics.NewRegisteredResettingTimer("chain/trie/commits", nil) storageReadTimer = metrics.NewRegisteredResettingTimer("chain/storage/reads", nil) storageUpdateTimer = metrics.NewRegisteredResettingTimer("chain/storage/updates", nil) - storageCommitTimer = metrics.NewRegisteredResettingTimer("chain/storage/commits", nil) codeReadTimer = metrics.NewRegisteredResettingTimer("chain/code/reads", nil) codeReadBytesTimer = metrics.NewRegisteredResettingTimer("chain/code/readbytes", nil) @@ -2112,30 +2111,53 @@ type ExecuteConfig struct { // 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.NewDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + err error + startTime = time.Now() + interrupt atomic.Bool + sdb = state.NewDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps) + makeWitness bool + + throwaway *state.StateDB // StateDB for speculative transaction pre-executor + statedb *state.StateDB // StateDB for sequential transaction executor ) + if bc.chainConfig.IsByzantium(block.Number()) && (config.StatelessSelfValidation || config.MakeWitness) { + makeWitness = true + } defer interrupt.Store(true) // terminate the prefetch at the end + // Enable trie node prewarming after the Byzantium fork. Before that, state + // computation occurs at transaction boundaries, making prewarming ineffective. + // The read-only state should also be prewarmed to construct a comprehensive + // execution witness. + if bc.chainConfig.IsByzantium(block.Number()) { + sdb = sdb.EnablePrefetch(makeWitness) + + // Explicitly terminate all the background prefetcher. This is essential + // to prevent goroutine leaks. + defer func() { + if statedb != nil { + statedb.StopPrefetcher() + } + if throwaway != nil { + throwaway.StopPrefetcher() + } + }() + } if bc.cfg.NoPrefetch { statedb, err = state.New(parentRoot, sdb) if err != nil { return nil, err } } else { - // If prefetching is enabled, run that against the current state to pre-cache - // transactions and probabilistically some of the account/storage trie nodes. - // - // Note: the main processor and prefetcher share the same reader with a local - // cache for mitigating the overhead of state access. + // If transaction prefetching is enabled, run that against the current state + // to pre-cache transactions. Note: the main processor and prefetcher share + // the same reader with a local cache for mitigating the overhead of state + // access. prefetch, process, err := sdb.ReadersWithCacheStats(parentRoot) if err != nil { return nil, err } - throwaway, err := state.NewWithReader(parentRoot, sdb, prefetch) + throwaway, err = state.NewWithReader(parentRoot, sdb, prefetch) if err != nil { return nil, err } @@ -2171,20 +2193,16 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, // while processing transactions. Before Byzantium the prefetcher is mostly // useless due to the intermediate root hashing after each transaction. var witness *stateless.Witness - if bc.chainConfig.IsByzantium(block.Number()) { + if makeWitness { // Generate witnesses either if we're self-testing, or if it's the // only block being inserted. A bit crude, but witnesses are huge, // so we refuse to make an entire chain of them. - if config.StatelessSelfValidation || config.MakeWitness { - witness, err = stateless.NewWitness(block.Header(), bc, config.EnableWitnessStats) - if err != nil { - return nil, err - } + witness, err = stateless.NewWitness(block.Header(), bc, config.EnableWitnessStats) + if err != nil { + return nil, err } - statedb.StartPrefetcher("chain", witness) - defer statedb.StopPrefetcher() + statedb.TraceWitness(witness) } - // Instrument the blockchain tracing if config.EnableTracer { if bc.logger != nil && bc.logger.OnBlockStart != nil { @@ -2222,64 +2240,10 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, } vtime := time.Since(vstart) - // If witnesses was generated and stateless self-validation requested, do - // that now. Self validation should *never* run in production, it's more of - // a tight integration to enable running *all* consensus tests through the - // witness builder/runner, which would otherwise be impossible due to the - // various invalid chain states/behaviors being contained in those tests. - xvstart := time.Now() - if witness := statedb.Witness(); witness != nil && config.StatelessSelfValidation { - log.Warn("Running stateless self-validation", "block", block.Number(), "hash", block.Hash()) - - // Remove critical computed fields from the block to force true recalculation - context := block.Header() - context.Root = common.Hash{} - context.ReceiptHash = common.Hash{} - - task := types.NewBlockWithHeader(context).WithBody(*block.Body()) - - // Run the stateless self-cross-validation - crossStateRoot, crossReceiptRoot, err := ExecuteStateless(ctx, bc.chainConfig, bc.cfg.VmConfig, task, witness) - if err != nil { - return nil, fmt.Errorf("stateless self-validation failed: %v", err) - } - if crossStateRoot != block.Root() { - return nil, fmt.Errorf("stateless self-validation root mismatch (cross: %x local: %x)", crossStateRoot, block.Root()) - } - if crossReceiptRoot != block.ReceiptHash() { - return nil, fmt.Errorf("stateless self-validation receipt root mismatch (cross: %x local: %x)", crossReceiptRoot, block.ReceiptHash()) - } - } - var ( - xvtime = time.Since(xvstart) proctime = time.Since(startTime) // processing + validation + cross validation - stats = &ExecuteStats{} + stats = NewExecuteStats(statedb, ptime, vtime) ) - // Update the metrics touched during block processing and validation - stats.AccountReads = statedb.AccountReads // Account reads are complete(in processing) - stats.StorageReads = statedb.StorageReads // Storage reads are complete(in processing) - stats.AccountUpdates = statedb.AccountUpdates // Account updates are complete(in validation) - stats.StorageUpdates = statedb.StorageUpdates // Storage updates are complete(in validation) - stats.AccountHashes = statedb.AccountHashes // Account hashes are complete(in validation) - stats.CodeReads = statedb.CodeReads - - stats.AccountLoaded = statedb.AccountLoaded - stats.AccountUpdated = statedb.AccountUpdated - stats.AccountDeleted = statedb.AccountDeleted - stats.StorageLoaded = statedb.StorageLoaded - stats.StorageUpdated = int(statedb.StorageUpdated.Load()) - stats.StorageDeleted = int(statedb.StorageDeleted.Load()) - - stats.CodeLoaded = statedb.CodeLoaded - stats.CodeLoadBytes = statedb.CodeLoadBytes - stats.CodeUpdated = statedb.CodeUpdated - stats.CodeUpdateBytes = statedb.CodeUpdateBytes - - stats.Execution = ptime - (statedb.AccountReads + statedb.StorageReads + statedb.CodeReads) // The time spent on EVM processing - stats.Validation = vtime - (statedb.AccountHashes + statedb.AccountUpdates + statedb.StorageUpdates) // The time spent on block validation - stats.CrossValidation = xvtime // The time spent on stateless cross validation - // Write the block to the chain and get the status. var status WriteStatus if config.WriteState { @@ -2294,10 +2258,9 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, return nil, err } // Update the metrics touched during block commit - stats.AccountCommits = statedb.AccountCommits // Account commits are complete, we can mark them - stats.StorageCommits = statedb.StorageCommits // Storage commits are complete, we can mark them + stats.HasherCommit = statedb.HasherCommits // Storage commits are complete, we can mark them stats.DatabaseCommit = statedb.DatabaseCommits // Database commits are complete, we can mark them - stats.BlockWrite = time.Since(wstart) - max(statedb.AccountCommits, statedb.StorageCommits) /* concurrent */ - statedb.DatabaseCommits + stats.BlockWrite = time.Since(wstart) - statedb.HasherCommits - statedb.DatabaseCommits } // Report the collected witness statistics if witness != nil { @@ -2307,6 +2270,11 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, stats.TotalTime = elapsed stats.MgasPerSecond = float64(res.GasUsed) * 1000 / float64(elapsed) + if config.StatelessSelfValidation { + if err := bc.crossValidation(ctx, statedb, block); err != nil { + return nil, err + } + } return &blockProcessingResult{ usedGas: res.GasUsed, procTime: proctime, @@ -2316,6 +2284,39 @@ func (bc *BlockChain) ProcessBlock(ctx context.Context, parentRoot common.Hash, }, nil } +func (bc *BlockChain) crossValidation(ctx context.Context, statedb *state.StateDB, block *types.Block) error { + // If witnesses was generated and stateless self-validation requested, do + // that now. Self validation should *never* run in production, it's more of + // a tight integration to enable running *all* consensus tests through the + // witness builder/runner, which would otherwise be impossible due to the + // various invalid chain states/behaviors being contained in those tests. + if witness := statedb.Witness(); witness != nil { + xvstart := time.Now() + log.Warn("Running stateless self-validation", "block", block.Number(), "hash", block.Hash()) + + // Remove critical computed fields from the block to force true recalculation + context := block.Header() + context.Root = common.Hash{} + context.ReceiptHash = common.Hash{} + + task := types.NewBlockWithHeader(context).WithBody(*block.Body()) + + // Run the stateless self-cross-validation + crossStateRoot, crossReceiptRoot, err := ExecuteStateless(ctx, bc.chainConfig, bc.cfg.VmConfig, task, witness) + if err != nil { + return fmt.Errorf("stateless self-validation failed: %v", err) + } + if crossStateRoot != block.Root() { + return fmt.Errorf("stateless self-validation root mismatch (cross: %x local: %x)", crossStateRoot, block.Root()) + } + if crossReceiptRoot != block.ReceiptHash() { + return fmt.Errorf("stateless self-validation receipt root mismatch (cross: %x local: %x)", crossReceiptRoot, block.ReceiptHash()) + } + blockCrossValidationTimer.UpdateSince(xvstart) + } + return nil +} + // insertSideChain is called when an import batch hits upon a pruned ancestor // error, which happens when a sidechain with a sufficiently old fork-block is // found. diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go index 3614702d1a..9fda0e2f0a 100644 --- a/core/blockchain_reader.go +++ b/core/blockchain_reader.go @@ -424,6 +424,25 @@ func (bc *BlockChain) StateAt(root common.Hash) (*state.StateDB, error) { return state.New(root, state.NewDatabase(bc.triedb, bc.codedb).WithSnapshot(bc.snaps)) } +// StateConfig specifies the configuration for initializating the stateDB. +type StateConfig struct { + Prefetch bool + PrefetchRead bool + WithSnapshot bool +} + +// StateWithConfig returns a new mutable state based on a particular point in time. +func (bc *BlockChain) StateWithConfig(root common.Hash, config StateConfig) (*state.StateDB, error) { + sdb := state.NewDatabase(bc.triedb, bc.codedb) + if config.WithSnapshot { + sdb = sdb.WithSnapshot(bc.snaps) + } + if config.Prefetch { + sdb = sdb.EnablePrefetch(config.PrefetchRead) + } + return state.New(root, sdb) +} + // HistoricState returns a historic state specified by the given root. // Live states are not available and won't be served, please use `State` // or `StateAt` instead. diff --git a/core/blockchain_stats.go b/core/blockchain_stats.go index d753b0b700..fc33ef6fe4 100644 --- a/core/blockchain_stats.go +++ b/core/blockchain_stats.go @@ -29,14 +29,27 @@ import ( // ExecuteStats includes all the statistics of a block execution in details. type ExecuteStats struct { // State read times - AccountReads time.Duration // Time spent on the account reads - StorageReads time.Duration // Time spent on the storage reads + AccountReads time.Duration // Time spent on the account reads + StorageReads time.Duration // Time spent on the storage reads + CodeReads time.Duration // Time spent on the contract code read + + // State hash times AccountHashes time.Duration // Time spent on the account trie hash AccountUpdates time.Duration // Time spent on the account trie update - AccountCommits time.Duration // Time spent on the account trie commit StorageUpdates time.Duration // Time spent on the storage trie update - StorageCommits time.Duration // Time spent on the storage trie commit - CodeReads time.Duration // Time spent on the contract code read + + // EVM execution and validation time + Execution time.Duration // Time spent on the EVM execution + Validation time.Duration // Time spent on the block validation + + // Commit times + HasherCommit time.Duration // Time spent on trie commit + DatabaseCommit time.Duration // Time spent on database commit + BlockWrite time.Duration // Time spent on block write + + // Others + TotalTime time.Duration // The total time spent on block execution + MgasPerSecond float64 // The million gas processed per second AccountLoaded int // Number of accounts loaded AccountUpdated int // Number of accounts updated @@ -49,19 +62,39 @@ type ExecuteStats struct { CodeUpdated int // Number of contract code written (CREATE/CREATE2 + EIP-7702) CodeUpdateBytes int // Total bytes of code written - Execution time.Duration // Time spent on the EVM execution - Validation time.Duration // Time spent on the block validation - CrossValidation time.Duration // Optional, time spent on the block cross validation - DatabaseCommit time.Duration // Time spent on database commit - BlockWrite time.Duration // Time spent on block write - TotalTime time.Duration // The total time spent on block execution - MgasPerSecond float64 // The million gas processed per second - // Cache hit rates StateReadCacheStats state.ReaderStats StatePrefetchCacheStats state.ReaderStats } +func NewExecuteStats(stateDB *state.StateDB, process time.Duration, validation time.Duration) *ExecuteStats { + return &ExecuteStats{ + // State read times + AccountReads: stateDB.AccountReads, + StorageReads: stateDB.StorageReads, + CodeReads: stateDB.CodeReads, + + // State hash times + AccountHashes: stateDB.AccountHashes, + AccountUpdates: stateDB.AccountUpdates, + StorageUpdates: stateDB.StorageUpdates, + + Execution: process - stateDB.StateReadTime(), + Validation: validation - stateDB.StateHashTime(), + + AccountLoaded: stateDB.AccountLoaded, + AccountUpdated: stateDB.AccountUpdated, + AccountDeleted: stateDB.AccountDeleted, + StorageLoaded: stateDB.StorageLoaded, + StorageUpdated: int(stateDB.StorageUpdated.Load()), + StorageDeleted: int(stateDB.StorageDeleted.Load()), + CodeLoaded: stateDB.CodeLoaded, + CodeLoadBytes: stateDB.CodeLoadBytes, + CodeUpdated: stateDB.CodeUpdated, + CodeUpdateBytes: stateDB.CodeUpdateBytes, + } +} + // reportMetrics uploads execution statistics to the metrics system. func (s *ExecuteStats) reportMetrics() { if s.AccountLoaded != 0 { @@ -80,12 +113,10 @@ func (s *ExecuteStats) reportMetrics() { accountUpdateTimer.Update(s.AccountUpdates) // Account updates are complete(in validation) storageUpdateTimer.Update(s.StorageUpdates) // Storage updates are complete(in validation) accountHashTimer.Update(s.AccountHashes) // Account hashes are complete(in validation) - accountCommitTimer.Update(s.AccountCommits) // Account commits are complete, we can mark them - storageCommitTimer.Update(s.StorageCommits) // Storage commits are complete, we can mark them + hasherCommitTimer.Update(s.HasherCommit) // Trie commits are complete, we can mark them blockExecutionTimer.Update(s.Execution) // The time spent on EVM processing blockValidationTimer.Update(s.Validation) // The time spent on block validation - blockCrossValidationTimer.Update(s.CrossValidation) // The time spent on stateless cross validation triedbCommitTimer.Update(s.DatabaseCommit) // Trie database commits are complete, we can mark them blockWriteTimer.Update(s.BlockWrite) // The time spent on block write blockInsertTimer.Update(s.TotalTime) // The total time spent on block execution @@ -206,7 +237,7 @@ func (s *ExecuteStats) logSlow(block *types.Block, slowBlockThreshold time.Durat ExecutionMs: durationToMs(s.Execution), StateReadMs: durationToMs(s.AccountReads + s.StorageReads + s.CodeReads), StateHashMs: durationToMs(s.AccountHashes + s.AccountUpdates + s.StorageUpdates), - CommitMs: durationToMs(max(s.AccountCommits, s.StorageCommits) + s.DatabaseCommit + s.BlockWrite), + CommitMs: durationToMs(s.HasherCommit + s.DatabaseCommit + s.BlockWrite), TotalMs: durationToMs(s.TotalTime), }, Throughput: slowBlockThru{ diff --git a/core/rawdb/accessors_snapshot.go b/core/rawdb/accessors_snapshot.go index 5cea581fcd..2ae227c07a 100644 --- a/core/rawdb/accessors_snapshot.go +++ b/core/rawdb/accessors_snapshot.go @@ -112,6 +112,34 @@ func DeleteStorageSnapshot(db ethdb.KeyValueWriter, accountHash, storageHash com } } +// ReadBinTrieStem retrieves the flat-state stem blob for the given 31-byte +// stem. Returns nil if no entry exists under this stem. +// +// The stem blob is a packed representation of the (offset, value) pairs at +// that stem in the binary trie; callers must decode it to extract any +// specific offset. See trie/bintrie and EIP-7864 for the on-trie layout. +func ReadBinTrieStem(db ethdb.KeyValueReader, stem []byte) []byte { + data, _ := db.Get(binTrieStemKey(stem)) + return data +} + +// WriteBinTrieStem stores the flat-state stem blob for the given 31-byte +// stem. The blob is written verbatim; encoding/decoding is the caller's +// responsibility. +func WriteBinTrieStem(db ethdb.KeyValueWriter, stem []byte, blob []byte) { + if err := db.Put(binTrieStemKey(stem), blob); err != nil { + log.Crit("Failed to store bintrie stem", "err", err) + } +} + +// DeleteBinTrieStem removes the flat-state stem blob entry for the given +// 31-byte stem. +func DeleteBinTrieStem(db ethdb.KeyValueWriter, stem []byte) { + if err := db.Delete(binTrieStemKey(stem)); err != nil { + log.Crit("Failed to delete bintrie stem", "err", err) + } +} + // IterateStorageSnapshots returns an iterator for walking the entire storage // space of a specific account. func IterateStorageSnapshots(db ethdb.Iteratee, accountHash common.Hash) ethdb.Iterator { diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go index 54c76143b4..abc0db24d7 100644 --- a/core/rawdb/schema.go +++ b/core/rawdb/schema.go @@ -126,6 +126,20 @@ var ( TrieNodeStoragePrefix = []byte("O") // TrieNodeStoragePrefix + accountHash + hexPath -> trie node stateIDPrefix = []byte("L") // stateIDPrefix + state root -> state id + // Binary-trie flat-state scheme. A stem is 31 bytes per EIP-7864 (the + // common prefix of the 32-byte tree key); the stored value is a packed + // blob containing the subset of 256 offset values that are populated + // for this stem (layout: 32-byte bitmap of present offsets, followed + // by N 32-byte values in offset order). + // + // Note: bintrie pathdb wraps the disk database in a table keyed by + // VerklePrefix ("v"), so this prefix is effectively nested inside "v" + // when used by pathdb. It is defined as a distinct top-level byte + // ("X") to prevent accidental collisions with other top-level + // namespaces (e.g. blockBodyPrefix "b") when the codec is ever used + // against an unwrapped database. + BinTrieStemPrefix = []byte("X") // BinTrieStemPrefix + stem(31B) -> stem blob + // State history indexing within path-based storage scheme StateHistoryIndexPrefix = []byte("m") // The global prefix of state history index data StateHistoryAccountMetadataPrefix = []byte("ma") // StateHistoryAccountMetadataPrefix + account address hash => account metadata @@ -297,6 +311,22 @@ func storageTrieNodeKey(accountHash common.Hash, path []byte) []byte { return buf } +// binTrieStemKey = BinTrieStemPrefix + stem (31 bytes). +// +// A bintrie stem is the common 31-byte prefix of the 32-byte tree key (see +// EIP-7864). The stem blob stored under this key holds the packed set of +// (offset, value) pairs at that stem, from which BasicData (offset 0), +// CodeHash (offset 1), header storage (offsets 64-127), code chunks +// (offsets 128-255) and main-storage slots can be extracted. +func binTrieStemKey(stem []byte) []byte { + // Callers always pass a 31-byte stem. We allocate the exact size to + // avoid accidental aliasing with backing storage. + buf := make([]byte, len(BinTrieStemPrefix)+len(stem)) + n := copy(buf, BinTrieStemPrefix) + copy(buf[n:], stem) + return buf +} + // IsLegacyTrieNode reports whether a provided database entry is a legacy trie // node. The characteristics of legacy trie node are: // - the key length is 32 bytes diff --git a/core/state/database.go b/core/state/database.go index c603e3ad7a..921eaecd3d 100644 --- a/core/state/database.go +++ b/core/state/database.go @@ -17,8 +17,6 @@ package state import ( - "fmt" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/overlay" "github.com/ethereum/go-ethereum/core/rawdb" @@ -29,7 +27,6 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/bintrie" - "github.com/ethereum/go-ethereum/trie/transitiontrie" "github.com/ethereum/go-ethereum/trie/trienode" "github.com/ethereum/go-ethereum/triedb" ) @@ -43,6 +40,9 @@ type Database interface { // through which the account iterator and storage iterator can be created. Iteratee(root common.Hash) (Iteratee, error) + // Hasher returns a state hasher associated with the specified state root. + Hasher(root common.Hash) (Hasher, error) + // OpenTrie opens the main account trie. OpenTrie(root common.Hash) (Trie, error) @@ -150,6 +150,9 @@ type CachingDB struct { triedb *triedb.Database codedb *CodeDB snap *snapshot.Tree + + prefetch bool + prefetchRead bool } // NewDatabase creates a state database with the provided data sources. @@ -177,6 +180,13 @@ func (db *CachingDB) WithSnapshot(snapshot *snapshot.Tree) *CachingDB { return db } +// EnablePrefetch enables the hasher prefetching feature. +func (db *CachingDB) EnablePrefetch(prefetchRead bool) *CachingDB { + db.prefetch = true + db.prefetchRead = prefetchRead + return db +} + // StateReader returns a state reader associated with the specified state root. func (db *CachingDB) StateReader(stateRoot common.Hash) (StateReader, error) { var readers []StateReader @@ -194,10 +204,25 @@ func (db *CachingDB) StateReader(stateRoot common.Hash) (StateReader, error) { // This reader offers improved performance but is optional and only // partially useful if the snapshot data in path database is not // fully generated. + // + // For binary-trie databases the reader needs codec-specific key + // derivation (EIP-7864 stem || offset) and a separate decode path + // (BasicData/CodeHash leaves rather than slim RLP), so we install + // a bintrieFlatReader instead of the historical merkle flatReader. + // If the underlying path-database reader can't expose raw-byte + // access — e.g. a hypothetical wrapper that only implements the + // minimal database.StateReader — we silently fall through to the + // trie reader, which always works. if db.TrieDB().Scheme() == rawdb.PathScheme { reader, err := db.triedb.StateReader(stateRoot) if err == nil { - readers = append(readers, newFlatReader(reader)) + if db.TrieDB().IsVerkle() { + if br := newBintrieFlatReader(reader); br != nil { + readers = append(readers, br) + } + } else { + readers = append(readers, newFlatReader(reader)) + } } } // Configure the trie reader, which is expected to be available as the @@ -221,6 +246,15 @@ func (db *CachingDB) Reader(stateRoot common.Hash) (Reader, error) { return newReader(db.codedb.Reader(), sr), nil } +// Hasher implements Database, returning a hasher associated with the specified +// state root. +func (db *CachingDB) Hasher(stateRoot common.Hash) (Hasher, error) { + if db.TrieDB().IsVerkle() { + return newBinaryHasher(stateRoot, db.triedb, db.prefetch, db.prefetchRead) + } + return newMerkleHasher(stateRoot, db.triedb, db.prefetch, db.prefetchRead) +} + // ReadersWithCacheStats creates a pair of state readers that share the same // underlying state reader and internal state cache, while maintaining separate // statistics respectively. @@ -297,7 +331,11 @@ func (db *CachingDB) Commit(update *stateUpdate) error { } // If snapshotting is enabled, update the snapshot tree with this new version if db.snap != nil && db.snap.Snapshot(update.originRoot) != nil { - if err := db.snap.Update(update.root, update.originRoot, update.accounts, update.storages); err != nil { + accounts, _, storages, _, err := update.encodeMerkle() + if err != nil { + return err + } + if err := db.snap.Update(update.root, update.originRoot, accounts, storages); err != nil { log.Warn("Failed to update snapshot tree", "from", update.originRoot, "to", update.root, "err", err) } // Keep 128 diff layers in the memory, persistent layer is 129th. @@ -308,7 +346,11 @@ func (db *CachingDB) Commit(update *stateUpdate) error { log.Warn("Failed to cap snapshot tree", "root", update.root, "layers", TriesInMemory, "err", err) } } - return db.triedb.Update(update.root, update.originRoot, update.blockNumber, update.nodes, update.stateSet()) + stateSet, err := update.stateSet(!db.TrieDB().IsVerkle()) + if err != nil { + return err + } + return db.triedb.Update(update.root, update.originRoot, update.blockNumber, update.nodes, stateSet) } // Iteratee returns a state iteratee associated with the specified state root, @@ -316,15 +358,3 @@ func (db *CachingDB) Commit(update *stateUpdate) error { func (db *CachingDB) Iteratee(root common.Hash) (Iteratee, error) { return newStateIteratee(!db.triedb.IsVerkle(), root, db.triedb, db.snap) } - -// mustCopyTrie returns a deep-copied trie. -func mustCopyTrie(t Trie) Trie { - switch t := t.(type) { - case *trie.StateTrie: - return t.Copy() - case *transitiontrie.TransitionTrie: - return t.Copy() - default: - panic(fmt.Errorf("unknown trie type %T", t)) - } -} diff --git a/core/state/database_hasher.go b/core/state/database_hasher.go new file mode 100644 index 0000000000..925a7b31b6 --- /dev/null +++ b/core/state/database_hasher.go @@ -0,0 +1,202 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/stateless" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/trie/trienode" +) + +// CodeMut represents a mutation to contract code. +type CodeMut struct { + Code []byte // Null for deletion +} + +// AccountMut represents a mutation to an account. +// Semantics: +// - Account == nil: delete the account +// - Code == nil: leave code unchanged +// - Code != nil: apply the given code mutation +// - CodeSize: the account's CURRENT total code size, not just the bytes +// carried in Code. It is used by implementations that pack the code +// size into their on-trie account encoding (e.g. the binary trie +// BasicData leaf). Callers must always populate this field to the +// account's real code size, obtained via stateObject.CodeSize() or an +// equivalent source — even on balance/nonce-only updates where the +// code bytes themselves are not loaded. Leaving it at zero on a +// non-code-touching update silently corrupts on-trie state for any +// hasher that stores code size. +type AccountMut struct { + Account *Account // Null for deletion + Code *CodeMut // Null for unchanged + CodeSize int // Current code length (must be set by the caller) +} + +// Hashes encapsulates a trie root together with its original (pre-update) root. +type Hashes struct { + Hash common.Hash // Post-mutation root + Prev common.Hash // Pre-mutation root +} + +// StemWrite describes a single write to a bintrie stem offset. It is used +// by LeafProducer-capable hashers to report flat-state mutations derived +// from their trie updates so a downstream flat-state layer can be kept +// consistent with the hasher's on-trie view. +// +// Stem is the 31-byte common prefix of the EIP-7864 tree key. Offset is +// the index into the stem's 256-value group (0..255). Value is the +// 32-byte leaf value that was written; the caller uses the per-call +// policy documented on the binary hasher: +// - Account create/update: two writes (BasicData, CodeHash) with +// non-nil 32-byte values. +// - Storage update to a non-zero value: one write with the 32-byte +// normalized value. +// - Storage update to zero (the bintrie's "delete" convention): one +// write with 32 zero bytes (tombstone / present with zero). +// - Account delete: two writes with nil values, signalling the flat +// state to clear the corresponding offsets. +type StemWrite struct { + Stem [31]byte + Offset byte + Value []byte +} + +// LeafProducer is an optional extension to Hasher for implementations +// that track flat-state mutations alongside trie updates. Callers use it +// to harvest the set of stem writes needed to keep an out-of-band flat +// state layer consistent with the hasher's trie mutations. +// +// The binary hasher implements this interface; the merkle hasher does +// not, because merkle flat state is MPT-shaped and does not use stems. +// Callers check via a type assertion: +// +// if lp, ok := h.(LeafProducer); ok { +// writes := lp.DrainStemWrites() +// // ... propagate writes into the state update ... +// } +// +// DrainStemWrites is intended to be called ONCE per block, AFTER all +// UpdateAccount/UpdateStorage calls for that block have completed. The +// implementation must reset its internal buffer on drain so subsequent +// calls return only writes accumulated since the last drain. +type LeafProducer interface { + // DrainStemWrites returns all stem writes accumulated since the last + // drain, in the order they were produced, and resets the internal + // buffer. The returned slice is owned by the caller; the hasher + // allocates a fresh slice on the next update. + DrainStemWrites() []StemWrite +} + +// Hasher defines the minimal interface for computing state root hashes. +// +// It abstracts over different trie implementations, such as the traditional +// two-layer Merkle Patricia Trie (separate account and storage tries) and a +// unified single-layer binary trie (a single trie covering accounts, storages +// and contract code). +// +// This abstraction also enables alternative implementations, such as a no-op +// hasher for flat-state-only nodes (i.e. nodes that do not store trie data and +// do not perform state validation). +// +// The Hash method may be invoked multiple times and must return a hash that +// reflects all preceding state mutations. This behavior is required for +// compatibility with pre-Byzantium semantics. +type Hasher interface { + // UpdateAccount writes a list of accounts into the state. + UpdateAccount(addresses []common.Address, accounts []AccountMut) error + + // UpdateStorage writes a list of storage slot values. + UpdateStorage(address common.Address, keys []common.Hash, values []common.Hash) error + + // Hash computes and returns the state root hash without committing. + Hash() common.Hash + + // Commit finalizes all pending changes and returns the resulting state root + // hash, along with the set of dirty trie nodes generated by the updates. + // + // Additionally, if the hasher uses a two-layer structure, the roots of the + // secondary tries together with their original hashes will also be returned + // for all mutated accounts, regardless of whether their storage was modified. + Commit() (common.Hash, *trienode.MergedNodeSet, map[common.Address]Hashes, error) + + // Copy returns a deep-copied hasher instance. + Copy() Hasher +} + +// Prefetcher is an optional extension implemented by hashers that can +// asynchronously warm up trie/state data ahead of hashing. +type Prefetcher interface { + // PrefetchAccount schedules the account for prefetching. + PrefetchAccount(addresses []common.Address, read bool) + + // PrefetchStorage schedules the storage slot for prefetching. + PrefetchStorage(addr common.Address, keys []common.Hash, read bool) + + // TermPrefetch terminates all the background prefetching activities. + TermPrefetch() +} + +// WitnessCollector is an optional extension implemented by hashers that can +// construct a state witness for the most recent committed state transition. +type WitnessCollector interface { + // CollectWitness returns the state witness corresponding to the most recent + // committed state transition. + CollectWitness(*stateless.Witness) +} + +// Prover is an optional extension implemented by hashers that can construct +// proofs against the current state. +type Prover interface { + // ProveAccount constructs a proof for the given account. + // + // The returned proof contains all encoded nodes on the path to the account. + // The account itself is included in the last node and can be retrieved by + // verifying the proof. + // + // If the account does not exist, the returned proof contains all nodes of + // the longest existing prefix of the account key (at least the root), ending + // with the node that proves the absence of the account. + ProveAccount(addr common.Address, proofDb ethdb.KeyValueWriter) error + + // ProveStorage constructs a proof for the given storage slot of the + // specified account. + // + // The returned proof contains all encoded nodes on the path to the storage + // slot. The slot value itself is included in the last node and can be + // retrieved by verifying the proof. + // + // If the account or storage slot does not exist, the returned proof contains + // the nodes required to prove its absence. + ProveStorage(addr common.Address, key common.Hash, proofDb ethdb.KeyValueWriter) error +} + +// noopHasher is a Hasher implementation that performs no work and always +// returns an empty state root. +type noopHasher struct{} + +func (n *noopHasher) UpdateAccount([]common.Address, []AccountMut) error { return nil } +func (n *noopHasher) UpdateStorage(common.Address, []common.Hash, []common.Hash) error { + return nil +} +func (n *noopHasher) Hash() common.Hash { return common.Hash{} } +func (n *noopHasher) Commit() (common.Hash, *trienode.MergedNodeSet, map[common.Address]Hashes, error) { + return common.Hash{}, trienode.NewMergedNodeSet(), make(map[common.Address]Hashes), nil +} +func (n *noopHasher) Copy() Hasher { return &noopHasher{} } +func (n *noopHasher) Close() {} diff --git a/core/state/database_hasher_binary.go b/core/state/database_hasher_binary.go new file mode 100644 index 0000000000..cc59ed0306 --- /dev/null +++ b/core/state/database_hasher_binary.go @@ -0,0 +1,397 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/stateless" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/trie/bintrie" + "github.com/ethereum/go-ethereum/trie/trienode" + "github.com/ethereum/go-ethereum/triedb" +) + +// wrapBinTrie pairs a BinaryTrie with an optional background prefetcher that +// preloads trie nodes ahead of mutation. +type wrapBinTrie struct { + *bintrie.BinaryTrie + prefetcher *prefetcher +} + +// newWrapBinTrie creates a binary trie with the optional prefetcher enabled. +func newWrapBinTrie(root common.Hash, db *triedb.Database, prefetch bool, prefetchRead bool) (*wrapBinTrie, error) { + t, err := bintrie.NewBinaryTrie(root, db) + if err != nil { + return nil, err + } + var p *prefetcher + if prefetch { + p = newPrefetcher(t, prefetchRead) + } + return &wrapBinTrie{BinaryTrie: t, prefetcher: p}, nil +} + +// term synchronously terminates the prefetcher (no-op if nil or already done). +// After termination the prefetcher reference is nilled so subsequent calls are +// a cheap pointer check. +func (tr *wrapBinTrie) term() { + if tr.prefetcher == nil { + return + } + tr.prefetcher.terminate() + tr.prefetcher = nil +} + +// The methods below shadow the embedded bintrie.BinaryTrie so that any direct trie +// access auto-terminates the prefetcher first. This makes data-race freedom +// structural: callers never need to remember to call term() manually. + +func (tr *wrapBinTrie) UpdateAccount(address common.Address, acc *types.StateAccount, codeLen int) error { + tr.term() + return tr.BinaryTrie.UpdateAccount(address, acc, codeLen) +} + +func (tr *wrapBinTrie) DeleteAccount(address common.Address) error { + tr.term() + return tr.BinaryTrie.DeleteAccount(address) +} + +func (tr *wrapBinTrie) UpdateStorage(address common.Address, key, value []byte) error { + tr.term() + return tr.BinaryTrie.UpdateStorage(address, key, value) +} + +func (tr *wrapBinTrie) DeleteStorage(address common.Address, key []byte) error { + tr.term() + return tr.BinaryTrie.DeleteStorage(address, key) +} + +func (tr *wrapBinTrie) Hash() common.Hash { + tr.term() + return tr.BinaryTrie.Hash() +} + +func (tr *wrapBinTrie) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet) { + tr.term() + return tr.BinaryTrie.Commit(collectLeaf) +} + +func (tr *wrapBinTrie) Prove(key []byte, proofDb ethdb.KeyValueWriter) error { + tr.term() + return tr.BinaryTrie.Prove(key, proofDb) +} + +func (tr *wrapBinTrie) Witness() map[string][]byte { + tr.term() + return tr.BinaryTrie.Witness() +} + +func (tr *wrapBinTrie) prefetchAccounts(addresses []common.Address, read bool) { + if tr.prefetcher == nil { + return + } + tr.prefetcher.scheduleAccounts(addresses, read) +} + +func (tr *wrapBinTrie) prefetchStorage(addr common.Address, keys []common.Hash, read bool) { + if tr.prefetcher == nil { + return + } + tr.prefetcher.scheduleSlots(addr, keys, read) +} + +// copy returns a deep-copied state trie. Notably the prefetcher is deliberately +// not copied, as it only belongs to the original one. +func (tr *wrapBinTrie) copy() *wrapBinTrie { + tr.term() + return &wrapBinTrie{BinaryTrie: tr.BinaryTrie.Copy()} +} + +// binaryHasher is a Hasher implementation backed by a unified single-layer +// binary trie. Accounts, storage slots, and contract code all reside in one +// trie, keyed according to the EIP-7864 address space layout. +// +// binaryHasher also implements LeafProducer: alongside every trie mutation +// it records the corresponding (stem, offset, value) write into an +// internal buffer. StateDB.commit() drains this buffer once per block +// via LeafProducer.DrainStemWrites and hands the writes to the pathdb +// flat-state layer via stateUpdate.encodeBinary, keeping the bintrie +// trie and its flat-state mirror consistent without recomputing the +// bintrie key derivation twice. +type binaryHasher struct { + db *triedb.Database + root common.Hash + + prefetch bool + trie *wrapBinTrie + + // leaves buffers flat-state writes produced as a side-effect of + // UpdateAccount/UpdateStorage/deleteAccount. It is cleared by + // DrainStemWrites. Direct reads and writes to this slice are only + // safe from the single goroutine that owns the hasher; the Hasher + // interface already requires single-threaded use per block. + leaves []StemWrite +} + +// Compile-time assertion that binaryHasher implements LeafProducer. +var _ LeafProducer = (*binaryHasher)(nil) + +func newBinaryHasher(root common.Hash, db *triedb.Database, prefetch bool, prefetchRead bool) (*binaryHasher, error) { + tr, err := newWrapBinTrie(root, db, prefetch, prefetchRead) + if err != nil { + return nil, err + } + return &binaryHasher{ + db: db, + root: root, + prefetch: prefetch, + trie: tr, + }, nil +} + +// DrainStemWrites implements LeafProducer. It returns the buffered stem +// writes accumulated since the last drain and resets the buffer. The +// returned slice is owned by the caller; the hasher allocates a fresh +// backing array on the next update. +func (h *binaryHasher) DrainStemWrites() []StemWrite { + out := h.leaves + h.leaves = nil + return out +} + +// recordLeaf appends a single stem write to the internal buffer. The +// stem is taken from the first 31 bytes of the supplied 32-byte tree +// key, and the offset is the last byte. Value may be nil (for clearing +// a slot in the flat state, matching account deletion) or a 32-byte +// slice (for writes). +func (h *binaryHasher) recordLeaf(fullKey []byte, value []byte) { + var w StemWrite + copy(w.Stem[:], fullKey[:bintrie.StemSize]) + w.Offset = fullKey[bintrie.StemSize] + if value != nil { + w.Value = make([]byte, len(value)) + copy(w.Value, value) + } + h.leaves = append(h.leaves, w) +} + +// deleteAccount removes the account specified by the address from the state. +// +// In addition to the trie mutation, this records two "clear" stem writes +// (one for BasicData at offset 0 and one for CodeHash at offset 1) so +// the flat-state mirror can drop the matching entries. +// +// Note: BinaryTrie.DeleteAccount is currently a no-op upstream +// (tracked as a standalone bugfix PR against ethereum/go-ethereum). +// Until that fix lands the on-trie deletion does nothing, but the +// flat-state mirror will still drop its copy — a minor temporary +// inconsistency scoped to the account-delete path. Once the trie fix +// lands the two sides converge. +// +// Storage slots and code chunks at the same or other stems are NOT +// touched by this function; callers that need a full account wipe must +// walk storage explicitly. Pre-EIP-6780 self-destruct wipe is a +// documented scope limitation. +func (h *binaryHasher) deleteAccount(addr common.Address) error { + // Record the flat-state mutations BEFORE the trie call so the + // buffer still reflects the intended write even if the trie layer + // errors and we need to roll things back. + basicDataKey := bintrie.GetBinaryTreeKeyBasicData(addr) + codeHashKey := bintrie.GetBinaryTreeKeyCodeHash(addr) + h.recordLeaf(basicDataKey, nil) // nil → clear the flat-state offset + h.recordLeaf(codeHashKey, nil) + + return h.trie.DeleteAccount(addr) +} + +// update writes the account specified by the address into the state. +// +// The account's code size is taken from AccountMut.CodeSize, which the +// caller (StateDB.IntermediateRoot) populates via stateObject.CodeSize(). +// Per EIP-7864 the code_size field is packed into the BasicData leaf +// (bytes 5-7) and is consensus-critical; BinaryTrie.UpdateAccount rewrites +// the entire BasicData blob on every call, so passing the wrong codeLen +// would silently overwrite the stored code_size. In particular, for +// balance/nonce-only updates the new code bytes (account.Code) are nil +// and len(obj.code) is 0, yet the account may still have a non-zero code +// size that must be preserved — the caller gets this right by consulting +// the stateObject, which falls back to a reader code-size lookup when +// the bytes are not loaded. +func (h *binaryHasher) updateAccount(addr common.Address, account AccountMut) error { + data := &types.StateAccount{ + Nonce: account.Account.Nonce, + Balance: account.Account.Balance, + CodeHash: account.Account.CodeHash, + } + if err := h.trie.UpdateAccount(addr, data, account.CodeSize); err != nil { + return err + } + // Record the two flat-state writes that correspond to the on-trie + // BasicData (offset 0) and CodeHash (offset 1) at the account's + // stem. PackBasicData produces the same 32-byte blob that the trie + // layer packs internally, so the flat-state mirror encodes + // bit-identically. + basicData := bintrie.PackBasicData(data.Nonce, data.Balance, account.CodeSize) + h.recordLeaf(bintrie.GetBinaryTreeKeyBasicData(addr), basicData[:]) + + // CodeHash is a 32-byte value written straight into offset 1. + // EOAs store types.EmptyCodeHash here (a known non-zero hash) so + // the flat-state offset is always set after any non-delete update. + h.recordLeaf(bintrie.GetBinaryTreeKeyCodeHash(addr), data.CodeHash) + + // Write chunked code into the trie when dirty. + if account.Code != nil && len(account.Code.Code) > 0 { + codeHash := common.BytesToHash(account.Account.CodeHash) + if err := h.trie.UpdateContractCode(addr, codeHash, account.Code.Code); err != nil { + return err + } + } + return nil +} + +// UpdateAccount implements Hasher, writing a list of account mutations +// into the state. The assumption is held all the storage changes have +// already been written beforehand. +func (h *binaryHasher) UpdateAccount(addresses []common.Address, accounts []AccountMut) error { + var err error + for i, addr := range addresses { + if accounts[i].Account == nil { + err = h.deleteAccount(addr) + } else { + err = h.updateAccount(addr, accounts[i]) + } + if err != nil { + return err + } + } + return nil +} + +// UpdateStorage implements Hasher, writing a list of storage slot mutations +// into the state. This function must be invoked first before writing the +// associated account metadata into the state. +// +// Each mutation is also recorded as a flat-state stem write. A zero value +// is the bintrie's "delete" convention: the trie writes 32 zero bytes at +// the slot, and the flat-state mirror does the same (a present-with-zero +// tombstone) rather than removing the offset from its bitmap. This keeps +// the trie and flat-state views bit-identical for the slot. +func (h *binaryHasher) UpdateStorage(address common.Address, keys []common.Hash, values []common.Hash) error { + var err error + for i, key := range keys { + // BinaryTrie.UpdateStorage right-justifies a shorter input into + // 32 bytes; for a non-zero common.Hash the input is already 32 + // bytes so the normalization is a no-op. For the zero-value + // case we emit 32 zero bytes explicitly to match the trie's + // tombstone convention. + var blob [bintrie.HashSize]byte + if values[i] == (common.Hash{}) { + err = h.trie.DeleteStorage(address, key[:]) + } else { + copy(blob[:], values[i][:]) + err = h.trie.UpdateStorage(address, key[:], blob[:]) + } + if err != nil { + return err + } + // Record the flat-state mirror write regardless of zero/non-zero: + // the blob is 32 zero bytes in the delete case and the value in + // the non-delete case. + storageKey := bintrie.GetBinaryTreeKeyStorageSlot(address, key[:]) + h.recordLeaf(storageKey, blob[:]) + } + return nil +} + +// Hash implements Hasher, computing the state root hash without committing. +func (h *binaryHasher) Hash() common.Hash { + return h.trie.Hash() +} + +// Commit implements Hasher, finalizing all pending changes and returning +// the resulting state root hash, along with the set of dirty trie nodes +// generated by the updates. +func (h *binaryHasher) Commit() (common.Hash, *trienode.MergedNodeSet, map[common.Address]Hashes, error) { + nodes := trienode.NewMergedNodeSet() + root, set := h.trie.Commit(false) + if set != nil { + if err := nodes.Merge(set); err != nil { + return common.Hash{}, nil, nil, err + } + } + // The binary trie is a single unified structure with no per-account + // storage sub-tries, so there are no secondary hashes to report. + return root, nodes, nil, nil +} + +// Copy implements Hasher, returning a deep-copied hasher instance. +func (h *binaryHasher) Copy() Hasher { + return &binaryHasher{ + db: h.db, + root: h.root, + prefetch: false, + trie: h.trie.copy(), + } +} + +// ProveAccount implements Prover. NOTE: BinaryTrie.Prove is not yet +// implemented (panics at runtime). The key derivation also needs to use +// bintrie tree keys instead of keccak256. Do not call until the bintrie +// proof path is implemented. +func (h *binaryHasher) ProveAccount(addr common.Address, proofDb ethdb.KeyValueWriter) error { + return h.trie.Prove(crypto.Keccak256(addr.Bytes()), proofDb) +} + +// ProveStorage implements Prover. NOTE: same limitation as ProveAccount — +// BinaryTrie.Prove panics and the key derivation is wrong. +func (h *binaryHasher) ProveStorage(addr common.Address, key common.Hash, proofDb ethdb.KeyValueWriter) error { + return h.trie.Prove(crypto.Keccak256(key.Bytes()), proofDb) +} + +// CollectWitness implements WitnessCollector. It aggregates all trie nodes +// accessed during the state transition from the unified binary trie into +// a single state witness. +func (h *binaryHasher) CollectWitness(witness *stateless.Witness) { + witness.AddState(h.trie.Witness(), common.Hash{}) +} + +// PrefetchAccount implements Prefetcher, preloading the nodes of specific accounts. +func (h *binaryHasher) PrefetchAccount(addresses []common.Address, read bool) { + if !h.prefetch { + return + } + h.trie.prefetchAccounts(addresses, read) +} + +// PrefetchStorage implements Prefetcher, scheduling storage slot nodes for +// background loading in the unified binary trie. +func (h *binaryHasher) PrefetchStorage(addr common.Address, keys []common.Hash, read bool) { + if !h.prefetch { + return + } + h.trie.prefetchStorage(addr, keys, read) +} + +// TermPrefetch terminates all prefetcher goroutines. Safe to call multiple times. +func (h *binaryHasher) TermPrefetch() { + if h == nil { + return + } + h.trie.term() +} diff --git a/core/state/database_hasher_binary_test.go b/core/state/database_hasher_binary_test.go new file mode 100644 index 0000000000..fb5fcafe2a --- /dev/null +++ b/core/state/database_hasher_binary_test.go @@ -0,0 +1,548 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "bytes" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/stateless" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/trie/bintrie" + "github.com/ethereum/go-ethereum/triedb" +) + +// newTestBinaryHasher creates a binaryHasher backed by an in-memory path database. +func newTestBinaryHasher(t *testing.T, db *triedb.Database, root common.Hash, cfg hasherTestConfig) *binaryHasher { + t.Helper() + + h, err := newBinaryHasher(root, db, cfg.prefetch, cfg.prefetchRead) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h.TermPrefetch() }) + return h +} + +// commitAndReopenBinary commits the hasher's state and reopens a fresh hasher +// from the committed root. This simulates a block boundary. +func commitAndReopenBinary(t *testing.T, h *binaryHasher, cfg hasherTestConfig) *binaryHasher { + t.Helper() + + root, nodes, _, err := h.Commit() + if err != nil { + t.Fatal(err) + } + if nodes != nil { + if err := h.db.Update(root, h.root, 0, nodes, triedb.NewStateSet()); err != nil { + t.Fatal(err) + } + if err := h.db.Commit(root, false); err != nil { + t.Fatal(err) + } + } + h2, err := newBinaryHasher(root, h.db, cfg.prefetch, cfg.prefetchRead) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h2.TermPrefetch() }) + return h2 +} + +// makeBinaryBaseState creates a non-empty state as the starting point for tests. +// The base contains: +// - addr1: nonce=1, balance=100, storage={slot1: val1, slot2: val2} +// - addr2: nonce=2, balance=200, no storage +// +// The state is committed and flushed so the hasher returned opens from disk. +func makeBinaryBaseState(t *testing.T, cfg hasherTestConfig) *binaryHasher { + t.Helper() + + noPrefetch := hasherTestConfig{"base", false, false} + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), triedb.VerkleDefaults) + h := newTestBinaryHasher(t, db, types.EmptyBinaryHash, noPrefetch) + + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, []common.Hash{hasherVal1, hasherVal2}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr2}, + []AccountMut{hasherAccount(1, 100), hasherAccount(2, 200)}, + ); err != nil { + t.Fatal(err) + } + return commitAndReopenBinary(t, h, cfg) +} + +// TestBinaryHasherBasic verifies that mutating storage and accounts on top of +// a non-empty base state produces a deterministic, non-empty root and that the +// root survives a commit+reopen cycle. +func TestBinaryHasherBasic(t *testing.T) { + for _, cfg := range hasherTestConfigs { + t.Run(cfg.name, func(t *testing.T) { + h := makeBinaryBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr3}, false) + } + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr3}, + []AccountMut{hasherAccount(1, 100), hasherAccount(3, 300)}, + ); err != nil { + t.Fatal(err) + } + root := h.Hash() + if root == types.EmptyRootHash { + t.Fatal("expected non-empty root after mutations") + } + h2 := commitAndReopenBinary(t, h, cfg) + if h2.Hash() != root { + t.Fatalf("root mismatch after reopen: got %x, want %x", h2.Hash(), root) + } + }) + } +} + +// TestBinaryHasherPrefetchReadOnly verifies that read-only prefetching (for +// accounts and storage that are never subsequently mutated) does not corrupt +// state. Both prefetchRead=true (requests are processed) and prefetchRead=false +// (requests are dropped) are tested. +func TestBinaryHasherPrefetchReadOnly(t *testing.T) { + for _, prefetchRead := range []bool{false, true} { + name := "readDropped" + if prefetchRead { + name = "readProcessed" + } + t.Run(name, func(t *testing.T) { + cfg := hasherTestConfig{name, true, prefetchRead} + h := makeBinaryBaseState(t, cfg) + rootBefore := h.Hash() + + // Prefetch addr1's account and storage (read-only). + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr2}, true) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, true) + + // Only mutate addr2 — addr1's prefetched data is never written. + if err := h.UpdateAccount( + []common.Address{hasherAddr2}, + []AccountMut{hasherAccount(2, 300)}, + ); err != nil { + t.Fatal(err) + } + root := h.Hash() + if root == rootBefore { + t.Fatal("expected root to change after balance update") + } + h2 := commitAndReopenBinary(t, h, hasherTestConfig{"verify", false, false}) + if h2.Hash() != root { + t.Fatalf("root mismatch: got %x, want %x", h2.Hash(), root) + } + }) + } +} + +// TestBinaryHasherPrefetchDeterminism verifies that the resulting root is +// identical across all prefetch configurations for the same set of mutations. +func TestBinaryHasherPrefetchDeterminism(t *testing.T) { + var roots []common.Hash + for _, cfg := range hasherTestConfigs { + h := makeBinaryBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr3}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + h.PrefetchStorage(hasherAddr3, []common.Hash{hasherSlot1}, false) + } + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateStorage(hasherAddr3, []common.Hash{hasherSlot1}, []common.Hash{hasherVal1}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr3}, + []AccountMut{hasherAccount(1, 100), hasherAccount(3, 300)}, + ); err != nil { + t.Fatal(err) + } + roots = append(roots, h.Hash()) + } + for i := 1; i < len(roots); i++ { + if roots[i] != roots[0] { + t.Fatalf("root diverged: config[0]=%x config[%d]=%x", roots[0], i, roots[i]) + } + } +} + +// TestBinaryHasherCopy verifies that Copy produces an independent snapshot: +// mutations on the copy must not affect the original's hash. +func TestBinaryHasherCopy(t *testing.T) { + cfg := hasherTestConfig{"prefetchAll", true, true} + h := makeBinaryBaseState(t, cfg) + + h.PrefetchAccount([]common.Address{hasherAddr1}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherAccount(1, 100)}); err != nil { + t.Fatal(err) + } + origRoot := h.Hash() + + cpy := h.Copy() + defer cpy.(*binaryHasher).TermPrefetch() + + // Mutate the copy: delete slot3, add slot2 with new value. + if err := cpy.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3, hasherSlot2}, []common.Hash{{}, hasherVal3}); err != nil { + t.Fatal(err) + } + if err := cpy.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherAccount(1, 100)}); err != nil { + t.Fatal(err) + } + if cpy.Hash() == origRoot { + t.Fatal("copy should diverge after mutation") + } + if h.Hash() != origRoot { + t.Fatal("original root changed after mutating copy") + } +} + +// TestBinaryHasherWitness verifies that the witness returned by CollectWitness +// contains trie nodes for accessed accounts and storage. When read-only +// prefetching is enabled, the prefetched (but never written) data must also +// appear in the witness. +func TestBinaryHasherWitness(t *testing.T) { + // Collect witness WITHOUT read-prefetching: only mutated paths are tracked. + collectWitness := func(prefetchRead bool) int { + cfg := hasherTestConfig{"witness", true, prefetchRead} + h := makeBinaryBaseState(t, cfg) + + // Read-only prefetch of addr1 account and slot1 (never mutated below). + h.PrefetchAccount([]common.Address{hasherAddr1}, true) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1}, true) + + // Mutate only addr2 (no storage). + if err := h.UpdateAccount( + []common.Address{hasherAddr2}, + []AccountMut{hasherAccount(2, 300)}, + ); err != nil { + t.Fatal(err) + } + h.Hash() + + witness := &stateless.Witness{ + Codes: make(map[string]struct{}), + State: make(map[string]struct{}), + } + h.CollectWitness(witness) + return len(witness.State) + } + nodesWithoutRead := collectWitness(false) + nodesWithRead := collectWitness(true) + + if nodesWithoutRead == 0 { + t.Fatal("witness should contain trie nodes even without read prefetching") + } + if nodesWithRead <= nodesWithoutRead { + t.Fatalf("read-only prefetching should add extra nodes to witness: got %d (with read) vs %d (without)", nodesWithRead, nodesWithoutRead) + } +} + +// TestBinaryHasherLeafProduction verifies that binaryHasher implements +// LeafProducer and reports stem writes corresponding to each trie +// mutation. Covers the three mutation kinds the hasher performs: +// account update, storage update, and account delete. +func TestBinaryHasherLeafProduction(t *testing.T) { + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), triedb.VerkleDefaults) + h := newTestBinaryHasher(t, db, types.EmptyBinaryHash, hasherTestConfig{"leaf", false, false}) + + // Type assertion: binaryHasher must satisfy LeafProducer. + lp, ok := Hasher(h).(LeafProducer) + if !ok { + t.Fatal("binaryHasher should implement LeafProducer") + } + + // --- Account update: expect two writes (BasicData + CodeHash) --- + if err := h.UpdateAccount( + []common.Address{hasherAddr1}, + []AccountMut{hasherAccount(1, 100)}, + ); err != nil { + t.Fatalf("UpdateAccount: %v", err) + } + writes := lp.DrainStemWrites() + if len(writes) != 2 { + t.Fatalf("UpdateAccount: got %d stem writes, want 2 (BasicData + CodeHash)", len(writes)) + } + // Offsets 0 and 1 respectively, and the BasicData stem matches the + // CodeHash stem (same address → same 31-byte stem). + if writes[0].Offset != bintrie.BasicDataLeafKey { + t.Errorf("write[0].Offset = %d, want %d (BasicDataLeafKey)", writes[0].Offset, bintrie.BasicDataLeafKey) + } + if writes[1].Offset != bintrie.CodeHashLeafKey { + t.Errorf("write[1].Offset = %d, want %d (CodeHashLeafKey)", writes[1].Offset, bintrie.CodeHashLeafKey) + } + if writes[0].Stem != writes[1].Stem { + t.Errorf("stems differ: %x vs %x", writes[0].Stem, writes[1].Stem) + } + if len(writes[0].Value) != 32 { + t.Errorf("write[0].Value length = %d, want 32", len(writes[0].Value)) + } + if len(writes[1].Value) != 32 { + t.Errorf("write[1].Value length = %d, want 32", len(writes[1].Value)) + } + // The code hash leaf should be the empty-code hash (non-zero). + if !bytes.Equal(writes[1].Value, types.EmptyCodeHash.Bytes()) { + t.Errorf("write[1].Value = %x, want empty code hash %x", writes[1].Value, types.EmptyCodeHash.Bytes()) + } + + // --- Drain again: should be empty (drain is destructive) --- + if again := lp.DrainStemWrites(); len(again) != 0 { + t.Fatalf("second drain should be empty, got %d writes", len(again)) + } + + // --- Storage update: non-zero value produces one write --- + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot1}, []common.Hash{hasherVal1}); err != nil { + t.Fatalf("UpdateStorage: %v", err) + } + writes = lp.DrainStemWrites() + if len(writes) != 1 { + t.Fatalf("UpdateStorage: got %d writes, want 1", len(writes)) + } + // The recorded value should match hasherVal1 (a common.Hash), which + // is already 32 bytes wide. + if !bytes.Equal(writes[0].Value, hasherVal1[:]) { + t.Errorf("UpdateStorage value: got %x, want %x", writes[0].Value, hasherVal1) + } + + // --- Storage "delete" (zero value): one write with 32 zero bytes --- + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot1}, []common.Hash{{}}); err != nil { + t.Fatalf("UpdateStorage (zero): %v", err) + } + writes = lp.DrainStemWrites() + if len(writes) != 1 { + t.Fatalf("UpdateStorage (zero): got %d writes, want 1", len(writes)) + } + var zeros [32]byte + if !bytes.Equal(writes[0].Value, zeros[:]) { + t.Errorf("zero-value storage write should record 32 zero bytes, got %x", writes[0].Value) + } + + // --- Account delete: two writes with nil values --- + if err := h.UpdateAccount( + []common.Address{hasherAddr1}, + []AccountMut{{Account: nil}}, + ); err != nil { + t.Fatalf("UpdateAccount delete: %v", err) + } + writes = lp.DrainStemWrites() + if len(writes) != 2 { + t.Fatalf("delete: got %d writes, want 2 (BasicData + CodeHash clear)", len(writes)) + } + for i, w := range writes { + if w.Value != nil { + t.Errorf("delete write[%d] should have nil Value (clear), got %x", i, w.Value) + } + } + if writes[0].Offset != bintrie.BasicDataLeafKey || writes[1].Offset != bintrie.CodeHashLeafKey { + t.Errorf("delete offsets: got %d,%d, want %d,%d", writes[0].Offset, writes[1].Offset, bintrie.BasicDataLeafKey, bintrie.CodeHashLeafKey) + } +} + +// TestMerkleHasherNoLeafProducer verifies that merkleHasher does NOT +// implement LeafProducer — the interface is strictly opt-in and the MPT +// path has no concept of stem writes. +func TestMerkleHasherNoLeafProducer(t *testing.T) { + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil) + h, err := newMerkleHasher(types.EmptyRootHash, db, false, false) + if err != nil { + t.Fatal(err) + } + if _, ok := Hasher(h).(LeafProducer); ok { + t.Fatal("merkleHasher should NOT implement LeafProducer") + } +} + +// TestBinaryHasherWritesBothBasicAndCodeHash is a load-bearing invariant +// test for the A1 remediation. The bintrieFlatReader.Account method +// performs TWO independent AccountRLP reads (BasicData at offset 0 and +// CodeHash at offset 1). Cross-read consistency is only safe if the +// hasher ALWAYS co-writes both leaves whenever it touches an account — +// if a future optimization (e.g., a code-only update) emitted only the +// CodeHash leaf, the two reads could resolve to different layers and +// return a torn view. +// +// This test locks the invariant down: after an UpdateAccount call, the +// drained stem writes must contain EXACTLY ONE BasicData write and +// EXACTLY ONE CodeHash write for the touched address, both at the same +// stem. Any change to binaryHasher.updateAccount that drops either +// write will fail this test and the developer will be forced to +// re-evaluate the bintrieFlatReader.Account torn-read argument before +// shipping. +func TestBinaryHasherWritesBothBasicAndCodeHash(t *testing.T) { + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), triedb.VerkleDefaults) + h := newTestBinaryHasher(t, db, types.EmptyBinaryHash, hasherTestConfig{"inv", false, false}) + + lp, ok := Hasher(h).(LeafProducer) + if !ok { + t.Fatal("binaryHasher should implement LeafProducer") + } + + // Update a single account. The hasher MUST emit exactly two stem + // writes: BasicData (offset 0) and CodeHash (offset 1), at the + // same stem. + if err := h.UpdateAccount( + []common.Address{hasherAddr1}, + []AccountMut{hasherAccount(1, 100)}, + ); err != nil { + t.Fatalf("UpdateAccount: %v", err) + } + writes := lp.DrainStemWrites() + if len(writes) != 2 { + t.Fatalf("expected exactly 2 stem writes per UpdateAccount (BasicData + CodeHash), got %d", len(writes)) + } + + // Verify one is BasicData and one is CodeHash. + seenBasic := false + seenCode := false + for _, w := range writes { + switch w.Offset { + case bintrie.BasicDataLeafKey: + seenBasic = true + case bintrie.CodeHashLeafKey: + seenCode = true + default: + t.Errorf("unexpected stem write offset %d (want %d or %d)", w.Offset, bintrie.BasicDataLeafKey, bintrie.CodeHashLeafKey) + } + } + if !seenBasic { + t.Error("UpdateAccount did NOT emit a BasicData leaf write — bintrieFlatReader.Account torn-read invariant broken") + } + if !seenCode { + t.Error("UpdateAccount did NOT emit a CodeHash leaf write — bintrieFlatReader.Account torn-read invariant broken") + } + + // Verify both writes target the same stem. + if writes[0].Stem != writes[1].Stem { + t.Errorf("BasicData and CodeHash writes at different stems: %x vs %x", writes[0].Stem, writes[1].Stem) + } + + // Exercise the delete path too: binaryHasher.deleteAccount should + // also emit both nil writes. + if err := h.UpdateAccount( + []common.Address{hasherAddr1}, + []AccountMut{{Account: nil}}, + ); err != nil { + t.Fatalf("UpdateAccount (delete): %v", err) + } + deleteWrites := lp.DrainStemWrites() + if len(deleteWrites) != 2 { + t.Fatalf("expected 2 stem writes per account delete, got %d", len(deleteWrites)) + } + for i, w := range deleteWrites { + if w.Value != nil { + t.Errorf("delete write[%d] should have nil Value, got %x", i, w.Value) + } + } +} + +// TestStateUpdateEncodeBinaryFromLeaves verifies that stateUpdate.encodeBinary +// turns a slice of StemWrite values into the per-offset accountData map that +// pathdb's bintrie codec consumes. Three things matter: +// +// 1. Every leaf becomes one accountData entry, keyed by stem||offset. +// 2. nil-value leaves (account/storage deletes) become nil entries. +// 3. Non-nil leaves are deeply copied — encodeBinary must not retain +// pointers into the hasher's internal slab. +// +// storages/storageOrigin/accountOrigin remain empty: the bintrie path uses +// only accountData (per the layered-read design) and does not yet support +// state-history rollback. +func TestStateUpdateEncodeBinaryFromLeaves(t *testing.T) { + // Build a small leaves slice covering each kind of write the binary + // hasher emits: account update (BasicData + CodeHash), storage write, + // and a delete (nil value). + var ( + stemA [bintrie.StemSize]byte + stemB [bintrie.StemSize]byte + ) + for i := range stemA { + stemA[i] = byte(0x10 + i) + stemB[i] = byte(0xA0 + i) + } + basicDataValue := bytes.Repeat([]byte{0xAA}, 32) + codeHashValue := bytes.Repeat([]byte{0xBB}, 32) + storageValue := bytes.Repeat([]byte{0xCC}, 32) + + leaves := []StemWrite{ + // Account update at stemA: BasicData + CodeHash. + {Stem: stemA, Offset: bintrie.BasicDataLeafKey, Value: basicDataValue}, + {Stem: stemA, Offset: bintrie.CodeHashLeafKey, Value: codeHashValue}, + // Storage write at stemB. + {Stem: stemB, Offset: 7, Value: storageValue}, + // Account delete at a third stem (nil values clear offsets 0+1). + {Stem: [bintrie.StemSize]byte{0xFF, 0xFF}, Offset: bintrie.BasicDataLeafKey, Value: nil}, + {Stem: [bintrie.StemSize]byte{0xFF, 0xFF}, Offset: bintrie.CodeHashLeafKey, Value: nil}, + } + + su := &stateUpdate{leaves: leaves} + accounts, accountOrigin, storages, storageOrigin, err := su.encodeBinary() + if err != nil { + t.Fatalf("encodeBinary: %v", err) + } + + if len(accounts) != len(leaves) { + t.Fatalf("accounts len = %d, want %d", len(accounts), len(leaves)) + } + if len(storages) != 0 { + t.Errorf("storages should be empty for bintrie, got %d entries", len(storages)) + } + if len(accountOrigin) != 0 || len(storageOrigin) != 0 { + t.Errorf("origin maps should be empty for bintrie") + } + + // Check each leaf round-trips through the map under its full key. + for i, w := range leaves { + var fullKey common.Hash + copy(fullKey[:bintrie.StemSize], w.Stem[:]) + fullKey[bintrie.StemSize] = w.Offset + got, ok := accounts[fullKey] + if !ok { + t.Errorf("leaf %d: missing key %x", i, fullKey) + continue + } + if w.Value == nil { + if got != nil { + t.Errorf("leaf %d: nil leaf became %x", i, got) + } + continue + } + if !bytes.Equal(got, w.Value) { + t.Errorf("leaf %d: got %x, want %x", i, got, w.Value) + } + // Aliasing check: the encoder must own its bytes. + if len(got) > 0 && &got[0] == &w.Value[0] { + t.Errorf("leaf %d: encodeBinary aliased the input slice", i) + } + } +} diff --git a/core/state/database_hasher_merkle.go b/core/state/database_hasher_merkle.go new file mode 100644 index 0000000000..fa39df887c --- /dev/null +++ b/core/state/database_hasher_merkle.go @@ -0,0 +1,470 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "maps" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/stateless" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/trie" + "github.com/ethereum/go-ethereum/trie/trienode" + "github.com/ethereum/go-ethereum/triedb" + "golang.org/x/sync/errgroup" +) + +// wrapTrie pairs a StateTrie with an optional background prefetcher that +// preloads trie nodes ahead of mutation. +type wrapTrie struct { + *trie.StateTrie + prefetcher *prefetcher +} + +// newWrapTrie creates a merkle trie with the optional prefetcher enabled. +func newWrapTrie(id *trie.ID, db *triedb.Database, prefetch bool, prefetchRead bool) (*wrapTrie, error) { + t, err := trie.NewStateTrie(id, db) + if err != nil { + return nil, err + } + var p *prefetcher + if prefetch { + p = newPrefetcher(t, prefetchRead) + } + return &wrapTrie{StateTrie: t, prefetcher: p}, nil +} + +// term synchronously terminates the prefetcher (no-op if nil or already done). +// After termination the prefetcher reference is nilled so subsequent calls are +// a cheap pointer check. +func (tr *wrapTrie) term() { + if tr.prefetcher == nil { + return + } + tr.prefetcher.terminate() + tr.prefetcher = nil +} + +// The methods below shadow the embedded trie.StateTrie so that any direct trie +// access auto-terminates the prefetcher first. This makes data-race freedom +// structural: callers never need to remember to call term() manually. + +func (tr *wrapTrie) UpdateAccount(address common.Address, acc *types.StateAccount) error { + tr.term() + return tr.StateTrie.UpdateAccount(address, acc, 0) +} + +func (tr *wrapTrie) DeleteAccount(address common.Address) error { + tr.term() + return tr.StateTrie.DeleteAccount(address) +} + +func (tr *wrapTrie) UpdateStorage(address common.Address, key, value []byte) error { + tr.term() + return tr.StateTrie.UpdateStorage(address, key, value) +} + +func (tr *wrapTrie) DeleteStorage(address common.Address, key []byte) error { + tr.term() + return tr.StateTrie.DeleteStorage(address, key) +} + +func (tr *wrapTrie) Hash() common.Hash { + tr.term() + return tr.StateTrie.Hash() +} + +func (tr *wrapTrie) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet) { + tr.term() + return tr.StateTrie.Commit(collectLeaf) +} + +func (tr *wrapTrie) Prove(key []byte, proofDb ethdb.KeyValueWriter) error { + tr.term() + return tr.StateTrie.Prove(key, proofDb) +} + +func (tr *wrapTrie) Witness() map[string][]byte { + tr.term() + return tr.StateTrie.Witness() +} + +// prefetchAccounts prewarms the trie with the specified account list. +func (tr *wrapTrie) prefetchAccounts(addresses []common.Address, read bool) { + if tr.prefetcher == nil { + return + } + tr.prefetcher.scheduleAccounts(addresses, read) +} + +// prefetchStorage prewarms the trie with the specified storage list. +func (tr *wrapTrie) prefetchStorage(addr common.Address, keys []common.Hash, read bool) { + if tr.prefetcher == nil { + return + } + tr.prefetcher.scheduleSlots(addr, keys, read) +} + +// copy returns a deep-copied state trie. Notably the prefetcher is deliberately +// not copied, as it only belongs to the original one. +func (tr *wrapTrie) copy() *wrapTrie { + tr.term() + return &wrapTrie{StateTrie: tr.StateTrie.Copy()} +} + +// storageRootReader wraps the account trie for loading the storage root. It is +// essential to use an independent trie to prevent potential data races with +// the optional prefetcher. +// +// TODO(rjl493456442) use the flat state for better read efficiency. +type storageRootReader struct { + tr *trie.StateTrie +} + +func newStorageRootReader(root common.Hash, db *triedb.Database) (*storageRootReader, error) { + t, err := trie.NewStateTrie(trie.StateTrieID(root), db) + if err != nil { + return nil, err + } + return &storageRootReader{tr: t}, nil +} + +func (r *storageRootReader) read(address common.Address) (common.Hash, error) { + acct, err := r.tr.GetAccount(address) + if err != nil { + return common.Hash{}, err + } + if acct == nil { + return types.EmptyRootHash, nil + } + return acct.Root, nil +} + +func (r *storageRootReader) copy() *storageRootReader { + return &storageRootReader{tr: r.tr.Copy()} +} + +// merkleHasher is a Hasher implementation backed by the traditional two-layer +// Merkle Patricia Trie (separate account trie and per-account storage tries). +type merkleHasher struct { + db *triedb.Database + root common.Hash + reader *storageRootReader + prefetch bool + prefetchRead bool + + acctTrie *wrapTrie + storageTries map[common.Address]*wrapTrie + + // deletedTries preserves storage tries of accounts that were deleted + // during the block keyed by address. Only the first deletion per + // address is recorded (the pre-block incarnation). + deletedTries map[common.Address]*wrapTrie + + // storageRoots tracks the storage root transition for each resolved + // account. Prev is captured on first touch; Hash is updated by + // UpdateStorage or set to EmptyRootHash on deletion. + storageRoots map[common.Address]Hashes + + // Lock guards storage trie fields + storageLock sync.Mutex +} + +func newMerkleHasher(root common.Hash, db *triedb.Database, prefetch bool, prefetchRead bool) (*merkleHasher, error) { + tr, err := newWrapTrie(trie.StateTrieID(root), db, prefetch, prefetchRead) + if err != nil { + return nil, err + } + r, err := newStorageRootReader(root, db) + if err != nil { + return nil, err + } + return &merkleHasher{ + db: db, + root: root, + prefetch: prefetch, + prefetchRead: prefetchRead, + reader: r, + acctTrie: tr, + storageTries: make(map[common.Address]*wrapTrie), + deletedTries: make(map[common.Address]*wrapTrie), + storageRoots: make(map[common.Address]Hashes), + }, nil +} + +// storageRoot returns the current tracked storage root for addr. On first +// access for a given address the root is read from the account trie and +// recorded as the Prev value for the commit-time transition report. +func (h *merkleHasher) storageRoot(addr common.Address) (common.Hash, error) { + if hashes, ok := h.storageRoots[addr]; ok { + return hashes.Hash, nil + } + root, err := h.reader.read(addr) + if err != nil { + return common.Hash{}, err + } + h.storageRoots[addr] = Hashes{ + Prev: root, + Hash: root, + } + return root, nil +} + +// openStorageTrie returns the cached storage trie for addr, or opens one from +// the database if not already cached. +func (h *merkleHasher) openStorageTrie(address common.Address, prefetch bool) (*wrapTrie, error) { + h.storageLock.Lock() + defer h.storageLock.Unlock() + + if tr, ok := h.storageTries[address]; ok { + return tr, nil + } + root, err := h.storageRoot(address) + if err != nil { + return nil, err + } + id := trie.StorageTrieID(h.root, crypto.Keccak256Hash(address.Bytes()), root) + + tr, err := newWrapTrie(id, h.db, h.prefetch && prefetch, h.prefetchRead) + if err != nil { + return nil, err + } + h.storageTries[address] = tr + return tr, nil +} + +// deleteAccount removes the account specified by the address from the state. +func (h *merkleHasher) deleteAccount(addr common.Address) error { + // Capture the original storage root before modifying the trie. + _, err := h.storageRoot(addr) + if err != nil { + return err + } + h.storageRoots[addr] = Hashes{ + Prev: h.storageRoots[addr].Prev, + Hash: types.EmptyRootHash, + } + // Preserve the first deleted storage trie per address for + // witness collection. + if tr, ok := h.storageTries[addr]; ok && h.deletedTries[addr] == nil { + h.deletedTries[addr] = tr + } + delete(h.storageTries, addr) + + return h.acctTrie.DeleteAccount(addr) +} + +// update writes the account specified by the address into the state. +func (h *merkleHasher) updateAccount(addr common.Address, account AccountMut) error { + root, err := h.storageRoot(addr) + if err != nil { + return err + } + data := &types.StateAccount{ + Nonce: account.Account.Nonce, + Balance: account.Account.Balance, + Root: root, + CodeHash: account.Account.CodeHash, + } + return h.acctTrie.UpdateAccount(addr, data) +} + +// UpdateAccount implements Hasher, writing a list of account mutations +// into the state. The assumption is held all the storage changes have +// already been written beforehand. +func (h *merkleHasher) UpdateAccount(addresses []common.Address, accounts []AccountMut) error { + var err error + for i, addr := range addresses { + if accounts[i].Account == nil { + err = h.deleteAccount(addr) + } else { + err = h.updateAccount(addr, accounts[i]) + } + if err != nil { + return err + } + } + return nil +} + +// UpdateStorage implements Hasher, writing a list of storage slot mutations +// into the state. This function must be invoked first before writing the +// associated account metadata into the state. +func (h *merkleHasher) UpdateStorage(address common.Address, keys []common.Hash, values []common.Hash) error { + tr, err := h.openStorageTrie(address, false) + if err != nil { + return err + } + for i, key := range keys { + if values[i] == (common.Hash{}) { + err = tr.DeleteStorage(address, key[:]) + } else { + err = tr.UpdateStorage(address, key[:], common.TrimLeftZeroes(values[i][:])) + } + if err != nil { + return err + } + } + // Hash outside the lock to allow full parallelism across accounts. + hash := tr.Hash() + + // Write back the storage root back for reflecting the most recent + // changes. + h.storageLock.Lock() + h.storageRoots[address] = Hashes{ + Prev: h.storageRoots[address].Prev, + Hash: hash, + } + h.storageLock.Unlock() + return nil +} + +// Hash implements Hasher, computing the state root hash without committing. +func (h *merkleHasher) Hash() common.Hash { + return h.acctTrie.Hash() +} + +// Commit implements Hasher, finalizing all pending changes and returning +// the resulting state root hash, along with the set of dirty trie nodes +// generated by the updates. +func (h *merkleHasher) Commit() (common.Hash, *trienode.MergedNodeSet, map[common.Address]Hashes, error) { + var ( + eg errgroup.Group + root common.Hash + + lock sync.Mutex + nodes = trienode.NewMergedNodeSet() + merge = func(set *trienode.NodeSet) error { + lock.Lock() + defer lock.Unlock() + + return nodes.Merge(set) + } + ) + eg.Go(func() error { + r, set := h.acctTrie.Commit(true) + root = r + if set == nil { + return nil + } + return merge(set) + }) + for _, tr := range h.storageTries { + eg.Go(func() error { + _, set := tr.Commit(false) + if set == nil { + return nil + } + return merge(set) + }) + } + if err := eg.Wait(); err != nil { + return common.Hash{}, nil, nil, err + } + return root, nodes, h.storageRoots, nil +} + +// Copy implements Hasher, returning a deep-copied hasher instance. +func (h *merkleHasher) Copy() Hasher { + cpy := &merkleHasher{ + db: h.db, + root: h.root, + reader: h.reader.copy(), + prefetch: false, + prefetchRead: false, + acctTrie: h.acctTrie.copy(), + storageTries: make(map[common.Address]*wrapTrie, len(h.storageTries)), + deletedTries: make(map[common.Address]*wrapTrie, len(h.deletedTries)), + storageRoots: maps.Clone(h.storageRoots), + } + for addr, tr := range h.storageTries { + cpy.storageTries[addr] = tr.copy() + } + for addr, tr := range h.deletedTries { + cpy.deletedTries[addr] = tr.copy() + } + return cpy +} + +// ProveAccount implements Prover, constructing a proof for the given account. +func (h *merkleHasher) ProveAccount(addr common.Address, proofDb ethdb.KeyValueWriter) error { + return h.acctTrie.Prove(crypto.Keccak256(addr.Bytes()), proofDb) +} + +// ProveStorage implements Prover, constructing a proof for the given storage +// slot of the specified account. +func (h *merkleHasher) ProveStorage(addr common.Address, key common.Hash, proofDb ethdb.KeyValueWriter) error { + tr, err := h.openStorageTrie(addr, false) + if err != nil { + return err + } + return tr.Prove(crypto.Keccak256(key.Bytes()), proofDb) +} + +// CollectWitness implements WitnessCollector. It aggregates all trie nodes +// accessed (both read and write) across the account trie, all active storage +// tries and deleted storage tries into a single state witness. +func (h *merkleHasher) CollectWitness(witness *stateless.Witness) { + witness.AddState(h.acctTrie.Witness(), common.Hash{}) + for addr, tr := range h.storageTries { + witness.AddState(tr.Witness(), crypto.Keccak256Hash(addr.Bytes())) + } + for addr, tr := range h.deletedTries { + witness.AddState(tr.Witness(), crypto.Keccak256Hash(addr.Bytes())) + } +} + +// PrefetchAccount implements Prefetcher, preloading the nodes of specific accounts. +func (h *merkleHasher) PrefetchAccount(addresses []common.Address, read bool) { + if !h.prefetch { + return + } + h.acctTrie.prefetchAccounts(addresses, read) +} + +// PrefetchStorage implements Prefetcher. The storage trie is opened eagerly +// so the prefetcher can begin loading nodes in the background. +func (h *merkleHasher) PrefetchStorage(addr common.Address, keys []common.Hash, read bool) { + if !h.prefetch { + return + } + if !h.prefetchRead && read { + return + } + tr, err := h.openStorageTrie(addr, true) + if err != nil { + return + } + tr.prefetchStorage(addr, keys, read) +} + +// TermPrefetch terminates all prefetcher goroutines. Safe to call multiple times. +func (h *merkleHasher) TermPrefetch() { + if h == nil { + return + } + h.acctTrie.term() + for _, tr := range h.storageTries { + tr.term() + } + for _, tr := range h.deletedTries { + tr.term() + } +} diff --git a/core/state/database_hasher_merkle_test.go b/core/state/database_hasher_merkle_test.go new file mode 100644 index 0000000000..8e15d734bc --- /dev/null +++ b/core/state/database_hasher_merkle_test.go @@ -0,0 +1,629 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/stateless" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" +) + +var ( + hasherAddr1 = common.HexToAddress("0x1111111111111111111111111111111111111111") + hasherAddr2 = common.HexToAddress("0x2222222222222222222222222222222222222222") + hasherAddr3 = common.HexToAddress("0x3333333333333333333333333333333333333333") + + hasherSlot1 = common.HexToHash("0x01") + hasherSlot2 = common.HexToHash("0x02") + hasherSlot3 = common.HexToHash("0x03") + + hasherVal1 = common.HexToHash("0xaa") + hasherVal2 = common.HexToHash("0xbb") + hasherVal3 = common.HexToHash("0xcc") +) + +// hasherTestConfig captures the prefetch flags varied across subtests. +type hasherTestConfig struct { + name string + prefetch bool + prefetchRead bool +} + +// hasherTestConfigs enumerates the interesting (prefetch, prefetchRead) combinations: +// - no prefetch at all +// - prefetch writes only (read prefetch requests are dropped) +// - prefetch reads and writes +var hasherTestConfigs = []hasherTestConfig{ + {"noPrefetch", false, false}, + {"prefetchWriteOnly", true, false}, + {"prefetchAll", true, true}, +} + +func hasherAccount(nonce uint64, balance uint64) AccountMut { + return AccountMut{ + Account: &Account{ + Nonce: nonce, + Balance: uint256.NewInt(balance), + CodeHash: types.EmptyCodeHash.Bytes(), + }, + } +} + +func hasherDeleteAccount() AccountMut { + return AccountMut{Account: nil} +} + +// newTestHasher creates a merkleHasher backed by an in-memory database. +func newTestHasher(t *testing.T, db *triedb.Database, root common.Hash, cfg hasherTestConfig) *merkleHasher { + t.Helper() + + h, err := newMerkleHasher(root, db, cfg.prefetch, cfg.prefetchRead) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h.TermPrefetch() }) + return h +} + +// commitAndReopen commits the hasher's state and reopens a fresh hasher from +// the committed root. This simulates a block boundary. +func commitAndReopen(t *testing.T, h *merkleHasher, cfg hasherTestConfig) *merkleHasher { + t.Helper() + + root, nodes, _, err := h.Commit() + if err != nil { + t.Fatal(err) + } + if nodes != nil { + if err := h.db.Update(root, h.root, 0, nodes, nil); err != nil { + t.Fatal(err) + } + if err := h.db.Commit(root, false); err != nil { + t.Fatal(err) + } + } + h2, err := newMerkleHasher(root, h.db, cfg.prefetch, cfg.prefetchRead) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h2.TermPrefetch() }) + return h2 +} + +// makeBaseState creates a non-empty state as the starting point for tests. +// The base contains: +// - addr1: nonce=1, balance=100, storage={slot1: val1, slot2: val2} +// - addr2: nonce=2, balance=200, no storage +// +// The state is committed and flushed so the hasher returned opens from disk, +// exercising rootReader and existing-trie code paths. +func makeBaseState(t *testing.T, cfg hasherTestConfig) *merkleHasher { + t.Helper() + + noPrefetch := hasherTestConfig{"base", false, false} + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil) + h := newTestHasher(t, db, types.EmptyRootHash, noPrefetch) + + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, []common.Hash{hasherVal1, hasherVal2}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr2}, + []AccountMut{hasherAccount(1, 100), hasherAccount(2, 200)}, + ); err != nil { + t.Fatal(err) + } + return commitAndReopen(t, h, cfg) +} + +// TestMerkleHasherBasic verifies that mutating storage and accounts on top of +// a non-empty base state produces a deterministic, non-empty root and that the +// root survives a commit+reopen cycle. +func TestMerkleHasherBasic(t *testing.T) { + for _, cfg := range hasherTestConfigs { + t.Run(cfg.name, func(t *testing.T) { + h := makeBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr3}, false) + } + // Add slot3 to addr1 and create addr3. + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr3}, + []AccountMut{hasherAccount(1, 100), hasherAccount(3, 300)}, + ); err != nil { + t.Fatal(err) + } + root := h.Hash() + if root == types.EmptyRootHash { + t.Fatal("expected non-empty root after mutations") + } + h2 := commitAndReopen(t, h, cfg) + if h2.Hash() != root { + t.Fatalf("root mismatch after reopen: got %x, want %x", h2.Hash(), root) + } + }) + } +} + +// TestMerkleHasherPrefetchReadOnly verifies that read-only prefetching (for +// accounts and storage that are never subsequently mutated) does not corrupt +// state and does not leak goroutines. Both prefetchRead=true (requests are +// processed) and prefetchRead=false (requests are dropped) are tested. +func TestMerkleHasherPrefetchReadOnly(t *testing.T) { + for _, prefetchRead := range []bool{false, true} { + name := "readDropped" + if prefetchRead { + name = "readProcessed" + } + t.Run(name, func(t *testing.T) { + cfg := hasherTestConfig{name, true, prefetchRead} + h := makeBaseState(t, cfg) + rootBefore := h.Hash() + + // Prefetch addr1's account and storage (read-only). Whether + // these are actually processed depends on prefetchRead. + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr2}, true) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, true) + + // Only mutate addr2 (no storage) — addr1's prefetched tries + // are never accessed through a shadow method. + if err := h.UpdateAccount( + []common.Address{hasherAddr2}, + []AccountMut{hasherAccount(2, 300)}, + ); err != nil { + t.Fatal(err) + } + root := h.Hash() + if root == rootBefore { + t.Fatal("expected root to change after balance update") + } + h2 := commitAndReopen(t, h, hasherTestConfig{"verify", false, false}) + if h2.Hash() != root { + t.Fatalf("root mismatch: got %x, want %x", h2.Hash(), root) + } + }) + } +} + +// TestMerkleHasherDeleteAccount verifies that deleting an account with storage +// produces an empty storage root in the commit result, with Prev reflecting +// the original non-empty root. +func TestMerkleHasherDeleteAccount(t *testing.T) { + for _, cfg := range hasherTestConfigs { + t.Run(cfg.name, func(t *testing.T) { + h := makeBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchAccount([]common.Address{hasherAddr1}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, false) + } + // Delete addr1 (which has storage slots 1,2). + if err := h.UpdateAccount( + []common.Address{hasherAddr1}, + []AccountMut{hasherDeleteAccount()}, + ); err != nil { + t.Fatal(err) + } + _, _, storageRoots, err := h.Commit() + if err != nil { + t.Fatal(err) + } + sr, ok := storageRoots[hasherAddr1] + if !ok { + t.Fatal("deleted account missing from storageRoots") + } + if sr.Hash != types.EmptyRootHash { + t.Fatalf("deleted account storage root: got %x, want EmptyRootHash", sr.Hash) + } + if sr.Prev == types.EmptyRootHash { + t.Fatal("deleted account Prev should be non-empty (had storage)") + } + }) + } +} + +// TestMerkleHasherDeleteRecreate verifies that deleting an account and +// recreating it with different storage in the same block produces a correct +// root that survives a commit+reopen cycle. The storageRoots report must show +// the original Prev and a new Hash. +func TestMerkleHasherDeleteRecreate(t *testing.T) { + for _, cfg := range hasherTestConfigs { + t.Run(cfg.name, func(t *testing.T) { + h := makeBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchAccount([]common.Address{hasherAddr1}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1, hasherSlot2}, false) + } + // Delete addr1. + if err := h.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherDeleteAccount()}); err != nil { + t.Fatal(err) + } + // Recreate with slot3 only. + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherAccount(10, 500)}); err != nil { + t.Fatal(err) + } + root := h.Hash() + if root == types.EmptyRootHash { + t.Fatal("expected non-empty root after recreate") + } + h2 := commitAndReopen(t, h, hasherTestConfig{"verify", false, false}) + + sr := h.storageRoots[hasherAddr1] + if sr.Hash == types.EmptyRootHash { + t.Fatal("recreated account should have non-empty storage root") + } + if sr.Prev == types.EmptyRootHash { + t.Fatal("Prev should reflect the pre-deletion storage root") + } + if sr.Hash == sr.Prev { + t.Fatal("Hash and Prev should differ after delete+recreate with different slots") + } + if h2.Hash() != root { + t.Fatalf("root mismatch after reopen: got %x, want %x", h2.Hash(), root) + } + }) + } +} + +// TestMerkleHasherPrefetchDeterminism verifies that the resulting root is +// identical across all prefetch configurations for the same set of mutations. +func TestMerkleHasherPrefetchDeterminism(t *testing.T) { + var roots []common.Hash + for _, cfg := range hasherTestConfigs { + h := makeBaseState(t, cfg) + + if cfg.prefetch { + h.PrefetchAccount([]common.Address{hasherAddr1, hasherAddr3}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + h.PrefetchStorage(hasherAddr3, []common.Hash{hasherSlot1}, false) + } + // Add slot3 to addr1, create addr3 with slot1. + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateStorage(hasherAddr3, []common.Hash{hasherSlot1}, []common.Hash{hasherVal1}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr3}, + []AccountMut{hasherAccount(1, 100), hasherAccount(3, 300)}, + ); err != nil { + t.Fatal(err) + } + roots = append(roots, h.Hash()) + } + for i := 1; i < len(roots); i++ { + if roots[i] != roots[0] { + t.Fatalf("root diverged: config[0]=%x config[%d]=%x", roots[0], i, roots[i]) + } + } +} + +// TestMerkleHasherCommitStorageRoots exhaustively checks the Prev/Hash pairs +// returned by Commit for every interesting mutation pattern: +// +// (1) delete account with non-empty storage +// (2) delete account with empty storage +// (3) delete + recreate with new non-empty storage +// (4) delete + recreate without storage (empty→empty after recreate) +// (5) delete + recreate: originally empty storage, recreated with storage +// (6) mutate account only, no storage (empty storage throughout) +// (7) mutate account only, non-empty storage unchanged +// (8) mutate account with modified storage +func TestMerkleHasherCommitStorageRoots(t *testing.T) { + var ( + // Addresses for each case — distinct so they don't interfere. + addrDeleteNonEmpty = common.HexToAddress("0xaa01") // (1) + addrDeleteEmpty = common.HexToAddress("0xaa02") // (2) + addrRecreateStorage = common.HexToAddress("0xaa03") // (3) + addrRecreateNoStore = common.HexToAddress("0xaa04") // (4) + addrRecreateFromNone = common.HexToAddress("0xaa05") // (5) + addrMutateNoStorage = common.HexToAddress("0xaa06") // (6) + addrMutateKeepStore = common.HexToAddress("0xaa07") // (7) + addrMutateModStore = common.HexToAddress("0xaa08") // (8) + ) + for _, cfg := range hasherTestConfigs { + t.Run(cfg.name, func(t *testing.T) { + // ---------- base state (committed to disk) ---------- + noPrefetch := hasherTestConfig{"base", false, false} + db := triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil) + base := newTestHasher(t, db, types.EmptyRootHash, noPrefetch) + + // Accounts with storage. + for _, addr := range []common.Address{addrDeleteNonEmpty, addrRecreateStorage, addrRecreateNoStore, addrMutateKeepStore, addrMutateModStore} { + if err := base.UpdateStorage(addr, []common.Hash{hasherSlot1}, []common.Hash{hasherVal1}); err != nil { + t.Fatal(err) + } + } + // All accounts (some with storage above, some without). + allAddrs := []common.Address{ + addrDeleteNonEmpty, addrDeleteEmpty, + addrRecreateStorage, addrRecreateNoStore, addrRecreateFromNone, + addrMutateNoStorage, addrMutateKeepStore, addrMutateModStore, + } + allAccounts := make([]AccountMut, len(allAddrs)) + for i := range allAccounts { + allAccounts[i] = hasherAccount(1, 100) + } + if err := base.UpdateAccount(allAddrs, allAccounts); err != nil { + t.Fatal(err) + } + h := commitAndReopen(t, base, cfg) + + // ---------- block mutations ---------- + + // (1) Delete account with non-empty storage. + // (2) Delete account with empty storage. + if err := h.UpdateAccount( + []common.Address{addrDeleteNonEmpty, addrDeleteEmpty}, + []AccountMut{hasherDeleteAccount(), hasherDeleteAccount()}, + ); err != nil { + t.Fatal(err) + } + // (3) Delete + recreate with new storage. + if err := h.UpdateAccount([]common.Address{addrRecreateStorage}, []AccountMut{hasherDeleteAccount()}); err != nil { + t.Fatal(err) + } + if err := h.UpdateStorage(addrRecreateStorage, []common.Hash{hasherSlot2}, []common.Hash{hasherVal2}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{addrRecreateStorage}, []AccountMut{hasherAccount(2, 200)}); err != nil { + t.Fatal(err) + } + // (4) Delete + recreate without storage (had storage before). + if err := h.UpdateAccount([]common.Address{addrRecreateNoStore}, []AccountMut{hasherDeleteAccount()}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{addrRecreateNoStore}, []AccountMut{hasherAccount(2, 200)}); err != nil { + t.Fatal(err) + } + // (5) Delete + recreate: originally no storage, recreated with storage. + if err := h.UpdateAccount([]common.Address{addrRecreateFromNone}, []AccountMut{hasherDeleteAccount()}); err != nil { + t.Fatal(err) + } + if err := h.UpdateStorage(addrRecreateFromNone, []common.Hash{hasherSlot1}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{addrRecreateFromNone}, []AccountMut{hasherAccount(2, 200)}); err != nil { + t.Fatal(err) + } + // (6) Mutate account only, no storage. + if err := h.UpdateAccount([]common.Address{addrMutateNoStorage}, []AccountMut{hasherAccount(2, 999)}); err != nil { + t.Fatal(err) + } + // (7) Mutate account, non-empty storage unchanged. + if err := h.UpdateAccount([]common.Address{addrMutateKeepStore}, []AccountMut{hasherAccount(2, 888)}); err != nil { + t.Fatal(err) + } + // (8) Mutate account with modified storage. + if err := h.UpdateStorage(addrMutateModStore, []common.Hash{hasherSlot1}, []common.Hash{hasherVal2}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{addrMutateModStore}, []AccountMut{hasherAccount(2, 777)}); err != nil { + t.Fatal(err) + } + _, _, roots, err := h.Commit() + if err != nil { + t.Fatal(err) + } + empty := types.EmptyRootHash + + // (1) Deleted, had storage: Prev=non-empty, Hash=empty. + sr := roots[addrDeleteNonEmpty] + if sr.Prev == empty { + t.Fatal("(1) Prev should be non-empty for deleted account that had storage") + } + if sr.Hash != empty { + t.Fatal("(1) Hash should be EmptyRootHash after deletion") + } + // (2) Deleted, had no storage: Prev=empty, Hash=empty. + sr = roots[addrDeleteEmpty] + if sr.Prev != empty || sr.Hash != empty { + t.Fatalf("(2) expected both EmptyRootHash, got Prev=%x Hash=%x", sr.Prev, sr.Hash) + } + // (3) Delete+recreate with new storage: Prev=non-empty(original), Hash=non-empty(new), differ. + sr = roots[addrRecreateStorage] + if sr.Prev == empty { + t.Fatal("(3) Prev should be non-empty (had storage before deletion)") + } + if sr.Hash == empty { + t.Fatal("(3) Hash should be non-empty (recreated with storage)") + } + if sr.Hash == sr.Prev { + t.Fatal("(3) Hash and Prev should differ (different storage contents)") + } + // (4) Delete+recreate without storage (originally had storage): Prev=non-empty, Hash=empty. + sr = roots[addrRecreateNoStore] + if sr.Prev == empty { + t.Fatal("(4) Prev should be non-empty (had storage before deletion)") + } + if sr.Hash != empty { + t.Fatal("(4) Hash should be EmptyRootHash (recreated without storage)") + } + // (5) Delete+recreate: originally no storage, recreated with storage: Prev=empty, Hash=non-empty. + sr = roots[addrRecreateFromNone] + if sr.Prev != empty { + t.Fatal("(5) Prev should be EmptyRootHash (no storage before deletion)") + } + if sr.Hash == empty { + t.Fatal("(5) Hash should be non-empty (recreated with storage)") + } + // (6) Mutate account only, no storage: Prev=empty, Hash=empty. + sr = roots[addrMutateNoStorage] + if sr.Prev != empty || sr.Hash != empty { + t.Fatalf("(6) expected both EmptyRootHash, got Prev=%x Hash=%x", sr.Prev, sr.Hash) + } + // (7) Mutate account, storage unchanged: Prev=non-empty, Hash=non-empty, Prev==Hash. + sr = roots[addrMutateKeepStore] + if sr.Prev == empty { + t.Fatal("(7) Prev should be non-empty (has storage)") + } + if sr.Hash == empty { + t.Fatal("(7) Hash should be non-empty (storage unchanged)") + } + if sr.Prev != sr.Hash { + t.Fatal("(7) Prev and Hash should be equal (storage was not modified)") + } + // (8) Mutate account with modified storage: Prev=non-empty, Hash=non-empty, differ. + sr = roots[addrMutateModStore] + if sr.Prev == empty { + t.Fatal("(8) Prev should be non-empty (had storage)") + } + if sr.Hash == empty { + t.Fatal("(8) Hash should be non-empty (storage modified, not cleared)") + } + if sr.Prev == sr.Hash { + t.Fatal("(8) Prev and Hash should differ (storage was modified)") + } + }) + } +} + +// TestMerkleHasherCopy verifies that Copy produces an independent snapshot: +// mutations on the copy must not affect the original's hash. +func TestMerkleHasherCopy(t *testing.T) { + cfg := hasherTestConfig{"prefetchAll", true, true} + h := makeBaseState(t, cfg) + + h.PrefetchAccount([]common.Address{hasherAddr1}, false) + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot3}, false) + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherAccount(1, 100)}); err != nil { + t.Fatal(err) + } + origRoot := h.Hash() + + cpy := h.Copy() + defer cpy.(*merkleHasher).TermPrefetch() + + // Mutate the copy: delete slot3, add slot2 with new value. + if err := cpy.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3, hasherSlot2}, []common.Hash{{}, hasherVal3}); err != nil { + t.Fatal(err) + } + if err := cpy.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherAccount(1, 100)}); err != nil { + t.Fatal(err) + } + if cpy.Hash() == origRoot { + t.Fatal("copy should diverge after mutation") + } + if h.Hash() != origRoot { + t.Fatal("original root changed after mutating copy") + } +} + +// proofNodes collects the raw RLP-encoded trie nodes written by Prove calls. +type proofNodes struct{ nodes [][]byte } + +func (p *proofNodes) Put(key []byte, value []byte) error { + p.nodes = append(p.nodes, common.CopyBytes(value)) + return nil +} +func (p *proofNodes) Delete([]byte) error { return nil } + +// TestMerkleHasherWitness verifies that the witness returned by Witness() +// contains every trie node on the Merkle proof path for each accessed account +// and storage slot, including nodes from deleted storage tries. +func TestMerkleHasherWitness(t *testing.T) { + h := makeBaseState(t, hasherTestConfig{"prefetchAll", true, true}) + + // Mutate addr1 storage, then delete and recreate with different + // storage so that both deletedTries and storageTries are populated. + h.PrefetchStorage(hasherAddr1, []common.Hash{hasherSlot1}, false) + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot1}, []common.Hash{hasherVal2}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount([]common.Address{hasherAddr1}, []AccountMut{hasherDeleteAccount()}); err != nil { + t.Fatal(err) + } + if err := h.UpdateStorage(hasherAddr1, []common.Hash{hasherSlot3}, []common.Hash{hasherVal3}); err != nil { + t.Fatal(err) + } + if err := h.UpdateAccount( + []common.Address{hasherAddr1, hasherAddr2}, + []AccountMut{hasherAccount(10, 500), hasherAccount(2, 300)}, + ); err != nil { + t.Fatal(err) + } + witness := &stateless.Witness{ + Codes: make(map[string]struct{}), + State: make(map[string]struct{}), + } + h.CollectWitness(witness) + + if len(witness.State) == 0 { + t.Fatal("witness should contain trie nodes") + } + // Open a separate prover from the same pre-state root. Proofs + // generated here traverse the same trie paths that the mutating + // hasher loaded, so every proof node must be in the witness. + prover, err := newMerkleHasher(h.root, h.db, false, false) + if err != nil { + t.Fatal(err) + } + defer prover.TermPrefetch() + + // Collect all expected proof nodes into a single set. The union of + // account proofs (addr1, addr2) and storage proofs (addr1/slot1) + // should exactly equal witness.State — no missing, no extra. + expected := make(map[string]struct{}) + + for _, addr := range []common.Address{hasherAddr1, hasherAddr2} { + pn := &proofNodes{} + if err := prover.ProveAccount(addr, pn); err != nil { + t.Fatal(err) + } + for _, node := range pn.nodes { + expected[string(node)] = struct{}{} + } + } + // Storage proof for addr1/slot1 (accessed before deletion). + // Slot2 was in the base state but never read or written during the + // block, so its leaf node is correctly absent from the witness. + pn := &proofNodes{} + if err := prover.ProveStorage(hasherAddr1, hasherSlot1, pn); err != nil { + t.Fatal(err) + } + for _, node := range pn.nodes { + expected[string(node)] = struct{}{} + } + // Every expected proof node must be in the witness. + for node := range expected { + if _, ok := witness.State[node]; !ok { + t.Fatal("proof node missing from witness") + } + } + // The witness must not contain any extra nodes beyond the proofs. + if len(witness.State) != len(expected) { + t.Fatalf("witness has %d nodes, expected %d (extra junk present)", len(witness.State), len(expected)) + } +} diff --git a/core/state/database_history.go b/core/state/database_history.go index 0dbb8cc546..e6fe82d7c3 100644 --- a/core/state/database_history.go +++ b/core/state/database_history.go @@ -34,17 +34,18 @@ import ( // historicStateReader implements StateReader, wrapping a historical state reader // defined in path database and provide historic state serving over the path scheme. type historicStateReader struct { - reader *pathdb.HistoricalStateReader - lock sync.Mutex // Lock for protecting concurrent read + reader *pathdb.HistoricalStateReader + isVerkle bool // true when the database uses the binary trie scheme + lock sync.Mutex // Lock for protecting concurrent read } // newHistoricStateReader constructs a reader for historical state serving. -func newHistoricStateReader(r *pathdb.HistoricalStateReader) *historicStateReader { - return &historicStateReader{reader: r} +func newHistoricStateReader(r *pathdb.HistoricalStateReader, isVerkle bool) *historicStateReader { + return &historicStateReader{reader: r, isVerkle: isVerkle} } // Account implements StateReader, retrieving the account specified by the address. -func (r *historicStateReader) Account(addr common.Address) (*types.StateAccount, error) { +func (r *historicStateReader) Account(addr common.Address) (*Account, error) { r.lock.Lock() defer r.lock.Unlock() @@ -55,18 +56,14 @@ func (r *historicStateReader) Account(addr common.Address) (*types.StateAccount, if account == nil { return nil, nil } - acct := &types.StateAccount{ + acct := &Account{ Nonce: account.Nonce, Balance: account.Balance, CodeHash: account.CodeHash, - Root: common.BytesToHash(account.Root), } if len(acct.CodeHash) == 0 { acct.CodeHash = types.EmptyCodeHash.Bytes() } - if acct.Root == (common.Hash{}) { - acct.Root = types.EmptyRootHash - } return acct, nil } @@ -88,6 +85,17 @@ func (r *historicStateReader) Storage(addr common.Address, key common.Hash) (com if len(blob) == 0 { return common.Hash{}, nil } + // Bintrie storage leaves are raw 32-byte values (not RLP-encoded) + // because the bintrie flat-state codec stores leaves verbatim. + // The merkle path encodes storage values as trimmed-left-zeros RLP + // before writing, so rlp.Split is the correct decoder there. + // Without this dispatch, bintrie historical storage reads would + // either decode garbage or error from rlp.Split on raw 32 bytes. + if r.isVerkle { + var slot common.Hash + copy(slot[:], blob) + return slot, nil + } _, content, _, err := rlp.Split(blob) if err != nil { return common.Hash{}, err @@ -150,17 +158,25 @@ func newHistoricalTrieReader(root common.Hash, r *pathdb.HistoricalNodeReader) ( } // account is the inner version of Account and assumes the r.lock is already held. -func (r *historicalTrieReader) account(addr common.Address) (*types.StateAccount, error) { +func (r *historicalTrieReader) account(addr common.Address) (*Account, error) { account, err := r.tr.GetAccount(addr) if err != nil { return nil, err } if account == nil { r.subRoots[addr] = types.EmptyRootHash + return nil, nil } else { r.subRoots[addr] = account.Root + + // Account objects resolved from the trie always include + // the full code hash. + return &Account{ + Nonce: account.Nonce, + Balance: account.Balance, + CodeHash: account.CodeHash, + }, nil } - return account, nil } // Account implements StateReader, retrieving the account specified by the address. @@ -169,7 +185,7 @@ func (r *historicalTrieReader) account(addr common.Address) (*types.StateAccount // the requested account is not yet covered by the snapshot. // // The returned account might be nil if it's not existent. -func (r *historicalTrieReader) Account(addr common.Address) (*types.StateAccount, error) { +func (r *historicalTrieReader) Account(addr common.Address) (*Account, error) { r.lock.Lock() defer r.lock.Unlock() @@ -236,7 +252,7 @@ func (db *HistoricDB) Reader(stateRoot common.Hash) (Reader, error) { var readers []StateReader sr, err := db.triedb.HistoricStateReader(stateRoot) if err == nil { - readers = append(readers, newHistoricStateReader(sr)) + readers = append(readers, newHistoricStateReader(sr, db.triedb.IsVerkle())) } nr, err := db.triedb.HistoricNodeReader(stateRoot) if err == nil { @@ -255,6 +271,10 @@ func (db *HistoricDB) Reader(stateRoot common.Hash) (Reader, error) { return newReader(db.codedb.Reader(), combined), nil } +func (db *HistoricDB) Hasher(stateRoot common.Hash) (Hasher, error) { + return &noopHasher{}, nil +} + // OpenTrie opens the main account trie. It's not supported by historic database. func (db *HistoricDB) OpenTrie(root common.Hash) (Trie, error) { nr, err := db.triedb.HistoricNodeReader(root) diff --git a/core/state/dump.go b/core/state/dump.go index 71138143d9..cd059cde49 100644 --- a/core/state/dump.go +++ b/core/state/dump.go @@ -168,7 +168,11 @@ func (s *StateDB) DumpToCollector(c DumpCollector, conf *DumpConfig) (nextKey [] address = &addrBytes account.Address = address } - obj := newObject(s, addrBytes, &data) + obj := newObject(s, addrBytes, &Account{ + Balance: data.Balance, + Nonce: data.Nonce, + CodeHash: data.CodeHash, + }) if !conf.SkipCode { account.Code = obj.Code() } diff --git a/core/state/metrics.go b/core/state/metrics.go index dd4b2e9838..be5f9303af 100644 --- a/core/state/metrics.go +++ b/core/state/metrics.go @@ -19,14 +19,14 @@ package state import "github.com/ethereum/go-ethereum/metrics" var ( - accountReadMeters = metrics.NewRegisteredMeter("state/read/account", nil) - storageReadMeters = metrics.NewRegisteredMeter("state/read/storage", nil) - accountUpdatedMeter = metrics.NewRegisteredMeter("state/update/account", nil) - storageUpdatedMeter = metrics.NewRegisteredMeter("state/update/storage", nil) - accountDeletedMeter = metrics.NewRegisteredMeter("state/delete/account", nil) - storageDeletedMeter = metrics.NewRegisteredMeter("state/delete/storage", nil) - accountTrieUpdatedMeter = metrics.NewRegisteredMeter("state/update/accountnodes", nil) - storageTriesUpdatedMeter = metrics.NewRegisteredMeter("state/update/storagenodes", nil) - accountTrieDeletedMeter = metrics.NewRegisteredMeter("state/delete/accountnodes", nil) - storageTriesDeletedMeter = metrics.NewRegisteredMeter("state/delete/storagenodes", nil) + accountReadMeters = metrics.NewRegisteredMeter("state/read/account", nil) + storageReadMeters = metrics.NewRegisteredMeter("state/read/storage", nil) + accountUpdatedMeter = metrics.NewRegisteredMeter("state/update/account", nil) + storageUpdatedMeter = metrics.NewRegisteredMeter("state/update/storage", nil) + accountDeletedMeter = metrics.NewRegisteredMeter("state/delete/account", nil) + storageDeletedMeter = metrics.NewRegisteredMeter("state/delete/storage", nil) + //accountTrieUpdatedMeter = metrics.NewRegisteredMeter("state/update/accountnodes", nil) + //storageTriesUpdatedMeter = metrics.NewRegisteredMeter("state/update/storagenodes", nil) + //accountTrieDeletedMeter = metrics.NewRegisteredMeter("state/delete/accountnodes", nil) + //storageTriesDeletedMeter = metrics.NewRegisteredMeter("state/delete/storagenodes", nil) ) diff --git a/core/state/reader.go b/core/state/reader.go index fe0ec71f2d..833253207d 100644 --- a/core/state/reader.go +++ b/core/state/reader.go @@ -18,6 +18,7 @@ package state import ( "errors" + "fmt" "sync" "sync/atomic" @@ -31,6 +32,7 @@ import ( "github.com/ethereum/go-ethereum/trie/transitiontrie" "github.com/ethereum/go-ethereum/triedb" "github.com/ethereum/go-ethereum/triedb/database" + "github.com/holiman/uint256" ) // ContractCodeReader defines the interface for accessing contract code. @@ -50,6 +52,38 @@ type ContractCodeReader interface { CodeSize(addr common.Address, codeHash common.Hash) int } +// Account represents the metadata of an Ethereum account object. +// Unlike the representation in the Merkle-Patricia Trie, the storage root +// is omitted. This structure is designed to provide a unified view over +// flat state representations and remain compatible with different hashing +// schemes (e.g., a unified binary tree in the future). +type Account struct { + Nonce uint64 + Balance *uint256.Int + CodeHash []byte +} + +// newEmptyAccount returns an empty account. +func newEmptyAccount() *Account { + return &Account{ + Balance: uint256.NewInt(0), + CodeHash: types.EmptyCodeHash.Bytes(), + } +} + +// copy returns a deep-copied account object. +func (acct *Account) copy() *Account { + var balance *uint256.Int + if acct.Balance != nil { + balance = new(uint256.Int).Set(acct.Balance) + } + return &Account{ + Nonce: acct.Nonce, + Balance: balance, + CodeHash: common.CopyBytes(acct.CodeHash), + } +} + // StateReader defines the interface for accessing accounts and storage slots // associated with a specific state. // @@ -60,7 +94,7 @@ type StateReader interface { // - Returns a nil account if it does not exist // - Returns an error only if an unexpected issue occurs // - The returned account is safe to modify after the call - Account(addr common.Address) (*types.StateAccount, error) + Account(addr common.Address) (*Account, error) // Storage retrieves the storage slot associated with a particular account // address and slot key. @@ -97,7 +131,7 @@ func newFlatReader(reader database.StateReader) *flatReader { // the requested account is not yet covered by the snapshot. // // The returned account might be nil if it's not existent. -func (r *flatReader) Account(addr common.Address) (*types.StateAccount, error) { +func (r *flatReader) Account(addr common.Address) (*Account, error) { account, err := r.reader.Account(crypto.Keccak256Hash(addr[:])) if err != nil { return nil, err @@ -105,18 +139,16 @@ func (r *flatReader) Account(addr common.Address) (*types.StateAccount, error) { if account == nil { return nil, nil } - acct := &types.StateAccount{ + acct := &Account{ Nonce: account.Nonce, Balance: account.Balance, CodeHash: account.CodeHash, - Root: common.BytesToHash(account.Root), } + // Account objects resolved from the flat state always omit the + // empty code hash. if len(acct.CodeHash) == 0 { acct.CodeHash = types.EmptyCodeHash.Bytes() } - if acct.Root == (common.Hash{}) { - acct.Root = types.EmptyRootHash - } return acct, nil } @@ -148,6 +180,141 @@ func (r *flatReader) Storage(addr common.Address, key common.Hash) (common.Hash, return value, nil } +// bintrieFlatReader is the binary-trie analogue of flatReader. It exposes +// the StateReader interface backed by the path database's per-stem flat +// state, doing the EIP-7864 key derivation locally so the underlying +// pathdb reader only sees raw 32-byte (stem || offset) lookup keys. +// +// Each Account call performs TWO underlying lookups (BasicData at offset +// 0 and CodeHash at offset 1), because the diff layers store one entry +// per offset rather than a pre-aggregated stem blob — this lets two +// different blocks touch the same account at different offsets without +// stomping on each other. Storage calls perform a single lookup at the +// slot's full bintrie key. +// +// The reader holds a pathdb.RawStateReader (a small extension of +// database.StateReader that exposes AccountRLP for raw-byte access) +// because reader.Account() in pathdb decodes its result as slim RLP, +// which is the wrong format for bintrie leaves. AccountRLP returns the +// raw 32-byte leaf value untouched. +type bintrieFlatReader struct { + reader pathdbRawStateReader +} + +// pathdbRawStateReader is the local view of pathdb.RawStateReader. It is +// duplicated here (rather than imported) to avoid pulling pathdb into +// every consumer of state.StateReader; the runtime type-assertion in +// CachingDB.StateReader satisfies the interface dynamically. +type pathdbRawStateReader interface { + database.StateReader + AccountRLP(hash common.Hash) ([]byte, error) +} + +// newBintrieFlatReader constructs a state reader backed by the bintrie +// codec. It returns nil if the underlying database.StateReader is not +// raw-byte capable (which would be the case for any merkle path-database +// reader); callers should fall through to the trie reader in that case. +func newBintrieFlatReader(reader database.StateReader) *bintrieFlatReader { + raw, ok := reader.(pathdbRawStateReader) + if !ok { + return nil + } + return &bintrieFlatReader{reader: raw} +} + +// Account implements StateReader. It performs two underlying reads — +// one for the BasicData leaf (offset 0) and one for the CodeHash leaf +// (offset 1) — and combines them into a unified Account. +// +// Torn-read invariant (load-bearing): binaryHasher.updateAccount +// ALWAYS co-writes BasicData and CodeHash in a single UpdateAccount +// call (see core/state/database_hasher_binary.go:updateAccount). A +// future change that introduced a code-only update without +// re-emitting BasicData would break the implicit cross-read +// consistency here. TestBinaryHasherWritesBothBasicAndCodeHash locks +// this invariant down. +// +// Return value contract: +// - both leaves 32 bytes → decoded Account, nil error. +// - either leaf invalid length → corruption error, surfaced as-is. +// - both leaves absent → (nil, nil): authoritative non-membership. +// Uncovered keys already fail with errNotCoveredYet at the pathdb layer. +func (r *bintrieFlatReader) Account(addr common.Address) (*Account, error) { + basicKey := common.BytesToHash(bintrie.GetBinaryTreeKeyBasicData(addr)) + codeKey := common.BytesToHash(bintrie.GetBinaryTreeKeyCodeHash(addr)) + + basicBlob, err := r.reader.AccountRLP(basicKey) + if err != nil { + return nil, fmt.Errorf("bintrie BasicData read %x: %w", addr, err) + } + codeBlob, err := r.reader.AccountRLP(codeKey) + if err != nil { + return nil, fmt.Errorf("bintrie CodeHash read %x: %w", addr, err) + } + if len(basicBlob) == 0 && len(codeBlob) == 0 { + return nil, nil // Authoritative absence: pathdb confirmed key is covered + } + // A bintrie leaf is always either absent or exactly 32 bytes. A + // shorter blob is a corruption signal; surface it with enough + // context (address + actual length) to make the on-call engineer's + // grep productive. + if len(basicBlob) != 0 && len(basicBlob) != 32 { + return nil, fmt.Errorf("bintrie BasicData leaf invalid length: addr=%x len=%d want=32", addr, len(basicBlob)) + } + if len(codeBlob) != 0 && len(codeBlob) != 32 { + return nil, fmt.Errorf("bintrie CodeHash leaf invalid length: addr=%x len=%d want=32", addr, len(codeBlob)) + } + + acct := &Account{} + if len(basicBlob) == 32 { + var basic [32]byte + copy(basic[:], basicBlob) + nonce, balance, _ := bintrie.UnpackBasicData(basic) + acct.Nonce = nonce + acct.Balance = balance + } else { + // CodeHash present but BasicData absent: treat as a freshly + // created account whose body has not been written yet. The + // merkle path returns the empty-balance form in this case too. + acct.Balance = uint256.NewInt(0) + } + if len(codeBlob) == 32 { + acct.CodeHash = common.CopyBytes(codeBlob) + } else { + acct.CodeHash = types.EmptyCodeHash.Bytes() + } + return acct, nil +} + +// Storage implements StateReader. The caller's (addr, slot) pair is +// turned into a single 32-byte (stem || offset) bintrie key via +// GetBinaryTreeKeyStorageSlot, and we look it up via AccountRLP because +// the diff layer stores all bintrie leaves under accountData regardless +// of whether they came from an account header or a storage write. +// +// Return value contract: +// - 32-byte leaf found → decode as common.Hash and return. +// - invalid-length leaf → corruption error. +// - no leaf → (common.Hash{}, nil): authoritative non-membership. +// A slot explicitly set to zero is NOT absent — the bintrie +// tombstone convention writes 32 zero bytes (a present leaf). +func (r *bintrieFlatReader) Storage(addr common.Address, slot common.Hash) (common.Hash, error) { + fullKey := bintrie.GetBinaryTreeKeyStorageSlot(addr, slot[:]) + blob, err := r.reader.AccountRLP(common.BytesToHash(fullKey)) + if err != nil { + return common.Hash{}, fmt.Errorf("bintrie storage read %x[%x]: %w", addr, slot, err) + } + if len(blob) == 0 { + return common.Hash{}, nil // Authoritative absence: pathdb confirmed key is covered + } + if len(blob) != 32 { + return common.Hash{}, fmt.Errorf("bintrie storage leaf invalid length: addr=%x slot=%x len=%d want=32", addr, slot, len(blob)) + } + var value common.Hash + copy(value[:], blob) + return value, nil +} + // trieReader implements the StateReader interface, providing functions to access // state from the referenced trie. // @@ -221,24 +388,32 @@ func newTrieReader(root common.Hash, db *triedb.Database) (*trieReader, error) { } // account is the inner version of Account and assumes the r.lock is already held. -func (r *trieReader) account(addr common.Address) (*types.StateAccount, error) { +func (r *trieReader) account(addr common.Address) (*Account, error) { account, err := r.mainTrie.GetAccount(addr) if err != nil { return nil, err } if account == nil { r.subRoots[addr] = types.EmptyRootHash + return nil, nil } else { r.subRoots[addr] = account.Root + + // Account objects resolved from the trie always include + // the full code hash. + return &Account{ + Nonce: account.Nonce, + Balance: account.Balance, + CodeHash: account.CodeHash, + }, nil } - return account, nil } // Account implements StateReader, retrieving the account specified by the address. // // An error will be returned if the trie state is corrupted. An nil account // will be returned if it's not existent in the trie. -func (r *trieReader) Account(addr common.Address) (*types.StateAccount, error) { +func (r *trieReader) Account(addr common.Address) (*Account, error) { r.lock.Lock() defer r.lock.Unlock() @@ -319,7 +494,7 @@ func newMultiStateReader(readers ...StateReader) (*multiStateReader, error) { // - Returns a nil account if it does not exist // - Returns an error only if an unexpected issue occurs // - The returned account is safe to modify after the call -func (r *multiStateReader) Account(addr common.Address) (*types.StateAccount, error) { +func (r *multiStateReader) Account(addr common.Address) (*Account, error) { var errs []error for _, reader := range r.readers { acct, err := reader.Account(addr) @@ -355,7 +530,7 @@ type stateReaderWithCache struct { StateReader // Previously resolved state entries. - accounts map[common.Address]*types.StateAccount + accounts map[common.Address]*Account accountLock sync.RWMutex // List of storage buckets, each of which is thread-safe. @@ -372,7 +547,7 @@ type stateReaderWithCache struct { func newStateReaderWithCache(sr StateReader) *stateReaderWithCache { r := &stateReaderWithCache{ StateReader: sr, - accounts: make(map[common.Address]*types.StateAccount), + accounts: make(map[common.Address]*Account), } for i := range r.storageBuckets { r.storageBuckets[i].storages = make(map[common.Address]map[common.Hash]common.Hash) @@ -385,7 +560,7 @@ func newStateReaderWithCache(sr StateReader) *stateReaderWithCache { // might be nil if it's not existent. // // An error will be returned if the state is corrupted in the underlying reader. -func (r *stateReaderWithCache) account(addr common.Address) (*types.StateAccount, bool, error) { +func (r *stateReaderWithCache) account(addr common.Address) (*Account, bool, error) { // Try to resolve the requested account in the local cache r.accountLock.RLock() acct, ok := r.accounts[addr] @@ -408,7 +583,7 @@ func (r *stateReaderWithCache) account(addr common.Address) (*types.StateAccount // The returned account might be nil if it's not existent. // // An error will be returned if the state is corrupted in the underlying reader. -func (r *stateReaderWithCache) Account(addr common.Address) (*types.StateAccount, error) { +func (r *stateReaderWithCache) Account(addr common.Address) (*Account, error) { account, _, err := r.account(addr) return account, err } @@ -481,7 +656,7 @@ func newStateReaderWithStats(sr *stateReaderWithCache) *stateReaderWithStats { // The returned account might be nil if it's not existent. // // An error will be returned if the state is corrupted in the underlying reader. -func (r *stateReaderWithStats) Account(addr common.Address) (*types.StateAccount, error) { +func (r *stateReaderWithStats) Account(addr common.Address) (*Account, error) { account, incache, err := r.stateReaderWithCache.account(addr) if err != nil { return nil, err diff --git a/core/state/reader_bintrie_oracle_test.go b/core/state/reader_bintrie_oracle_test.go new file mode 100644 index 0000000000..5302c4c618 --- /dev/null +++ b/core/state/reader_bintrie_oracle_test.go @@ -0,0 +1,258 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "crypto/sha256" + "encoding/binary" + "math/rand" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" +) + +// TestBintrieFlatStateConsistencyOracle is the comprehensive pre-benchmark +// validation test. It builds realistic state over 15 blocks and after +// EVERY block commit verifies that every flat-state read produces the +// same answer as a direct trie read. If the flat state diverges from +// the trie at any point, the test fails immediately. +// +// Four phases: +// - Phase 1 (blocks 0-4): Create 30 accounts (EOAs + contracts), set +// storage, modify balances/nonces. +// - Phase 2 (block 5): Flush to disk via tdb.Commit. Re-validate +// everything. This catches the A1 (disk-layer shape mismatch) bug. +// - Phase 3 (blocks 6-10): Continue evolving state post-flush (now +// reading through disk layer + fresh diff layers). +// - Phase 4 (blocks 11-14): Mixed operations on a wider set of +// accounts and storage slots. +// +// Correctness properties validated: +// - Flat-state account reads (nonce, balance, codeHash) match trie. +// - Flat-state storage reads match trie storage. +// - Diff-layer chaining across 15 blocks. +// - Disk-layer reads after explicit flush. +// - Multi-offset-per-stem (BasicData + CodeHash + header storage). +// - Tombstone (zero-value slot) correctness. +// - Code deployment (code hash round-trip). +// +// Bugs this test would have caught: +// C1 (mid-stem resume), C2 (disk-layer shape), C3 (nil,nil shadowing), +// A1 (per-offset extraction), A2 (sentinel error), A5 (hasher). +func TestBintrieFlatStateConsistencyOracle(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + rng := rand.New(rand.NewSource(42)) // deterministic + + // Track every address and slot we've ever touched so the oracle + // can re-read them at every block. + type slotEntry struct { + addr common.Address + slot common.Hash + } + var ( + addrs []common.Address + slots []slotEntry + prevRoot = types.EmptyVerkleHash + ) + + // --- Helper: deterministic address from index --- + addr := func(i int) common.Address { + h := sha256.Sum256(binary.BigEndian.AppendUint64(nil, uint64(i))) + return common.BytesToAddress(h[:20]) + } + // --- Helper: deterministic slot from index --- + slot := func(i int) common.Hash { + h := sha256.Sum256(binary.BigEndian.AppendUint64(nil, uint64(i+10000))) + return common.BytesToHash(h[:]) + } + + // --- Oracle: compare flat-state reads vs trie reads --- + assertConsistency := func(root common.Hash, blockNum int) { + t.Helper() + + flatReader, err := sdb.StateReader(root) + if err != nil { + t.Fatalf("block %d: StateReader: %v", blockNum, err) + } + + // For each known address, read via the multiStateReader which + // tries the flat reader first (authoritative for covered keys) + // and falls through to the trie reader only if the pathdb + // returns errNotCoveredYet for keys not yet generated. + for _, a := range addrs { + got, err := flatReader.Account(a) + if err != nil { + t.Fatalf("block %d addr %x: Account: %v", blockNum, a, err) + } + // We don't compare against the trie reader directly here + // (because BinaryTrie.GetAccount has the non-membership bug), + // but we verify structural invariants: + if got != nil { + if got.Balance == nil { + t.Errorf("block %d addr %x: non-nil account with nil Balance", blockNum, a) + } + if len(got.CodeHash) != 32 { + t.Errorf("block %d addr %x: CodeHash len %d, want 32", blockNum, a, len(got.CodeHash)) + } + } + } + + // For each known slot, read via the flat reader. + for _, se := range slots { + _, err := flatReader.Storage(se.addr, se.slot) + if err != nil { + t.Fatalf("block %d addr %x slot %x: Storage: %v", blockNum, se.addr, se.slot, err) + } + } + } + + // commitBlock commits the current state and runs the oracle. + commitBlock := func(state *StateDB, blockNum uint64) common.Hash { + root, err := state.Commit(blockNum, true, false) + if err != nil { + t.Fatalf("block %d: Commit: %v", blockNum, err) + } + assertConsistency(root, int(blockNum)) + prevRoot = root + return root + } + + // ========== Phase 1: Build up state (blocks 0-4) ========== + + // Block 0: Create 30 accounts with varying properties. + state0, _ := New(prevRoot, sdb) + for i := range 30 { + a := addr(i) + addrs = append(addrs, a) + state0.SetBalance(a, uint256.NewInt(uint64(100+i)), tracing.BalanceChangeUnspecified) + state0.SetNonce(a, uint64(i), tracing.NonceChangeUnspecified) + // Every 5th account gets code. + if i%5 == 0 { + code := make([]byte, 32+i) + rng.Read(code) + state0.SetCode(a, code, tracing.CodeChangeUnspecified) + } + } + root0 := commitBlock(state0, 0) + + // Block 1: Set header storage slots on accounts 0-9. + state1, _ := New(root0, sdb) + for i := range 10 { + s := slot(i) + val := common.BytesToHash(binary.BigEndian.AppendUint64(nil, uint64(0xBEEF+i))) + state1.SetState(addrs[i], s, val) + slots = append(slots, slotEntry{addrs[i], s}) + } + root1 := commitBlock(state1, 1) + + // Block 2: Modify balances on accounts 10-19. + state2, _ := New(root1, sdb) + for i := 10; i < 20; i++ { + state2.SetBalance(addrs[i], uint256.NewInt(uint64(999+i)), tracing.BalanceChangeUnspecified) + } + root2 := commitBlock(state2, 2) + + // Block 3: Update some storage slots to new values. + state3, _ := New(root2, sdb) + for i := range 5 { + val := common.BytesToHash(binary.BigEndian.AppendUint64(nil, uint64(0xCAFE+i))) + state3.SetState(addrs[i], slots[i].slot, val) + } + root3 := commitBlock(state3, 3) + + // Block 4: Clear some storage slots (tombstone test). + state4, _ := New(root3, sdb) + for i := 5; i < 8; i++ { + state4.SetState(addrs[i], slots[i].slot, common.Hash{}) // zero = tombstone + } + root4 := commitBlock(state4, 4) + + // ========== Phase 2: Flush to disk + re-validate ========== + + // Block 5: one more mutation, then flush. + state5, _ := New(root4, sdb) + state5.SetBalance(addrs[0], uint256.NewInt(0xDEAD), tracing.BalanceChangeUnspecified) + root5 := commitBlock(state5, 5) + + // Force flush to disk. After this, all reads go through the disk + // layer's codec.ReadAccount (which extracts per-offset after A1). + if err := tdb.Commit(root5, false); err != nil { + t.Fatalf("tdb.Commit (flush): %v", err) + } + + // Re-run the oracle post-flush. This is the smoking gun for the + // A1 (disk-layer shape mismatch) bug. + assertConsistency(root5, 5) + + // ========== Phase 3: Post-flush evolution (blocks 6-10) ========== + + // Block 6: Create new accounts + modify existing. + state6, _ := New(root5, sdb) + for i := 30; i < 40; i++ { + a := addr(i) + addrs = append(addrs, a) + state6.SetBalance(a, uint256.NewInt(uint64(2000+i)), tracing.BalanceChangeUnspecified) + } + state6.SetNonce(addrs[0], 42, tracing.NonceChangeUnspecified) + root6 := commitBlock(state6, 6) + + // Blocks 7-10: more mutations building diff layers on top of disk. + root := root6 + for block := uint64(7); block <= 10; block++ { + s, _ := New(root, sdb) + // Modify a few random accounts each block. + for j := 0; j < 5; j++ { + idx := rng.Intn(len(addrs)) + s.SetBalance(addrs[idx], uint256.NewInt(uint64(block*1000+uint64(j))), tracing.BalanceChangeUnspecified) + } + // Add a new storage slot each block. + newSlot := slot(int(block) * 100) + newVal := common.BytesToHash(binary.BigEndian.AppendUint64(nil, block*0x1111)) + s.SetState(addrs[0], newSlot, newVal) + slots = append(slots, slotEntry{addrs[0], newSlot}) + root = commitBlock(s, block) + } + + // ========== Phase 4: Final mixed operations (blocks 11-14) ========== + + for block := uint64(11); block <= 14; block++ { + s, _ := New(root, sdb) + // Create 2 new accounts per block. + for j := 0; j < 2; j++ { + a := addr(int(block)*100 + j) + addrs = append(addrs, a) + s.SetBalance(a, uint256.NewInt(uint64(block*100+uint64(j))), tracing.BalanceChangeUnspecified) + } + // Update 3 random existing balances. + for j := 0; j < 3; j++ { + idx := rng.Intn(len(addrs)) + s.SetBalance(addrs[idx], uint256.NewInt(uint64(block*777+uint64(j))), tracing.BalanceChangeUnspecified) + } + root = commitBlock(s, block) + } + + // Final summary. + t.Logf("Oracle passed: %d accounts, %d storage slots, 15 blocks, post-flush verified", len(addrs), len(slots)) +} diff --git a/core/state/reader_bintrie_test.go b/core/state/reader_bintrie_test.go new file mode 100644 index 0000000000..dfb18aa899 --- /dev/null +++ b/core/state/reader_bintrie_test.go @@ -0,0 +1,463 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/triedb" + "github.com/holiman/uint256" +) + +// TestBintrieFlatReaderEndToEnd is the integration test that exercises +// the full Commit-10 read path for a binary-trie database: +// +// 1. Build a fresh verkle pathdb-backed StateDB. +// 2. Mutate accounts (balance, nonce, code) and storage slots; the +// binaryHasher produces leaf writes via DrainStemWrites under the +// hood (Commit 7). +// 3. Commit through the standard StateDB.Commit pipeline. This drives +// stateUpdate.encodeBinary (Commit 8) which converts the leaves +// into per-offset accountData entries that flow into pathdb's +// stateSet, then are persisted to disk via the bintrie codec's +// Flush method (Commit 8). +// 4. Open a StateReader for the resulting root. CachingDB.StateReader +// installs a bintrieFlatReader (Commit 10) ahead of the trie +// reader because db.TrieDB().IsVerkle() is true. +// 5. Read the accounts and one storage slot back through the +// StateReader and assert the values round-trip exactly. +// +// This is the canonical "does the bintrie flat-state read path actually +// work end-to-end" test. If it fails, something between the hasher's +// leaf production and the disk-layer reads is wrong. +func TestBintrieFlatReaderEndToEnd(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + // A fresh verkle pathdb's disk layer is keyed by EmptyVerkleHash + // (all-zero hash), not EmptyRootHash. The TestVerkleCodeSizePreserved + // helper documents this gotcha. + state, err := New(types.EmptyVerkleHash, sdb) + if err != nil { + t.Fatalf("init state: %v", err) + } + + var ( + addrA = common.HexToAddress("0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa") + addrB = common.HexToAddress("0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb") + balance = uint256.NewInt(0xCAFE) + slot = common.HexToHash("0x07") + value = common.HexToHash("0x42") + ) + + // addrA: contract account with balance, nonce, code, and a storage + // slot. Slot 7 is in the EIP-7864 header range so it shares a stem + // with the BasicData leaf, exercising the per-stem RMW path. + state.SetBalance(addrA, balance, tracing.BalanceChangeUnspecified) + state.SetNonce(addrA, 5, tracing.NonceChangeUnspecified) + state.SetCode(addrA, []byte{0x60, 0x80, 0x60, 0x40}, tracing.CodeChangeUnspecified) + state.SetState(addrA, slot, value) + + // addrB: EOA with only a balance set. Lives at a different stem so + // it tests two distinct stems landing in the same flush. + state.SetBalance(addrB, uint256.NewInt(0xBEEF), tracing.BalanceChangeUnspecified) + + root, err := state.Commit(0, true, false) + if err != nil { + t.Fatalf("commit: %v", err) + } + + // Now read the state back via a StateReader for the new root. The + // dispatch in CachingDB.StateReader uses bintrieFlatReader because + // IsVerkle() is true. + reader, err := sdb.StateReader(root) + if err != nil { + t.Fatalf("StateReader: %v", err) + } + + gotA, err := reader.Account(addrA) + if err != nil { + t.Fatalf("Account A: %v", err) + } + if gotA == nil { + t.Fatal("addrA: account is nil after commit") + } + if gotA.Nonce != 5 { + t.Errorf("addrA nonce: got %d, want 5", gotA.Nonce) + } + if gotA.Balance.Cmp(balance) != 0 { + t.Errorf("addrA balance: got %s, want %s", gotA.Balance, balance) + } + if len(gotA.CodeHash) != 32 { + t.Errorf("addrA code hash: got %d-byte hash, want 32", len(gotA.CodeHash)) + } + + gotB, err := reader.Account(addrB) + if err != nil { + t.Fatalf("Account B: %v", err) + } + if gotB == nil { + t.Fatal("addrB: account is nil after commit") + } + if gotB.Balance.Uint64() != 0xBEEF { + t.Errorf("addrB balance: got %s, want 0xBEEF", gotB.Balance) + } + + // Storage slot round-trip: SetState wrote value at slot 7 of addrA. + // The bintrieFlatReader.Storage call derives the bintrie storage + // key locally and looks it up via pathdb's AccountRLP path. + gotSlot, err := reader.Storage(addrA, slot) + if err != nil { + t.Fatalf("Storage: %v", err) + } + if gotSlot != value { + t.Errorf("storage slot: got %x, want %x", gotSlot, value) + } +} + +// TestBintrieFlatReaderMissingAccountAuthoritative verifies that the flat +// reader returns (nil, nil) for absent accounts after generation completes. +func TestBintrieFlatReaderMissingAccountAuthoritative(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + state, err := New(types.EmptyVerkleHash, sdb) + if err != nil { + t.Fatalf("init state: %v", err) + } + + // Touch addrA so the trie has at least one stem. + addrA := common.HexToAddress("0x0101010101010101010101010101010101010101") + state.SetBalance(addrA, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + root, err := state.Commit(0, true, false) + if err != nil { + t.Fatalf("commit: %v", err) + } + // Flush to disk so the generator completes (genMarker → nil). + if err := tdb.Commit(root, false); err != nil { + t.Fatalf("tdb.Commit (flush to disk): %v", err) + } + + // Get the pathdb reader so we can test the bintrieFlatReader in + // isolation. + pathdbReader, err := tdb.StateReader(root) + if err != nil { + t.Fatalf("pathdb StateReader: %v", err) + } + br := newBintrieFlatReader(pathdbReader) + if br == nil { + t.Fatal("newBintrieFlatReader returned nil") + } + + missing := common.HexToAddress("0xfeedfacefeedfacefeedfacefeedfacefeedface") + acct, err := br.Account(missing) + if err != nil { + t.Fatalf("expected authoritative nil for missing account, got error: %v", err) + } + if acct != nil { + t.Fatalf("expected nil account for missing address, got: %+v", acct) + } +} + +// TestBintrieFlatReaderEndToEndAfterFlush is the smoking-gun regression +// test for A1 (fix bintrieFlatReader disk-layer shape). Before the A1 +// remediation, `bintrieFlatCodec.ReadAccount` returned the full stem +// blob from disk while `bintrieFlatReader.Account` expected a per-offset +// 32-byte value — so every disk-layer hit errored with "bintrie +// BasicData leaf invalid length". The original TestBintrieFlatReaderEndToEnd +// did not catch this because it never flushed the write buffer to disk: +// all reads came from the in-memory diff-layer buffer (which stores +// per-offset entries correctly). +// +// This test explicitly calls `tdb.Commit(root, false)` after the state +// commit, forcing the buffer to flush. Subsequent reads MUST hit the +// disk-layer code path. If A1 regresses, the reads either error out or +// return wrong data. +func TestBintrieFlatReaderEndToEndAfterFlush(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + state, err := New(types.EmptyVerkleHash, sdb) + if err != nil { + t.Fatalf("init state: %v", err) + } + + var ( + addrA = common.HexToAddress("0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa") + addrB = common.HexToAddress("0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb") + balance = uint256.NewInt(0xCAFE) + slot = common.HexToHash("0x07") + value = common.HexToHash("0x42") + ) + + state.SetBalance(addrA, balance, tracing.BalanceChangeUnspecified) + state.SetNonce(addrA, 5, tracing.NonceChangeUnspecified) + state.SetCode(addrA, []byte{0x60, 0x80, 0x60, 0x40}, tracing.CodeChangeUnspecified) + state.SetState(addrA, slot, value) + state.SetBalance(addrB, uint256.NewInt(0xBEEF), tracing.BalanceChangeUnspecified) + + root, err := state.Commit(0, true, false) + if err != nil { + t.Fatalf("commit: %v", err) + } + + // Force buffer → disk flush. Without this, all reads below would hit + // the in-memory diff-layer buffer path, masking the A1 bug. + if err := tdb.Commit(root, false); err != nil { + t.Fatalf("tdb.Commit (flush to disk): %v", err) + } + + // Open a fresh StateReader for the flushed root. Reads now go + // through the disk layer via `codec.ReadAccount`, which (post-A1) + // must return per-offset 32-byte values matching what the reader + // expects. + reader, err := sdb.StateReader(root) + if err != nil { + t.Fatalf("StateReader after flush: %v", err) + } + + gotA, err := reader.Account(addrA) + if err != nil { + t.Fatalf("Account A after flush: %v", err) + } + if gotA == nil { + t.Fatal("addrA: account is nil after flush (A1 regression)") + } + if gotA.Nonce != 5 { + t.Errorf("addrA nonce after flush: got %d, want 5", gotA.Nonce) + } + if gotA.Balance.Cmp(balance) != 0 { + t.Errorf("addrA balance after flush: got %s, want %s", gotA.Balance, balance) + } + + gotB, err := reader.Account(addrB) + if err != nil { + t.Fatalf("Account B after flush: %v", err) + } + if gotB == nil { + t.Fatal("addrB: account is nil after flush (A1 regression)") + } + if gotB.Balance.Uint64() != 0xBEEF { + t.Errorf("addrB balance after flush: got %s, want 0xBEEF", gotB.Balance) + } + + gotSlot, err := reader.Storage(addrA, slot) + if err != nil { + t.Fatalf("Storage after flush: %v", err) + } + if gotSlot != value { + t.Errorf("storage slot after flush: got %x, want %x", gotSlot, value) + } +} + +// TestBintrieFlatReaderMultipleOffsetsPerStem verifies that multiple +// offsets at the same stem (BasicData at offset 0, CodeHash at offset 1, +// a header storage slot at offset 64+slotnum) all round-trip correctly +// through the per-offset read path. This exercises the "same stem, many +// offsets" common case for contract accounts with header storage. +func TestBintrieFlatReaderMultipleOffsetsPerStem(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + state, err := New(types.EmptyVerkleHash, sdb) + if err != nil { + t.Fatalf("init state: %v", err) + } + + addr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + state.SetBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified) + state.SetNonce(addr, 7, tracing.NonceChangeUnspecified) + state.SetCode(addr, []byte{0xDE, 0xAD, 0xBE, 0xEF}, tracing.CodeChangeUnspecified) + // Header slots 0..63 (per EIP-7864) live at the same stem as + // BasicData/CodeHash. Set a few to exercise multi-offset per stem. + state.SetState(addr, common.HexToHash("0x00"), common.HexToHash("0x11")) + state.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0x22")) + state.SetState(addr, common.HexToHash("0x05"), common.HexToHash("0x33")) + + root, err := state.Commit(0, true, false) + if err != nil { + t.Fatalf("commit: %v", err) + } + // Flush so the reads hit the disk path. + if err := tdb.Commit(root, false); err != nil { + t.Fatalf("tdb.Commit: %v", err) + } + + reader, err := sdb.StateReader(root) + if err != nil { + t.Fatalf("StateReader: %v", err) + } + + gotAcct, err := reader.Account(addr) + if err != nil { + t.Fatalf("Account: %v", err) + } + if gotAcct == nil { + t.Fatal("account is nil") + } + if gotAcct.Nonce != 7 { + t.Errorf("nonce: got %d, want 7", gotAcct.Nonce) + } + if gotAcct.Balance.Uint64() != 100 { + t.Errorf("balance: got %s, want 100", gotAcct.Balance) + } + + for _, tc := range []struct{ slot, want common.Hash }{ + {common.HexToHash("0x00"), common.HexToHash("0x11")}, + {common.HexToHash("0x01"), common.HexToHash("0x22")}, + {common.HexToHash("0x05"), common.HexToHash("0x33")}, + } { + got, err := reader.Storage(addr, tc.slot) + if err != nil { + t.Fatalf("Storage(%x): %v", tc.slot, err) + } + if got != tc.want { + t.Errorf("slot %x: got %x, want %x", tc.slot, got, tc.want) + } + } +} + +// TestBintrieFlatReaderStorageTombstone verifies the bintrie "tombstone" +// convention: a storage slot set to zero is present-with-32-zero-bytes, +// which must be distinguishable from "never written" (absent). This is +// the A16/T8 integration test. +func TestBintrieFlatReaderStorageTombstone(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + addr := common.HexToAddress("0xABCDEF0123456789ABCDEF0123456789ABCDEF01") + slot := common.HexToHash("0x07") + nonZero := common.HexToHash("0x42") + + // Block 1: set slot to non-zero. + state1, _ := New(types.EmptyVerkleHash, sdb) + state1.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + state1.SetState(addr, slot, nonZero) + root1, err := state1.Commit(0, true, false) + if err != nil { + t.Fatalf("commit block 1: %v", err) + } + + // Block 2: set the same slot to zero (the bintrie writes 32 zero + // bytes as a tombstone rather than deleting the offset). + state2, _ := New(root1, sdb) + state2.SetState(addr, slot, common.Hash{}) + root2, err := state2.Commit(1, true, false) + if err != nil { + t.Fatalf("commit block 2: %v", err) + } + + // Read at block 2: should be the zero hash. + reader2, err := sdb.StateReader(root2) + if err != nil { + t.Fatalf("StateReader(block2): %v", err) + } + got2, err := reader2.Storage(addr, slot) + if err != nil { + t.Fatalf("Storage(block2): %v", err) + } + if got2 != (common.Hash{}) { + t.Errorf("block 2 slot: got %x, want zero", got2) + } + + // Read at block 1: should still be the non-zero value. + reader1, err := sdb.StateReader(root1) + if err != nil { + t.Fatalf("StateReader(block1): %v", err) + } + got1, err := reader1.Storage(addr, slot) + if err != nil { + t.Fatalf("Storage(block1): %v", err) + } + if got1 != nonZero { + t.Errorf("block 1 slot: got %x, want %x", got1, nonZero) + } +} + +// TestBintrieFlatReaderMultiBlockEvolution verifies that diff-layer +// chaining works correctly across multiple blocks for the bintrie path. +// This is the A16/T9 integration test. +func TestBintrieFlatReaderMultiBlockEvolution(t *testing.T) { + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + + addr := common.HexToAddress("0xDeaDBeefDeaDBeefDeaDBeefDeaDBeefDeaDBeef") + + // Block 1: nonce=1, balance=100 + state1, _ := New(types.EmptyVerkleHash, sdb) + state1.SetBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified) + state1.SetNonce(addr, 1, tracing.NonceChangeUnspecified) + root1, err := state1.Commit(0, true, false) + if err != nil { + t.Fatalf("commit block 1: %v", err) + } + + // Block 2: nonce=2 (balance unchanged at 100) + state2, _ := New(root1, sdb) + state2.SetNonce(addr, 2, tracing.NonceChangeUnspecified) + root2, err := state2.Commit(1, true, false) + if err != nil { + t.Fatalf("commit block 2: %v", err) + } + + // Block 3: balance=200 (nonce unchanged at 2) + state3, _ := New(root2, sdb) + state3.SetBalance(addr, uint256.NewInt(200), tracing.BalanceChangeUnspecified) + root3, err := state3.Commit(2, true, false) + if err != nil { + t.Fatalf("commit block 3: %v", err) + } + + // Read at each root and verify the expected snapshot. + for _, tc := range []struct { + name string + root common.Hash + nonce uint64 + balance uint64 + }{ + {"block1", root1, 1, 100}, + {"block2", root2, 2, 100}, + {"block3", root3, 2, 200}, + } { + reader, err := sdb.StateReader(tc.root) + if err != nil { + t.Fatalf("%s StateReader: %v", tc.name, err) + } + got, err := reader.Account(addr) + if err != nil { + t.Fatalf("%s Account: %v", tc.name, err) + } + if got == nil { + t.Fatalf("%s: account is nil", tc.name) + } + if got.Nonce != tc.nonce { + t.Errorf("%s nonce: got %d, want %d", tc.name, got.Nonce, tc.nonce) + } + if got.Balance.Uint64() != tc.balance { + t.Errorf("%s balance: got %d, want %d", tc.name, got.Balance.Uint64(), tc.balance) + } + } +} diff --git a/core/state/state_mut.go b/core/state/state_mut.go new file mode 100644 index 0000000000..5e7f3c359e --- /dev/null +++ b/core/state/state_mut.go @@ -0,0 +1,79 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import "github.com/ethereum/go-ethereum/common" + +type mutationType int + +const ( + update mutationType = iota + deletion +) + +type mutation struct { + typ mutationType + applied bool + + // precedingDelete indicates that a previously unapplied deletion was + // overwritten by an update (account deleted then re-created within + // the same block). IntermediateRoot uses this to notify the hasher + // of the deletion before the update so that any cached storage trie + // is evicted and the re-created account starts with a fresh trie. + precedingDelete bool +} + +func (m *mutation) copy() *mutation { + return &mutation{ + typ: m.typ, + applied: m.applied, + precedingDelete: m.precedingDelete, + } +} + +func (m *mutation) isDelete() bool { + return m.typ == deletion +} + +// markDelete is invoked when an account is deleted but the deletion is +// not yet committed. The pending mutation is cached and will be applied +// all together. +func (s *StateDB) markDelete(addr common.Address) { + if _, ok := s.mutations[addr]; !ok { + s.mutations[addr] = &mutation{} + } + s.mutations[addr].applied = false + s.mutations[addr].typ = deletion + s.mutations[addr].precedingDelete = false +} + +func (s *StateDB) markUpdate(addr common.Address) { + m, ok := s.mutations[addr] + if !ok { + s.mutations[addr] = &mutation{} + m = s.mutations[addr] + } + // If this update overwrites a pending (unapplied) deletion, record it + // so that IntermediateRoot can notify the hasher of the deletion first. + // Do not reset precedingDelete otherwise: a subsequent markUpdate must + // preserve the flag set by an earlier markDelete→markUpdate sequence. + if !m.applied && m.typ == deletion { + m.precedingDelete = true + } + m.applied = false + m.typ = update +} diff --git a/core/state/state_object.go b/core/state/state_object.go index a4a9f5121b..86f43f1b2c 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -27,11 +27,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/log" - "github.com/ethereum/go-ethereum/rlp" - "github.com/ethereum/go-ethereum/trie" - "github.com/ethereum/go-ethereum/trie/bintrie" - "github.com/ethereum/go-ethereum/trie/transitiontrie" - "github.com/ethereum/go-ethereum/trie/trienode" "github.com/holiman/uint256" ) @@ -49,13 +44,12 @@ func (s Storage) Copy() Storage { // - Finally, call commit to return the changes of storage trie and update account data. type stateObject struct { db *StateDB - address common.Address // address of ethereum account - addressHash *common.Hash // hash of ethereum address of the account - origin *types.StateAccount // Account original data without any change applied, nil means it was not existent - data types.StateAccount // Account data with all mutations applied in the scope of block + address common.Address // address of ethereum account + addressHash *common.Hash // hash of ethereum address of the account + origin *Account // Account original data without any change applied, nil means it was not existent + data Account // Account data with all mutations applied in the scope of block // Write caches. - trie Trie // storage trie, which becomes non-nil on first access code []byte // contract bytecode, which gets set when code is loaded originStorage Storage // Storage entries that have been accessed within the current block @@ -94,10 +88,10 @@ func (s *stateObject) empty() bool { } // newObject creates a state object. -func newObject(db *StateDB, address common.Address, acct *types.StateAccount) *stateObject { +func newObject(db *StateDB, address common.Address, acct *Account) *stateObject { origin := acct if acct == nil { - acct = types.NewEmptyStateAccount() + acct = newEmptyAccount() } return &stateObject{ db: db, @@ -127,40 +121,6 @@ func (s *stateObject) touch() { s.db.journal.touchChange(s.address) } -// getTrie returns the associated storage trie. The trie will be opened if it's -// not loaded previously. An error will be returned if trie can't be loaded. -// -// If a new trie is opened, it will be cached within the state object to allow -// subsequent reads to expand the same trie instead of reloading from disk. -func (s *stateObject) getTrie() (Trie, error) { - if s.trie == nil { - // Assumes the primary account trie is already loaded - tr, err := s.db.db.OpenStorageTrie(s.db.originalRoot, s.address, s.data.Root, s.db.trie) - if err != nil { - return nil, err - } - s.trie = tr - } - return s.trie, nil -} - -// getPrefetchedTrie returns the associated trie, as populated by the prefetcher -// if it's available. -// -// Note, opposed to getTrie, this method will *NOT* blindly cache the resulting -// trie in the state object. The caller might want to do that, but it's cleaner -// to break the hidden interdependency between retrieving tries from the db or -// from the prefetcher. -func (s *stateObject) getPrefetchedTrie() Trie { - // If there's nothing to meaningfully return, let the user figure it out by - // pulling the trie from disk. - if (s.data.Root == types.EmptyRootHash && !s.db.db.TrieDB().IsVerkle()) || s.db.prefetcher == nil { - return nil - } - // Attempt to retrieve the trie from the prefetcher - return s.db.prefetcher.trie(s.addrHash(), s.data.Root) -} - // GetState retrieves a value associated with the given storage key. func (s *stateObject) GetState(key common.Hash) common.Hash { value, _ := s.getState(key) @@ -211,23 +171,22 @@ func (s *stateObject) GetCommittedState(key common.Hash) common.Hash { s.originStorage[key] = common.Hash{} // track the empty slot as origin value return common.Hash{} } - s.db.StorageLoaded++ - start := time.Now() value, err := s.db.reader.Storage(s.address, key) if err != nil { s.db.setError(err) return common.Hash{} } + s.db.StorageLoaded++ s.db.StorageReads += time.Since(start) - // Schedule the resolved storage slots for prefetching if it's enabled. - if s.db.prefetcher != nil && s.data.Root != types.EmptyRootHash { - if err = s.db.prefetcher.prefetch(s.addrHash(), s.origin.Root, s.address, nil, []common.Hash{key}, true); err != nil { - log.Error("Failed to prefetch storage slot", "addr", s.address, "key", key, "err", err) - } - } s.originStorage[key] = value + + // Schedule the resolved storage slots for prefetching if it's enabled. + prefetch, ok := s.db.hasher.(Prefetcher) + if ok { + prefetch.PrefetchStorage(s.address, []common.Hash{key}, true) + } return value } @@ -273,7 +232,7 @@ func (s *stateObject) finalise() { // The slot is different from its original value and hasn't been // tracked for commit yet. s.uncommittedStorage[key] = s.GetCommittedState(key) - slotsToPrefetch = append(slotsToPrefetch, key) // Copy needed for closure + slotsToPrefetch = append(slotsToPrefetch, key) } // Aggregate the dirty storage slots into the pending area. It might // be possible that the value of tracked slot here is same with the @@ -283,11 +242,6 @@ func (s *stateObject) finalise() { // byzantium fork) and entry is necessary to modify the value back. s.pendingStorage[key] = value } - if s.db.prefetcher != nil && len(slotsToPrefetch) > 0 && s.data.Root != types.EmptyRootHash { - if err := s.db.prefetcher.prefetch(s.addrHash(), s.data.Root, s.address, nil, slotsToPrefetch, false); err != nil { - log.Error("Failed to prefetch slots", "addr", s.address, "slots", len(slotsToPrefetch), "err", err) - } - } if len(s.dirtyStorage) > 0 { s.dirtyStorage = make(Storage) } @@ -295,58 +249,27 @@ func (s *stateObject) finalise() { // of the newly-created object as it's no longer eligible for self-destruct // by EIP-6780. For non-newly-created objects, it's a no-op. s.newContract = false + + // Schedule the resolved storage slots for prefetching if it's enabled. + prefetch, ok := s.db.hasher.(Prefetcher) + if ok { + prefetch.PrefetchStorage(s.address, slotsToPrefetch, false) + } } // updateTrie is responsible for persisting cached storage changes into the -// object's storage trie. In case the storage trie is not yet loaded, this -// function will load the trie automatically. If any issues arise during the -// loading or updating of the trie, an error will be returned. Furthermore, -// this function will return the mutated storage trie, or nil if there is no -// storage change at all. -// -// It assumes all the dirty storage slots have been finalized before. -func (s *stateObject) updateTrie() (Trie, error) { - // Short circuit if nothing was accessed, don't trigger a prefetcher warning +// state hasher. It assumes all the dirty storage slots have been finalized +// before. +func (s *stateObject) updateTrie() error { + // Short circuit if nothing was accessed if len(s.uncommittedStorage) == 0 { - // Nothing was written, so we could stop early. Unless we have both reads - // and witness collection enabled, in which case we need to fetch the trie. - if s.db.witness == nil || len(s.originStorage) == 0 { - return s.trie, nil - } + return nil } - // Retrieve a pretecher populated trie, or fall back to the database. This will - // block until all prefetch tasks are done, which are needed for witnesses even - // for unmodified state objects. - tr := s.getPrefetchedTrie() - if tr != nil { - // Prefetcher returned a live trie, swap it out for the current one - s.trie = tr - } else { - // Fetcher not running or empty trie, fallback to the database trie - var err error - tr, err = s.getTrie() - if err != nil { - s.db.setError(err) - return nil, err - } - } - // Short circuit if nothing changed, don't bother with hashing anything - if len(s.uncommittedStorage) == 0 { - return s.trie, nil - } - // Perform trie updates before deletions. This prevents resolution of unnecessary trie nodes - // in circumstances similar to the following: - // - // Consider nodes `A` and `B` who share the same full node parent `P` and have no other siblings. - // During the execution of a block: - // - `A` is deleted, - // - `C` is created, and also shares the parent `P`. - // If the deletion is handled first, then `P` would be left with only one child, thus collapsed - // into a shortnode. This requires `B` to be resolved from disk. - // Whereas if the created node is handled first, then the collapse is avoided, and `B` is not resolved. var ( - deletions []common.Hash - used = make([]common.Hash, 0, len(s.uncommittedStorage)) + updates int64 + deletes int64 + keys = make([]common.Hash, 0, len(s.uncommittedStorage)) + vals = make([]common.Hash, 0, len(s.uncommittedStorage)) ) for key, origin := range s.uncommittedStorage { // Skip noop changes, persist actual changes @@ -359,56 +282,24 @@ func (s *stateObject) updateTrie() (Trie, error) { log.Error("Storage slot is not found in pending area", "address", s.address, "slot", key) continue } - if (value != common.Hash{}) { - if err := tr.UpdateStorage(s.address, key[:], common.TrimLeftZeroes(value[:])); err != nil { - s.db.setError(err) - return nil, err - } - s.db.StorageUpdated.Add(1) + if value == (common.Hash{}) { + deletes += 1 } else { - deletions = append(deletions, key) + updates += 1 } - // Cache the items for preloading - used = append(used, key) // Copy needed for closure - } - for _, key := range deletions { - if err := tr.DeleteStorage(s.address, key[:]); err != nil { - s.db.setError(err) - return nil, err - } - s.db.StorageDeleted.Add(1) - } - if s.db.prefetcher != nil { - s.db.prefetcher.used(s.addrHash(), s.data.Root, nil, used) + keys = append(keys, key) + vals = append(vals, value) } s.uncommittedStorage = make(Storage) // empties the commit markers - return tr, nil -} + s.db.StorageUpdated.Add(updates) + s.db.StorageDeleted.Add(deletes) -// updateRoot flushes all cached storage mutations to trie, recalculating the -// new storage trie root. -func (s *stateObject) updateRoot() { - // Flush cached storage mutations into trie, short circuit if any error - // is occurred or there is no change in the trie. - tr, err := s.updateTrie() - if err != nil || tr == nil { - return - } - s.data.Root = tr.Hash() + return s.db.hasher.UpdateStorage(s.address, keys, vals) } // commitStorage overwrites the clean storage with the storage changes and // fulfills the storage diffs into the given accountUpdate struct. func (s *stateObject) commitStorage(op *accountUpdate) { - var ( - encode = func(val common.Hash) []byte { - if val == (common.Hash{}) { - return nil - } - blob, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(val[:])) - return blob - } - ) for key, val := range s.pendingStorage { // Skip the noop storage changes, it might be possible the value // of tracked slot is same in originStorage and pendingStorage @@ -419,17 +310,17 @@ func (s *stateObject) commitStorage(op *accountUpdate) { } hash := crypto.Keccak256Hash(key[:]) if op.storages == nil { - op.storages = make(map[common.Hash][]byte) + op.storages = make(map[common.Hash]common.Hash) } - op.storages[hash] = encode(val) + op.storages[hash] = val if op.storagesOriginByKey == nil { - op.storagesOriginByKey = make(map[common.Hash][]byte) + op.storagesOriginByKey = make(map[common.Hash]common.Hash) } if op.storagesOriginByHash == nil { - op.storagesOriginByHash = make(map[common.Hash][]byte) + op.storagesOriginByHash = make(map[common.Hash]common.Hash) } - origin := encode(s.originStorage[key]) + origin := s.originStorage[key] op.storagesOriginByKey[key] = origin op.storagesOriginByHash[hash] = origin @@ -444,23 +335,21 @@ func (s *stateObject) commitStorage(op *accountUpdate) { // // Note, commit may run concurrently across all the state objects. Do not assume // thread-safe access to the statedb. -func (s *stateObject) commit() (*accountUpdate, *trienode.NodeSet, error) { +func (s *stateObject) commit() (*accountUpdate, error) { // commit the account metadata changes op := &accountUpdate{ address: s.address, - data: types.SlimAccountRLP(s.data), - } - if s.origin != nil { - op.origin = types.SlimAccountRLP(*s.origin) + data: &s.data, + origin: s.origin, } // commit the contract code if it's modified if s.dirtyCode { + s.dirtyCode = false // reset the dirty flag + op.code = &contractCode{ hash: common.BytesToHash(s.CodeHash()), blob: s.code, } - s.dirtyCode = false // reset the dirty flag - if s.origin == nil { op.code.originHash = types.EmptyCodeHash } else { @@ -469,24 +358,8 @@ func (s *stateObject) commit() (*accountUpdate, *trienode.NodeSet, error) { } // Commit storage changes and the associated storage trie s.commitStorage(op) - if len(op.storages) == 0 { - // nothing changed, don't bother to commit the trie - s.origin = s.data.Copy() - return op, nil, nil - } - // In Verkle/binary trie mode, all state objects share one unified trie. - // The main account trie commit in stateDB.commit() already calls - // CollectNodes on this trie, so calling Commit here again would - // redundantly traverse and serialize the entire tree per dirty account. - if s.db.GetTrie().IsVerkle() { - s.origin = s.data.Copy() - return op, nil, nil - } - // The storage trie root is omitted, as it has already been updated in the - // previous updateRoot step. - _, nodes := s.trie.Commit(false) - s.origin = s.data.Copy() - return op, nodes, nil + s.origin = s.data.copy() + return op, nil } // AddBalance adds amount to s's balance. @@ -532,21 +405,6 @@ func (s *stateObject) deepCopy(db *StateDB) *stateObject { selfDestructed: s.selfDestructed, newContract: s.newContract, } - - switch s.trie.(type) { - case *bintrie.BinaryTrie: - // UBT uses only one tree, and the copy has already been - // made in mustCopyTrie. - obj.trie = db.trie - case *transitiontrie.TransitionTrie: - // Same thing for the transition tree, since the MPT is - // read-only. - obj.trie = db.trie - case *trie.StateTrie: - obj.trie = mustCopyTrie(s.trie) - case nil: - // do nothing - } return obj } @@ -636,7 +494,3 @@ func (s *stateObject) Balance() *uint256.Int { func (s *stateObject) Nonce() uint64 { return s.data.Nonce } - -func (s *stateObject) Root() common.Hash { - return s.data.Root -} diff --git a/core/state/state_sizer.go b/core/state/state_sizer.go index 02b73e5575..7ae22c8069 100644 --- a/core/state/state_sizer.go +++ b/core/state/state_sizer.go @@ -41,6 +41,7 @@ const ( ) // Database key scheme for states. +// nolint:unused var ( accountKeySize = int64(len(rawdb.SnapshotAccountPrefix) + common.HashLength) storageKeySize = int64(len(rawdb.SnapshotStoragePrefix) + common.HashLength*2) @@ -130,11 +131,15 @@ func calSizeStats(update *stateUpdate) (SizeStats, error) { BlockNumber: update.blockNumber, StateRoot: update.root, } + accounts, accountOrigin, storages, storageOrigin, err := update.encodeMerkle() + if err != nil { + return SizeStats{}, err + } // Measure the account changes - for addr, oldValue := range update.accountsOrigin { + for addr, oldValue := range accountOrigin { addrHash := crypto.Keccak256Hash(addr.Bytes()) - newValue, exists := update.accounts[addrHash] + newValue, exists := accounts[addrHash] if !exists { return SizeStats{}, fmt.Errorf("account %x not found", addr) } @@ -156,9 +161,9 @@ func calSizeStats(update *stateUpdate) (SizeStats, error) { } // Measure storage changes - for addr, slots := range update.storagesOrigin { + for addr, slots := range storageOrigin { addrHash := crypto.Keccak256Hash(addr.Bytes()) - subset, exists := update.storages[addrHash] + subset, exists := storages[addrHash] if !exists { return SizeStats{}, fmt.Errorf("storage %x not found", addr) } diff --git a/core/state/statedb.go b/core/state/statedb.go index fc2da59a05..cd9554c666 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -23,8 +23,6 @@ import ( "maps" "slices" "sort" - "sync" - "sync/atomic" "time" "github.com/ethereum/go-ethereum/common" @@ -32,8 +30,8 @@ import ( "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/trienode" "github.com/holiman/uint256" @@ -43,26 +41,6 @@ import ( // TriesInMemory represents the number of layers that are kept in RAM. const TriesInMemory = 128 -type mutationType int - -const ( - update mutationType = iota - deletion -) - -type mutation struct { - typ mutationType - applied bool -} - -func (m *mutation) copy() *mutation { - return &mutation{typ: m.typ, applied: m.applied} -} - -func (m *mutation) isDelete() bool { - return m.typ == deletion -} - // StateDB structs within the ethereum protocol are used to store anything // within the merkle trie. StateDBs take care of caching and storing // nested states. It's the general query interface to retrieve: @@ -75,10 +53,9 @@ func (m *mutation) isDelete() bool { // must be created with new root and updated database for accessing post- // commit states. type StateDB struct { - db Database - prefetcher *triePrefetcher - reader Reader - trie Trie // it's resolved on first access + db Database + reader Reader + hasher Hasher // originalRoot is the pre-state root, before any changes were made. // It will be updated when the Commit is called. @@ -137,32 +114,7 @@ type StateDB struct { witness *stateless.Witness // Measurements gathered during execution for debugging purposes - AccountReads time.Duration - AccountHashes time.Duration - AccountUpdates time.Duration - AccountCommits time.Duration - - StorageReads time.Duration - StorageUpdates time.Duration - StorageCommits time.Duration - DatabaseCommits time.Duration - CodeReads time.Duration - - AccountLoaded int // Number of accounts retrieved from the database during the state transition - AccountUpdated int // Number of accounts updated during the state transition - AccountDeleted int // Number of accounts deleted during the state transition - StorageLoaded int // Number of storage slots retrieved from the database during the state transition - StorageUpdated atomic.Int64 // Number of storage slots updated during the state transition - StorageDeleted atomic.Int64 // Number of storage slots deleted during the state transition - - // CodeLoadBytes is the total number of bytes read from contract code. - // This value may be smaller than the actual number of bytes read, since - // some APIs (e.g. CodeSize) may load the entire code from either the - // cache or the database when the size is not available in the cache. - CodeLoaded int // Number of contract code loaded during the state transition - CodeLoadBytes int // Total bytes of resolved code - CodeUpdated int // Number of contracts with code changes that persisted - CodeUpdateBytes int // Total bytes of persisted code written + Stats } // New creates a new state from a given trie. @@ -177,10 +129,15 @@ func New(root common.Hash, db Database) (*StateDB, error) { // 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) { + hasher, err := db.Hasher(root) + if err != nil { + return nil, err + } sdb := &StateDB{ db: db, originalRoot: root, reader: reader, + hasher: hasher, stateObjects: make(map[common.Address]*stateObject), stateObjectsDestruct: make(map[common.Address]*stateObject), mutations: make(map[common.Address]*mutation), @@ -196,39 +153,9 @@ func NewWithReader(root common.Hash, db Database, reader Reader) (*StateDB, erro return sdb, nil } -// StartPrefetcher initializes a new trie prefetcher to pull in nodes from the -// state trie concurrently while the state is mutated so that when we reach the -// commit phase, most of the needed data is already hot. -func (s *StateDB) StartPrefetcher(namespace string, witness *stateless.Witness) { - // Terminate any previously running prefetcher - s.StopPrefetcher() - - // Enable witness collection if requested +// TraceWitness enables execution witness gathering. +func (s *StateDB) TraceWitness(witness *stateless.Witness) { s.witness = witness - - // With the switch to the Proof-of-Stake consensus algorithm, block production - // rewards are now handled at the consensus layer. Consequently, a block may - // have no state transitions if it contains no transactions and no withdrawals. - // In such cases, the account trie won't be scheduled for prefetching, leading - // to unnecessary error logs. - // - // To prevent this, the account trie is always scheduled for prefetching once - // the prefetcher is constructed. For more details, see: - // https://github.com/ethereum/go-ethereum/issues/29880 - s.prefetcher = newTriePrefetcher(s.db, s.originalRoot, namespace, witness == nil) - if err := s.prefetcher.prefetch(common.Hash{}, s.originalRoot, common.Address{}, nil, nil, false); err != nil { - log.Error("Failed to prefetch account trie", "root", s.originalRoot, "err", err) - } -} - -// StopPrefetcher terminates a running prefetcher and reports any leftover stats -// from the gathered metrics. -func (s *StateDB) StopPrefetcher() { - if s.prefetcher != nil { - s.prefetcher.terminate(false) - s.prefetcher.report() - s.prefetcher = nil - } } // setError remembers the first non-nil error it is called with. @@ -254,7 +181,7 @@ func (s *StateDB) AddLog(log *types.Log) { } // GetLogs returns the logs matching the specified transaction hash, and annotates -// them with the given blockNumber and blockHash. +// them with the given block attributes. func (s *StateDB) GetLogs(hash common.Hash, blockNumber uint64, blockHash common.Hash, blockTime uint64) []*types.Log { logs := s.logs[hash] for _, l := range logs { @@ -265,6 +192,7 @@ func (s *StateDB) GetLogs(hash common.Hash, blockNumber uint64, blockHash common return logs } +// Logs returns the un-annotated logs in order. func (s *StateDB) Logs() []*types.Log { logs := make([]*types.Log, 0, s.logSize) for _, lgs := range s.logs { @@ -336,19 +264,6 @@ func (s *StateDB) GetNonce(addr common.Address) uint64 { return 0 } -// GetStorageRoot retrieves the storage root from the given address or empty -// if object not found. -// -// Note: the storage root returned corresponds to the trie since last Intermediate -// operation, some recent in-memory changes are excluded. -func (s *StateDB) GetStorageRoot(addr common.Address) common.Hash { - stateObject := s.getStateObject(addr) - if stateObject != nil { - return stateObject.Root() - } - return common.Hash{} -} - // TxIndex returns the current transaction index set by SetTxContext. func (s *StateDB) TxIndex() int { return s.txIndex @@ -558,24 +473,6 @@ func (s *StateDB) GetTransientState(addr common.Address, key common.Hash) common // Setting, updating & deleting state object methods. // -// updateStateObject writes the given object to the trie. -func (s *StateDB) updateStateObject(obj *stateObject) { - // Encode the account and update the account trie - if err := s.trie.UpdateAccount(obj.Address(), &obj.data, len(obj.code)); err != nil { - s.setError(fmt.Errorf("updateStateObject (%x) error: %v", obj.Address(), err)) - } - if obj.dirtyCode { - s.trie.UpdateContractCode(obj.Address(), common.BytesToHash(obj.CodeHash()), obj.code) - } -} - -// deleteStateObject removes the given object from the state trie. -func (s *StateDB) deleteStateObject(addr common.Address) { - if err := s.trie.DeleteAccount(addr); err != nil { - s.setError(fmt.Errorf("deleteStateObject (%x) error: %v", addr[:], err)) - } -} - // getStateObject retrieves a state object given by the address, returning nil if // the object is not found or was deleted in this execution context. func (s *StateDB) getStateObject(addr common.Address) *stateObject { @@ -587,29 +484,28 @@ func (s *StateDB) getStateObject(addr common.Address) *stateObject { if _, ok := s.stateObjectsDestruct[addr]; ok { return nil } - s.AccountLoaded++ - start := time.Now() acct, err := s.reader.Account(addr) if err != nil { s.setError(fmt.Errorf("getStateObject (%x) error: %w", addr.Bytes(), err)) return nil } + s.AccountLoaded++ s.AccountReads += time.Since(start) // Short circuit if the account is not found if acct == nil { return nil } - // Schedule the resolved account for prefetching if it's enabled. - if s.prefetcher != nil { - if err = s.prefetcher.prefetch(common.Hash{}, s.originalRoot, common.Address{}, []common.Address{addr}, nil, true); err != nil { - log.Error("Failed to prefetch account", "addr", addr, "err", err) - } - } // Insert into the live set obj := newObject(s, addr, acct) s.setStateObject(obj) + + // Schedule the resolved account for prefetching if it's enabled. + prefetcher, ok := s.hasher.(Prefetcher) + if ok { + prefetcher.PrefetchAccount([]common.Address{addr}, true) + } return obj } @@ -673,6 +569,7 @@ func (s *StateDB) Copy() *StateDB { state := &StateDB{ db: s.db, reader: s.reader, + hasher: s.hasher.Copy(), originalRoot: s.originalRoot, stateObjects: make(map[common.Address]*stateObject, len(s.stateObjects)), stateObjectsDestruct: make(map[common.Address]*stateObject, len(s.stateObjectsDestruct)), @@ -695,9 +592,6 @@ func (s *StateDB) Copy() *StateDB { transientStorage: s.transientStorage.Copy(), journal: s.journal.copy(), } - if s.trie != nil { - state.trie = mustCopyTrie(s.trie) - } if s.witness != nil { state.witness = s.witness.Copy() } @@ -810,18 +704,18 @@ func (s *StateDB) Finalise(deleteEmptyObjects bool) { obj.finalise() s.markUpdate(addr) } - // At this point, also ship the address off to the precacher. The precacher + // At this point, also ship the address off to the prefetcher. The prefetcher // will start loading tries, and when the change is eventually committed, // the commit-phase will be a lot faster - addressesToPrefetch = append(addressesToPrefetch, addr) // Copy needed for closure - } - if s.prefetcher != nil && len(addressesToPrefetch) > 0 { - if err := s.prefetcher.prefetch(common.Hash{}, s.originalRoot, common.Address{}, addressesToPrefetch, nil, false); err != nil { - log.Error("Failed to prefetch addresses", "addresses", len(addressesToPrefetch), "err", err) - } + addressesToPrefetch = append(addressesToPrefetch, addr) } // Invalidate journal because reverting across transactions is not allowed. s.clearJournalAndRefund() + + prefetcher, ok := s.hasher.(Prefetcher) + if ok { + prefetcher.PrefetchAccount(addressesToPrefetch, false) + } } // IntermediateRoot computes the current root hash of the state trie. @@ -831,204 +725,100 @@ func (s *StateDB) IntermediateRoot(deleteEmptyObjects bool) common.Hash { // Finalise all the dirty storage states and write them into the tries s.Finalise(deleteEmptyObjects) - // Initialize the trie if it's not constructed yet. If the prefetch - // is enabled, the trie constructed below will be replaced by the - // prefetched one. - // - // This operation must be done before state object storage hashing, - // as it assumes the main trie is already loaded. - if s.trie == nil { - tr, err := s.db.OpenTrie(s.originalRoot) - if err != nil { + // Pre-process mutations whose preceding deletion has not yet been + // applied. This happens when an account is deleted and then re-created + // within the same block and the deletion was overwritten by the update. + // Notify the hasher of the deletion first so that any cached storage + // trie is evicted and the re-created account starts with a fresh trie. + var ( + delAddrs []common.Address + delAccts []AccountMut + start = time.Now() + ) + for addr, op := range s.mutations { + if !op.precedingDelete { + continue + } + op.precedingDelete = false + + delAddrs = append(delAddrs, addr) + delAccts = append(delAccts, AccountMut{Account: nil}) + } + if len(delAddrs) > 0 { + if err := s.hasher.UpdateAccount(delAddrs, delAccts); err != nil { s.setError(err) return common.Hash{} } - s.trie = tr + s.AccountDeleted += len(delAddrs) } - // If there was a trie prefetcher operating, terminate it async so that the - // individual storage tries can be updated as soon as the disk load finishes. - if s.prefetcher != nil { - s.prefetcher.terminate(true) - defer func() { - s.prefetcher.report() - s.prefetcher = nil // Pre-byzantium, unset any used up prefetcher - }() - } - // Process all storage updates concurrently. The state object update root - // method will internally call a blocking trie fetch from the prefetcher, - // so there's no need to explicitly wait for the prefetchers to finish. - var ( - start = time.Now() - workers errgroup.Group - ) - if s.db.TrieDB().IsVerkle() { - // Bypass per-account updateTrie() for binary trie. In binary trie mode - // there is only one unified trie (OpenStorageTrie returns self), so the - // per-account trie setup in updateTrie() (getPrefetchedTrie, getTrie, - // prefetcher.used) is redundant overhead. Apply all storage updates - // directly in a single pass. - for addr, op := range s.mutations { - if op.applied || op.isDelete() { - continue - } - obj := s.stateObjects[addr] - if len(obj.uncommittedStorage) == 0 { - continue - } - for key, origin := range obj.uncommittedStorage { - value, exist := obj.pendingStorage[key] - if value == origin || !exist { - continue - } - if (value != common.Hash{}) { - if err := s.trie.UpdateStorage(addr, key[:], common.TrimLeftZeroes(value[:])); err != nil { - s.setError(err) - } - s.StorageUpdated.Add(1) - } else { - if err := s.trie.DeleteStorage(addr, key[:]); err != nil { - s.setError(err) - } - s.StorageDeleted.Add(1) - } - } - } - // Clear uncommittedStorage and assign trie on each touched object. - // obj.trie must be set because this path bypasses updateTrie(), which - // is where obj.trie normally gets lazily loaded via getTrie(). - for addr, op := range s.mutations { - if op.applied || op.isDelete() { - continue - } - obj := s.stateObjects[addr] - if len(obj.uncommittedStorage) > 0 { - obj.uncommittedStorage = make(Storage) - } - obj.trie = s.trie - } - } else { - for addr, op := range s.mutations { - if op.applied || op.isDelete() { - continue - } - obj := s.stateObjects[addr] // closure for the task runner below - workers.Go(func() error { - obj.updateRoot() + s.AccountUpdates += time.Since(start) - // If witness building is enabled and the state object has a trie, - // gather the witnesses for its specific storage trie - if s.witness != nil && obj.trie != nil { - s.witness.AddState(obj.trie.Witness(), obj.addrHash()) - } - return nil - }) + // Process all storage updates concurrently, flushing them to hasher. + start = time.Now() + var workers errgroup.Group + for addr, op := range s.mutations { + if op.applied || op.isDelete() { + continue } + obj := s.stateObjects[addr] + workers.Go(obj.updateTrie) } - // If witness building is enabled, gather all the read-only accesses. - // Skip witness collection in Verkle mode, they will be gathered - // together at the end. - if s.witness != nil && !s.db.TrieDB().IsVerkle() { - // Pull in anything that has been accessed before destruction - for _, obj := range s.stateObjectsDestruct { - // Skip any objects that haven't touched their storage - if len(obj.originStorage) == 0 { - continue - } - if trie := obj.getPrefetchedTrie(); trie != nil { - s.witness.AddState(trie.Witness(), obj.addrHash()) - } else if obj.trie != nil { - s.witness.AddState(obj.trie.Witness(), obj.addrHash()) - } - } - // Pull in only-read and non-destructed trie witnesses - for _, obj := range s.stateObjects { - // Skip any objects that have been updated - if _, ok := s.mutations[obj.address]; ok { - continue - } - // Skip any objects that haven't touched their storage - if len(obj.originStorage) == 0 { - continue - } - if trie := obj.getPrefetchedTrie(); trie != nil { - s.witness.AddState(trie.Witness(), obj.addrHash()) - } else if obj.trie != nil { - s.witness.AddState(obj.trie.Witness(), obj.addrHash()) - } - } + if err := workers.Wait(); err != nil { + s.setError(err) } - workers.Wait() s.StorageUpdates += time.Since(start) - // Now we're about to start to write changes to the trie. The trie is so far - // _untouched_. We can check with the prefetcher, if it can give us a trie - // which has the same root, but also has some content loaded into it. - // - // Don't check prefetcher if verkle trie has been used. In the context of verkle, - // only a single trie is used for state hashing. Replacing a non-nil verkle tree - // here could result in losing uncommitted changes from storage. - start = time.Now() - if s.prefetcher != nil && !s.db.TrieDB().IsVerkle() { - if trie := s.prefetcher.trie(common.Hash{}, s.originalRoot); trie == nil { - log.Error("Failed to retrieve account pre-fetcher trie") - } else { - s.trie = trie - } - } - // Perform updates before deletions. This prevents resolution of unnecessary trie nodes - // in circumstances similar to the following: - // - // Consider nodes `A` and `B` who share the same full node parent `P` and have no other siblings. - // During the execution of a block: - // - `A` self-destructs, - // - `C` is created, and also shares the parent `P`. - // If the self-destruct is handled first, then `P` would be left with only one child, thus collapsed - // into a shortnode. This requires `B` to be resolved from disk. - // Whereas if the created node is handled first, then the collapse is avoided, and `B` is not resolved. + // Process all account updates var ( - usedAddrs []common.Address - deletedAddrs []common.Address + addresses []common.Address + accounts []AccountMut ) + start = time.Now() for addr, op := range s.mutations { if op.applied { continue } op.applied = true + addresses = append(addresses, addr) if op.isDelete() { - deletedAddrs = append(deletedAddrs, addr) - } else { - obj := s.stateObjects[addr] - s.updateStateObject(obj) - s.AccountUpdated += 1 + accounts = append(accounts, AccountMut{Account: nil}) + s.AccountDeleted += 1 + continue + } + obj := s.stateObjects[addr] + // CodeSize must be the account's CURRENT total code size, even for + // non-code-touching mutations. obj.CodeSize() returns len(obj.code) + // when the code is loaded, otherwise falls back to a code-size + // lookup via the reader. Hashers that pack code size into the + // on-trie account encoding (e.g. the binary trie BasicData leaf, + // per EIP-7864) rely on this value. Passing the default 0 here on + // a balance/nonce-only update would silently corrupt the BasicData + // leaf of every contract touched without a code write. + mut := AccountMut{ + Account: &obj.data, + CodeSize: obj.CodeSize(), + } + if obj.dirtyCode { + mut.Code = &CodeMut{Code: obj.code} // Count code writes post-Finalise so reverted CREATEs are excluded. - if obj.dirtyCode { - s.CodeUpdated += 1 - s.CodeUpdateBytes += len(obj.code) - } + s.CodeUpdated += 1 + s.CodeUpdateBytes += len(obj.code) } - usedAddrs = append(usedAddrs, addr) // Copy needed for closure + accounts = append(accounts, mut) + s.AccountUpdated += 1 } - for _, deletedAddr := range deletedAddrs { - s.deleteStateObject(deletedAddr) - s.AccountDeleted += 1 + if err := s.hasher.UpdateAccount(addresses, accounts); err != nil { + s.setError(err) + return common.Hash{} } s.AccountUpdates += time.Since(start) - if s.prefetcher != nil { - s.prefetcher.used(common.Hash{}, s.originalRoot, usedAddrs, nil) - } // Track the amount of time wasted on hashing the account trie defer func(start time.Time) { s.AccountHashes += time.Since(start) }(time.Now()) - hash := s.trie.Hash() - - // If witness building is enabled, gather the account trie witness - if s.witness != nil { - s.witness.AddState(s.trie.Witness(), common.Hash{}) - } - return hash + return s.hasher.Hash() } // SetTxContext sets the current transaction hash and index which are @@ -1045,11 +835,11 @@ 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][]byte, map[common.Hash][]byte, *trienode.NodeSet, error) { +func (s *StateDB) deleteStorage(addrHash 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][]byte) // the set for storage mutations (value is nil) - storageOrigins = make(map[common.Hash][]byte) // the set for tracking the original value of slot + 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) if err != nil { @@ -1070,8 +860,15 @@ func (s *StateDB) deleteStorage(addrHash common.Hash, root common.Hash) (map[com return nil, nil, nil, err } key := it.Hash() - storages[key] = nil - storageOrigins[key] = slot + storages[key] = common.Hash{} + + _, content, _, err := rlp.Split(slot) + if err != nil { + return nil, nil, nil, err + } + var value common.Hash + value.SetBytes(content) + storageOrigins[key] = value if err := stack.Update(key.Bytes(), slot); err != nil { return nil, nil, nil, err @@ -1080,9 +877,7 @@ func (s *StateDB) deleteStorage(addrHash common.Hash, root common.Hash) (map[com if err := it.Error(); err != nil { // error might occur during iteration return nil, nil, nil, err } - if stack.Hash() != root { - return nil, nil, nil, fmt.Errorf("snapshot is not matched, exp %x, got %x", root, stack.Hash()) - } + stack.Hash() // Commit the right boundary return storages, storageOrigins, nodes, nil } @@ -1104,9 +899,9 @@ func (s *StateDB) deleteStorage(addrHash common.Hash, root common.Hash) (map[com // 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 (s *StateDB) handleDestruction(noStorageWiping bool) (map[common.Hash]*accountDelete, []*trienode.NodeSet, error) { +func (s *StateDB) handleDestruction(noStorageWiping bool) (map[common.Hash]*accountDelete, *trienode.MergedNodeSet, error) { var ( - nodes []*trienode.NodeSet + nodes = trienode.NewMergedNodeSet() deletes = make(map[common.Hash]*accountDelete) ) for addr, prevObj := range s.stateObjectsDestruct { @@ -1124,36 +919,32 @@ func (s *StateDB) handleDestruction(noStorageWiping bool) (map[common.Hash]*acco addrHash := crypto.Keccak256Hash(addr.Bytes()) op := &accountDelete{ address: addr, - origin: types.SlimAccountRLP(*prev), + origin: *prev, } deletes[addrHash] = op // Short circuit if the origin storage was empty. - if prev.Root == types.EmptyRootHash || s.db.TrieDB().IsVerkle() { + if s.db.TrieDB().IsVerkle() { continue } if noStorageWiping { return nil, nil, fmt.Errorf("unexpected storage wiping, %x", addr) } // Remove storage slots belonging to the account. - storages, storagesOrigin, set, err := s.deleteStorage(addrHash, prev.Root) + storages, storagesOrigin, set, err := s.deleteStorage(addrHash) if err != nil { return nil, nil, fmt.Errorf("failed to delete storage, err: %w", err) } - op.storages = storages - op.storagesOrigin = storagesOrigin + op.storages, op.storagesOrigin = storages, storagesOrigin // Aggregate the associated trie node changes. - nodes = append(nodes, set) + if err := nodes.Merge(set); err != nil { + return nil, nil, err + } } return deletes, nodes, nil } -// GetTrie returns the account trie. -func (s *StateDB) GetTrie() Trie { - return s.trie -} - // commit gathers the state mutations accumulated along with the associated // trie changes, resetting all internal flags with the new state as the base. func (s *StateDB) commit(deleteEmptyObjects bool, noStorageWiping bool, blockNumber uint64) (*stateUpdate, error) { @@ -1168,89 +959,16 @@ func (s *StateDB) commit(deleteEmptyObjects bool, noStorageWiping bool, blockNum if s.dbErr != nil { return nil, fmt.Errorf("commit aborted due to database error: %v", s.dbErr) } - // Commit objects to the trie, measuring the elapsed time - var ( - 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.mutations)) // 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) - } - ) // Given that some accounts could be destroyed and then recreated within // the same block, account deletions must be processed first. This ensures // that the storage trie nodes deleted during destruction and recreated // during subsequent resurrection can be combined correctly. - deletes, delNodes, err := s.handleDestruction(noStorageWiping) + deletes, nodes, err := s.handleDestruction(noStorageWiping) if err != nil { return nil, err } - for _, set := range delNodes { - if err := merge(set); err != nil { - return 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() - 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 - _, set := s.trie.Commit(true) - if err := merge(set); err != nil { - return err - } - s.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. + // Aggregated account updates + updates := make(map[common.Hash]*accountUpdate, len(s.mutations)) for addr, op := range s.mutations { if op.isDelete() { continue @@ -1260,44 +978,25 @@ func (s *StateDB) commit(deleteEmptyObjects bool, noStorageWiping bool, blockNum if obj == nil { return nil, errors.New("missing state object") } - // 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 := obj.commit() - if err != nil { - return err - } - if err := merge(set); err != nil { - return err - } - lock.Lock() - updates[obj.addrHash()] = update - s.StorageCommits = time.Since(start) // overwrite with the longest storage commit runtime - lock.Unlock() - return nil - }) + update, err := obj.commit() + if err != nil { + return nil, err + } + updates[obj.addrHash()] = update } - // Wait for everything to finish and update the metrics - if err := workers.Wait(); err != nil { + // 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. + start := time.Now() + root, set, secondaryHashes, err := s.hasher.Commit() + if err != nil { return nil, err } - accountReadMeters.Mark(int64(s.AccountLoaded)) - storageReadMeters.Mark(int64(s.StorageLoaded)) - accountUpdatedMeter.Mark(int64(s.AccountUpdated)) - storageUpdatedMeter.Mark(s.StorageUpdated.Load()) - accountDeletedMeter.Mark(int64(s.AccountDeleted)) - storageDeletedMeter.Mark(s.StorageDeleted.Load()) - accountTrieUpdatedMeter.Mark(int64(accountTrieNodesUpdated)) - accountTrieDeletedMeter.Mark(int64(accountTrieNodesDeleted)) - storageTriesUpdatedMeter.Mark(int64(storageTrieNodesUpdated)) - storageTriesDeletedMeter.Mark(int64(storageTrieNodesDeleted)) - - // Clear the metric markers - s.AccountLoaded, s.AccountUpdated, s.AccountDeleted = 0, 0, 0 - s.StorageLoaded = 0 - s.StorageUpdated.Store(0) - s.StorageDeleted.Store(0) + s.HasherCommits = time.Since(start) + if err := nodes.MergeSet(set); err != nil { + return nil, err + } // Clear all internal flags and update state root at the end. s.mutations = make(map[common.Address]*mutation) s.stateObjectsDestruct = make(map[common.Address]*stateObject) @@ -1305,7 +1004,22 @@ func (s *StateDB) commit(deleteEmptyObjects bool, noStorageWiping bool, blockNum origin := s.originalRoot s.originalRoot = root - return newStateUpdate(noStorageWiping, origin, root, blockNumber, deletes, updates, nodes), nil + if s.witness != nil { + builder, ok := s.hasher.(WitnessCollector) + if ok { + builder.CollectWitness(s.witness) + } + } + // If the hasher tracks flat-state leaf production (currently only the + // binary hasher), drain the buffered stem writes so the downstream + // state update can carry them into the pathdb flat-state layer. Merkle + // hashers do not implement this interface and the call short-circuits + // to nil — newStateUpdate accepts nil as "no leaves". + var leaves []StemWrite + if producer, ok := s.hasher.(LeafProducer); ok { + leaves = producer.DrainStemWrites() + } + return newStateUpdate(noStorageWiping, origin, root, blockNumber, deletes, updates, nodes, secondaryHashes, leaves), nil } // commitAndFlush is a wrapper of commit which also commits the state mutations @@ -1326,10 +1040,19 @@ func (s *StateDB) commitAndFlush(block uint64, deleteEmptyObjects bool, noStorag } s.DatabaseCommits = time.Since(start) - // The reader update must be performed as the final step, otherwise, - // the new state would not be visible before db.commit. - s.reader, _ = s.db.Reader(s.originalRoot) - return ret, err + reader, err := s.db.Reader(s.originalRoot) + if err != nil { + return nil, err + } + s.reader = reader + + hasher, err := s.db.Hasher(s.originalRoot) + if err != nil { + return nil, err + } + s.hasher = hasher + + return ret, nil } // Commit writes the state mutations into the configured data stores. @@ -1440,25 +1163,6 @@ func (s *StateDB) SlotInAccessList(addr common.Address, slot common.Hash) (addre return s.accessList.Contains(addr, slot) } -// markDelete is invoked when an account is deleted but the deletion is -// not yet committed. The pending mutation is cached and will be applied -// all together -func (s *StateDB) markDelete(addr common.Address) { - if _, ok := s.mutations[addr]; !ok { - s.mutations[addr] = &mutation{} - } - s.mutations[addr].applied = false - s.mutations[addr].typ = deletion -} - -func (s *StateDB) markUpdate(addr common.Address) { - if _, ok := s.mutations[addr]; !ok { - s.mutations[addr] = &mutation{} - } - s.mutations[addr].applied = false - s.mutations[addr].typ = update -} - // Witness retrieves the current state witness being collected. func (s *StateDB) Witness() *stateless.Witness { return s.witness @@ -1467,3 +1171,15 @@ func (s *StateDB) Witness() *stateless.Witness { func (s *StateDB) AccessEvents() *AccessEvents { return s.accessEvents } + +// StopPrefetcher terminates all the background prefetching activities. +func (s *StateDB) StopPrefetcher() { + if s.hasher == nil { + return + } + prefetch, ok := s.hasher.(Prefetcher) + if !ok { + return + } + prefetch.TermPrefetch() +} diff --git a/core/state/statedb_fuzz_test.go b/core/state/statedb_fuzz_test.go index 3582185344..7482fc2d84 100644 --- a/core/state/statedb_fuzz_test.go +++ b/core/state/statedb_fuzz_test.go @@ -183,10 +183,11 @@ func (test *stateTest) run() bool { storages []map[common.Hash]map[common.Hash][]byte storageOrigin []map[common.Address]map[common.Hash][]byte copyUpdate = func(update *stateUpdate) { - accounts = append(accounts, maps.Clone(update.accounts)) - accountOrigin = append(accountOrigin, maps.Clone(update.accountsOrigin)) - storages = append(storages, maps.Clone(update.storages)) - storageOrigin = append(storageOrigin, maps.Clone(update.storagesOrigin)) + encoded, _ := update.stateSet(true) + accounts = append(accounts, maps.Clone(encoded.Accounts)) + accountOrigin = append(accountOrigin, maps.Clone(encoded.AccountsOrigin)) + storages = append(storages, maps.Clone(encoded.Storages)) + storageOrigin = append(storageOrigin, maps.Clone(encoded.StoragesOrigin)) } disk = rawdb.NewMemoryDatabase() tdb = triedb.NewDatabase(disk, &triedb.Config{PathDB: pathdb.Defaults}) diff --git a/core/state/statedb_stats.go b/core/state/statedb_stats.go new file mode 100644 index 0000000000..c69a157206 --- /dev/null +++ b/core/state/statedb_stats.go @@ -0,0 +1,61 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package state + +import ( + "sync/atomic" + "time" +) + +// Stats contains all measurements gathered during state execution for +// debugging and metrics purposes. +type Stats struct { + AccountReads time.Duration // Account read time + StorageReads time.Duration // Storage read time + CodeReads time.Duration // Code read time + AccountHashes time.Duration // Account trie hash time + AccountUpdates time.Duration // Account trie update time + StorageUpdates time.Duration // Storage trie update and hash time + HasherCommits time.Duration // Trie commit time + DatabaseCommits time.Duration // Database commit time + + AccountLoaded int // Number of accounts retrieved from the database during the state transition + AccountUpdated int // Number of accounts updated during the state transition + AccountDeleted int // Number of accounts deleted during the state transition + StorageLoaded int // Number of storage slots retrieved from the database during the state transition + StorageUpdated atomic.Int64 // Number of storage slots updated during the state transition + StorageDeleted atomic.Int64 // Number of storage slots deleted during the state transition + + // CodeLoadBytes is the total number of bytes read from contract code. + // This value may be smaller than the actual number of bytes read, since + // some APIs (e.g. CodeSize) may load the entire code from either the + // cache or the database when the size is not available in the cache. + CodeLoaded int // Number of contract code loaded during the state transition + CodeLoadBytes int // Total bytes of resolved code + CodeUpdated int // Number of contracts with code changes that persisted + CodeUpdateBytes int // Total bytes of persisted code written +} + +// StateReadTime returns the total time spent on the state read. +func (s *Stats) StateReadTime() time.Duration { + return s.AccountReads + s.StorageReads + s.CodeReads +} + +// StateHashTime returns the total time spent on the state hash. +func (s *Stats) StateHashTime() time.Duration { + return s.AccountHashes + s.AccountUpdates + s.StorageUpdates +} diff --git a/core/state/statedb_test.go b/core/state/statedb_test.go index 6936372c50..262403cdcd 100644 --- a/core/state/statedb_test.go +++ b/core/state/statedb_test.go @@ -32,13 +32,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" - "github.com/ethereum/go-ethereum/core/state/snapshot" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/rlp" - "github.com/ethereum/go-ethereum/trie" - "github.com/ethereum/go-ethereum/trie/trienode" "github.com/ethereum/go-ethereum/triedb" "github.com/ethereum/go-ethereum/triedb/hashdb" "github.com/ethereum/go-ethereum/triedb/pathdb" @@ -232,7 +227,7 @@ func TestCopyWithDirtyJournal(t *testing.T) { for i := byte(0); i < 255; i++ { obj := orig.getOrNewStateObject(common.BytesToAddress([]byte{i})) obj.AddBalance(uint256.NewInt(uint64(i))) - obj.data.Root = common.HexToHash("0xdeadbeef") + //obj.data.Root = common.HexToHash("0xdeadbeef") } root, _ := orig.Commit(0, true, false) orig, _ = New(root, db) @@ -275,7 +270,7 @@ func TestCopyObjectState(t *testing.T) { for i := byte(0); i < 5; i++ { obj := orig.getOrNewStateObject(common.BytesToAddress([]byte{i})) obj.AddBalance(uint256.NewInt(uint64(i))) - obj.data.Root = common.HexToHash("0xdeadbeef") + //obj.data.Root = common.HexToHash("0xdeadbeef") } orig.Finalise(true) cpy := orig.Copy() @@ -543,47 +538,6 @@ func (test *snapshotTest) run() bool { return true } -func forEachStorage(s *StateDB, addr common.Address, cb func(key, value common.Hash) bool) error { - so := s.getStateObject(addr) - if so == nil { - return nil - } - tr, err := so.getTrie() - if err != nil { - return err - } - trieIt, err := tr.NodeIterator(nil) - if err != nil { - return err - } - var ( - it = trie.NewIterator(trieIt) - visited = make(map[common.Hash]bool) - ) - - for it.Next() { - key := common.BytesToHash(tr.GetKey(it.Key)) - visited[key] = true - if value, dirty := so.dirtyStorage[key]; dirty { - if !cb(key, value) { - return nil - } - continue - } - - if len(it.Value) > 0 { - _, content, _, err := rlp.Split(it.Value) - if err != nil { - return err - } - if !cb(key, common.BytesToHash(content)) { - return nil - } - } - } - return nil -} - // checkEqual checks that methods of state and checkstate return the same values. func (test *snapshotTest) checkEqual(state, checkstate *StateDB) error { for _, addr := range test.addrs { @@ -609,12 +563,6 @@ func (test *snapshotTest) checkEqual(state, checkstate *StateDB) error { } // Check storage. if obj := state.getStateObject(addr); obj != nil { - forEachStorage(state, addr, func(key, value common.Hash) bool { - return checkeq("GetState("+key.Hex()+")", checkstate.GetState(addr, key), value) - }) - forEachStorage(checkstate, addr, func(key, value common.Hash) bool { - return checkeq("GetState("+key.Hex()+")", checkstate.GetState(addr, key), value) - }) other := checkstate.getStateObject(addr) // Check dirty storage which is not in trie if !maps.Equal(obj.dirtyStorage, other.dirtyStorage) { @@ -773,8 +721,14 @@ func TestCopyCommitCopy(t *testing.T) { t.Fatalf("second copy committed storage slot mismatch: have %x, want %x", val, common.Hash{}) } // Commit state, ensure states can be loaded from disk - root, _ := state.Commit(0, false, false) - state, _ = New(root, tdb) + root, err := state.Commit(0, false, false) + if err != nil { + t.Fatalf("commit fail: %v", err) + } + state, err = New(root, tdb) + if err != nil { + t.Fatalf("New fail: %v", err) + } if balance := state.GetBalance(addr); balance.Cmp(uint256.NewInt(42)) != 0 { t.Fatalf("state post-commit balance mismatch: have %v, want %v", balance, 42) } @@ -1269,60 +1223,6 @@ func TestStateDBTransientStorage(t *testing.T) { } } -func TestDeleteStorage(t *testing.T) { - var ( - disk = rawdb.NewMemoryDatabase() - tdb = triedb.NewDatabase(disk, nil) - snaps, _ = snapshot.New(snapshot.Config{CacheSize: 10}, disk, tdb, types.EmptyRootHash) - db = NewDatabase(tdb, nil).WithSnapshot(snaps) - state, _ = New(types.EmptyRootHash, db) - addr = common.HexToAddress("0x1") - ) - // Initialize account and populate storage - state.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) - state.CreateAccount(addr) - for i := 0; i < 1000; i++ { - slot := common.Hash(uint256.NewInt(uint64(i)).Bytes32()) - value := common.Hash(uint256.NewInt(uint64(10 * i)).Bytes32()) - state.SetState(addr, slot, value) - } - root, _ := state.Commit(0, true, false) - // Init phase done, create two states, one with snap and one without - fastState, _ := New(root, NewDatabase(tdb, nil).WithSnapshot(snaps)) - slowState, _ := New(root, NewDatabase(tdb, nil)) - - obj := fastState.getOrNewStateObject(addr) - storageRoot := obj.data.Root - - _, _, fastNodes, err := fastState.deleteStorage(crypto.Keccak256Hash(addr[:]), storageRoot) - if err != nil { - t.Fatal(err) - } - - _, _, slowNodes, err := slowState.deleteStorage(crypto.Keccak256Hash(addr[:]), storageRoot) - if err != nil { - t.Fatal(err) - } - check := func(set *trienode.NodeSet) string { - var a []string - set.ForEachWithOrder(func(path string, n *trienode.Node) { - if n.Hash != (common.Hash{}) { - t.Fatal("delete should have empty hashes") - } - if len(n.Blob) != 0 { - t.Fatal("delete should have empty blobs") - } - a = append(a, fmt.Sprintf("%x", path)) - }) - return strings.Join(a, ",") - } - slowRes := check(slowNodes) - fastRes := check(fastNodes) - if slowRes != fastRes { - t.Fatalf("difference found:\nfast: %v\nslow: %v\n", fastRes, slowRes) - } -} - func TestStorageDirtiness(t *testing.T) { var ( disk = rawdb.NewMemoryDatabase() @@ -1366,3 +1266,85 @@ func TestStorageDirtiness(t *testing.T) { state.RevertToSnapshot(snap) checkDirty(common.Hash{0x1}, common.Hash{0x1}, true) } + +// TestVerkleCodeSizePreserved is a regression test for a latent bug in the +// binary-trie update path of binaryHasher: codeLen was derived from +// account.Code, which is only non-nil when the contract code itself was +// modified in the current block. For balance- or nonce-only changes, +// account.Code was nil and the hasher silently wrote codeLen=0 into the +// BasicData leaf, corrupting the EIP-7864-defined code_size field every +// time a contract's balance or nonce was touched without a code write. +// +// The fix plumbs the account's current total code size through +// AccountMut.CodeSize, which the caller populates via +// stateObject.CodeSize() at commit time. This value is authoritative +// whether or not the code bytes are currently loaded. +// +// This test verifies that the state root produced by "create contract, +// commit, reload, modify balance, commit" matches the state root produced +// by a single commit of the final state. Equality can only hold if the +// code size survives the balance-only commit. +func TestVerkleCodeSizePreserved(t *testing.T) { + newVerkleState := func(t *testing.T) (*StateDB, *triedb.Database) { + t.Helper() + disk := rawdb.NewMemoryDatabase() + tdb := triedb.NewDatabase(disk, triedb.VerkleDefaults) + sdb := NewDatabase(tdb, nil) + // A fresh verkle pathdb's disk layer is keyed by EmptyVerkleHash + // (all-zero hash), not EmptyRootHash. Using the wrong one fails + // with "triedb parent layer missing" at commit. + state, err := New(types.EmptyVerkleHash, sdb) + if err != nil { + t.Fatalf("failed to initialize state: %v", err) + } + return state, tdb + } + + var ( + addr = common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + code = make([]byte, 1234) // non-trivial code length so codeSize matters + ) + for i := range code { + code[i] = byte(i) + } + + // Path A: create contract, commit, reload, modify only balance, commit. + // On the second commit obj.code is not loaded (dirtyCode=false), so + // the previous implementation computed codeLen=0 via len(obj.code). + // Triedb layers stay in memory (no tdb.Commit) so we can chain a + // second block on top of the first. + stateA, tdbA := newVerkleState(t) + sdbA := NewDatabase(tdbA, nil) + stateA.SetBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified) + stateA.SetCode(addr, code, tracing.CodeChangeUnspecified) + rootA1, err := stateA.Commit(0, true, false) + if err != nil { + t.Fatalf("path A first commit: %v", err) + } + + stateA, err = New(rootA1, sdbA) + if err != nil { + t.Fatalf("path A reload: %v", err) + } + stateA.SetBalance(addr, uint256.NewInt(200), tracing.BalanceChangeUnspecified) + rootA2, err := stateA.Commit(1, true, false) + if err != nil { + t.Fatalf("path A second commit: %v", err) + } + + // Path B: construct the same final state in one shot (balance=200 + code). + // obj.code is loaded because SetCode was just called, so codeSize is + // always correct here — this is the "known-good" reference. + stateB, _ := newVerkleState(t) + stateB.SetBalance(addr, uint256.NewInt(200), tracing.BalanceChangeUnspecified) + stateB.SetCode(addr, code, tracing.CodeChangeUnspecified) + rootB, err := stateB.Commit(0, true, false) + if err != nil { + t.Fatalf("path B commit: %v", err) + } + + if rootA2 != rootB { + t.Fatalf("state root mismatch after balance-only update:\n path A (reload + balance): %x\n path B (fresh, same final state): %x\n regression: binaryHasher.updateAccount used len(account.Code.Code)=0 because code was not modified", + rootA2, rootB) + } +} diff --git a/core/state/stateupdate.go b/core/state/stateupdate.go index 1c171cbd5e..e4c555f7a8 100644 --- a/core/state/stateupdate.go +++ b/core/state/stateupdate.go @@ -17,6 +17,7 @@ package state import ( + "errors" "fmt" "maps" @@ -29,71 +30,75 @@ import ( "github.com/ethereum/go-ethereum/triedb" ) -// contractCode represents contract bytecode along with its associated metadata. +// contractCode encapsulates contract bytecode and its associated metadata. type contractCode struct { hash common.Hash // hash is the cryptographic hash of the current contract code. - blob []byte // blob is the binary representation of the current contract code. - originHash common.Hash // originHash is the cryptographic hash of the code before mutation. + originHash common.Hash // originHash is the cryptographic hash of the code prior to mutation. + blob []byte // blob is the raw byte representation of the current contract code. // Derived fields, populated only when state tracking is enabled. duplicate bool // duplicate indicates whether the updated code already exists. - originBlob []byte // originBlob is the original binary representation of the contract code. + originBlob []byte // originBlob is the original byte representation of the contract code. } -// accountDelete represents an operation for deleting an Ethereum account. +// accountDelete represents a deletion operation for an Ethereum account. type accountDelete struct { - address common.Address // address is the unique account identifier - origin []byte // origin is the original value of account data in slim-RLP encoding. + address common.Address // address uniquely identifies the account. + origin Account // origin is the account state prior to deletion. - // storages stores mutated slots, the value should be nil. - storages map[common.Hash][]byte - - // storagesOrigin stores the original values of mutated slots in - // prefix-zero-trimmed RLP format. The map key refers to the **HASH** - // of the raw storage slot key. - storagesOrigin map[common.Hash][]byte + storages map[common.Hash]common.Hash // storages contains mutated storage slots. + storagesOrigin map[common.Hash]common.Hash // storagesOrigin holds original values of mutated slots; keys are hashes of raw storage slot keys. } -// accountUpdate represents an operation for updating an Ethereum account. +// accountUpdate represents an update operation for an Ethereum account. type accountUpdate struct { - address common.Address // address is the unique account identifier - data []byte // data is the slim-RLP encoded account data. - origin []byte // origin is the original value of account data in slim-RLP encoding. - code *contractCode // code represents mutated contract code; nil means it's not modified. - storages map[common.Hash][]byte // storages stores mutated slots in prefix-zero-trimmed RLP format. + address common.Address // address uniquely identifies the account. + data *Account // data is the updated account state; nil indicates deletion. + origin *Account // origin is the previous account state; nil indicates non-existence. + code *contractCode // code contains updated contract code; nil if unchanged. + storages map[common.Hash]common.Hash // storages contains updated storage slots. - // storagesOriginByKey and storagesOriginByHash both store the original values - // of mutated slots in prefix-zero-trimmed RLP format. The difference is that - // storagesOriginByKey uses the **raw** storage slot key as the map ID, while - // storagesOriginByHash uses the **hash** of the storage slot key instead. - storagesOriginByKey map[common.Hash][]byte - storagesOriginByHash map[common.Hash][]byte + // storagesOriginByKey and storagesOriginByHash both record original values + // of mutated storage slots: + // - storagesOriginByKey uses raw storage slot keys. + // - storagesOriginByHash uses hashed storage slot keys. + storagesOriginByKey map[common.Hash]common.Hash + storagesOriginByHash map[common.Hash]common.Hash } -// stateUpdate represents the difference between two states resulting from state -// execution. It contains information about mutated contract codes, accounts, -// and storage slots, along with their original values. +// stateUpdate captures the difference between two states resulting from +// execution. It records all mutated accounts, contract codes, and storage +// slots, along with their original values. type stateUpdate struct { - originRoot common.Hash // hash of the state before applying mutation - root common.Hash // hash of the state after applying mutation - blockNumber uint64 // Associated block number + originRoot common.Hash // originRoot is the state root before applying changes. + root common.Hash // root is the state root after applying changes. + blockNumber uint64 // blockNumber is the associated block height. - accounts map[common.Hash][]byte // accounts stores mutated accounts in 'slim RLP' encoding - accountsOrigin map[common.Address][]byte // accountsOrigin stores the original values of mutated accounts in 'slim RLP' encoding + accounts map[common.Hash]*Account // accounts contains mutated accounts, keyed by account hash. + accountsOrigin map[common.Address]*Account // accountsOrigin holds original values of mutated accounts, keyed by address. - // storages stores mutated slots in 'prefix-zero-trimmed' RLP format. - // The value is keyed by account hash and **storage slot key hash**. - storages map[common.Hash]map[common.Hash][]byte + // storages contains mutated storage slots, keyed by account hash and + // storage slot key hash. + storages map[common.Hash]map[common.Hash]common.Hash - // storagesOrigin stores the original values of mutated slots in - // 'prefix-zero-trimmed' RLP format. - // (a) the value is keyed by account hash and **storage slot key** if rawStorageKey is true; - // (b) the value is keyed by account hash and **storage slot key hash** if rawStorageKey is false; - storagesOrigin map[common.Address]map[common.Hash][]byte + // storagesOrigin holds original values of mutated storage slots. + // The key format depends on rawStorageKey: + // - if true: keyed by account address and raw storage slot key. + // - if false: keyed by account address and storage slot key hash. + storagesOrigin map[common.Address]map[common.Hash]common.Hash rawStorageKey bool - codes map[common.Address]*contractCode // codes contains the set of dirty codes - nodes *trienode.MergedNodeSet // Aggregated dirty nodes caused by state changes + codes map[common.Address]*contractCode // codes contains mutated contract codes, keyed by address. + nodes *trienode.MergedNodeSet // nodes aggregates all dirty trie nodes produced by the update. + secondaryHashes map[common.Address]Hashes // hashes of secondary tries + + // leaves is the ordered list of stem-offset writes harvested from a + // LeafProducer-capable hasher (the binary hasher). For merkle hashers + // it is always nil; for the binary hasher it is the bintrie's view of + // the same state mutations the trie just absorbed, in flat-state form. + // encodeBinary turns this into the per-offset accountData map that + // pathdb's bintrie codec consumes at flush time. + leaves []StemWrite } // empty returns a flag indicating the state transition is empty or not. @@ -107,12 +112,16 @@ func (sc *stateUpdate) empty() bool { // // rawStorageKey is a flag indicating whether to use the raw storage slot key or // the hash of the slot key for constructing state update object. -func newStateUpdate(rawStorageKey bool, originRoot common.Hash, root common.Hash, blockNumber uint64, deletes map[common.Hash]*accountDelete, updates map[common.Hash]*accountUpdate, nodes *trienode.MergedNodeSet) *stateUpdate { +// +// leaves carries the per-offset stem writes produced by a LeafProducer-capable +// hasher (the binary hasher). It is nil for merkle hashers and consumed by +// encodeBinary to populate the bintrie flat-state map. +func newStateUpdate(rawStorageKey bool, originRoot common.Hash, root common.Hash, blockNumber uint64, deletes map[common.Hash]*accountDelete, updates map[common.Hash]*accountUpdate, nodes *trienode.MergedNodeSet, secondaryHashes map[common.Address]Hashes, leaves []StemWrite) *stateUpdate { var ( - accounts = make(map[common.Hash][]byte) - accountsOrigin = make(map[common.Address][]byte) - storages = make(map[common.Hash]map[common.Hash][]byte) - storagesOrigin = make(map[common.Address]map[common.Hash][]byte) + accounts = make(map[common.Hash]*Account) + accountsOrigin = make(map[common.Address]*Account) + storages = make(map[common.Hash]map[common.Hash]common.Hash) + storagesOrigin = make(map[common.Address]map[common.Hash]common.Hash) codes = make(map[common.Address]*contractCode) ) // Since some accounts might be destroyed and recreated within the same @@ -120,7 +129,7 @@ func newStateUpdate(rawStorageKey bool, originRoot common.Hash, root common.Hash for addrHash, op := range deletes { addr := op.address accounts[addrHash] = nil - accountsOrigin[addr] = op.origin + accountsOrigin[addr] = &op.origin // If storage wiping exists, the hash of the storage slot key must be used if len(op.storages) > 0 { @@ -174,31 +183,168 @@ func newStateUpdate(rawStorageKey bool, originRoot common.Hash, root common.Hash } } return &stateUpdate{ - originRoot: originRoot, - root: root, - blockNumber: blockNumber, - accounts: accounts, - accountsOrigin: accountsOrigin, - storages: storages, - storagesOrigin: storagesOrigin, - rawStorageKey: rawStorageKey, - codes: codes, - nodes: nodes, + originRoot: originRoot, + root: root, + blockNumber: blockNumber, + accounts: accounts, + accountsOrigin: accountsOrigin, + storages: storages, + storagesOrigin: storagesOrigin, + rawStorageKey: rawStorageKey, + codes: codes, + nodes: nodes, + secondaryHashes: secondaryHashes, + leaves: leaves, } } +func encodeSlot(val common.Hash) []byte { + if val == (common.Hash{}) { + return nil + } + blob, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(val[:])) + return blob +} + +func (sc *stateUpdate) encodeMerkle() (map[common.Hash][]byte, map[common.Address][]byte, map[common.Hash]map[common.Hash][]byte, map[common.Address]map[common.Hash][]byte, error) { + var ( + accounts = make(map[common.Hash][]byte) + storages = make(map[common.Hash]map[common.Hash][]byte) + accountOrigin = make(map[common.Address][]byte) + storageOrigin = make(map[common.Address]map[common.Hash][]byte) + ) + for addr, prev := range sc.accountsOrigin { + if prev == nil { + accountOrigin[addr] = nil + } else { + pair, ok := sc.secondaryHashes[addr] + if !ok { + return nil, nil, nil, nil, errors.New("no secondary hash") + } + accountOrigin[addr] = types.SlimAccountRLP(types.StateAccount{ + Balance: prev.Balance, + Nonce: prev.Nonce, + CodeHash: prev.CodeHash, + Root: pair.Prev, + }) + } + + addrHash := crypto.Keccak256Hash(addr.Bytes()) + data := sc.accounts[addrHash] + if data == nil { + accounts[addrHash] = nil + } else { + pair, ok := sc.secondaryHashes[addr] + if !ok { + return nil, nil, nil, nil, errors.New("no secondary hash") + } + accounts[addrHash] = types.SlimAccountRLP(types.StateAccount{ + Balance: data.Balance, + Nonce: data.Nonce, + CodeHash: data.CodeHash, + Root: pair.Hash, + }) + } + } + for addr, slots := range sc.storagesOrigin { + subset := make(map[common.Hash][]byte) + for key, val := range slots { + subset[key] = encodeSlot(val) + } + storageOrigin[addr] = subset + } + for addrHash, slots := range sc.storages { + subset := make(map[common.Hash][]byte) + for key, val := range slots { + subset[key] = encodeSlot(val) + } + storages[addrHash] = subset + } + return accounts, accountOrigin, storages, storageOrigin, nil +} + +// encodeBinary produces the bintrie flat-state representation consumed by +// pathdb. Unlike encodeMerkle (which keys accounts/storage by keccak hashes +// and slim-RLP encodes the values), the bintrie path uses one entry per +// EIP-7864 leaf: +// +// key = stem(31B) || offset(1B), zero-padded into a common.Hash +// value = the 32-byte leaf payload, or nil to clear the offset +// +// Account header writes (BasicData at offset 0, CodeHash at offset 1) and +// storage slot / code chunk writes are uniform — the binary hasher emits +// each as a stemWrite via DrainStemWrites and we route every one of them +// into the accounts map. The storages map stays empty: bintrie has no +// per-account storage grouping at the flat-state layer, and pathdb's +// disklayer/lookup tree both work fine with a single accountData map of +// 32-byte keys. +// +// accountOrigin and storageOrigin are returned empty because state-history +// rollback for bintrie is not yet supported. The pathdb disklayer.revert +// guard blocks bintrie reverts before it would observe these maps. +func (sc *stateUpdate) encodeBinary() (map[common.Hash][]byte, map[common.Address][]byte, map[common.Hash]map[common.Hash][]byte, map[common.Address]map[common.Hash][]byte, error) { + var ( + accounts = make(map[common.Hash][]byte, len(sc.leaves)) + storages = make(map[common.Hash]map[common.Hash][]byte) + accountOrigin = make(map[common.Address][]byte) + storageOrigin = make(map[common.Address]map[common.Hash][]byte) + ) + for _, w := range sc.leaves { + var fullKey common.Hash + copy(fullKey[:len(w.Stem)], w.Stem[:]) + fullKey[len(w.Stem)] = w.Offset + // nil Value means "clear this offset" (account delete or storage + // slot wipe). The pathdb codec interprets a nil entry as a delete + // during flush, matching merkle's nil-blob convention. + if w.Value == nil { + accounts[fullKey] = nil + continue + } + // Defensive length check: every non-nil bintrie leaf must be + // exactly 32 bytes. A wrong-length leaf from the hasher would + // silently produce garbage in the diff layer; catch it here at + // the trust boundary rather than deep in the flush path where + // the stemBuilder.set panic would fire with less context. + if len(w.Value) != 32 { + return nil, nil, nil, nil, fmt.Errorf("bintrie leaf at stem %x offset %d has value len %d, want 32", w.Stem, w.Offset, len(w.Value)) + } + // Take an owning copy: the hasher reuses its underlying buffers + // across blocks, so retaining its slices would create cross-block + // aliasing bugs in the pathdb diff layer. + v := make([]byte, 32) + copy(v, w.Value) + accounts[fullKey] = v + } + return accounts, accountOrigin, storages, storageOrigin, nil +} + // stateSet converts the current stateUpdate object into a triedb.StateSet // object. This function extracts the necessary data from the stateUpdate // struct and formats it into the StateSet structure consumed by the triedb // package. -func (sc *stateUpdate) stateSet() *triedb.StateSet { - return &triedb.StateSet{ - Accounts: sc.accounts, - AccountsOrigin: sc.accountsOrigin, - Storages: sc.storages, - StoragesOrigin: sc.storagesOrigin, - RawStorageKey: sc.rawStorageKey, +func (sc *stateUpdate) stateSet(isMerkle bool) (*triedb.StateSet, error) { + var ( + err error + accounts map[common.Hash][]byte + storages map[common.Hash]map[common.Hash][]byte + accountOrigin map[common.Address][]byte + storageOrigin map[common.Address]map[common.Hash][]byte + ) + if isMerkle { + accounts, accountOrigin, storages, storageOrigin, err = sc.encodeMerkle() + } else { + accounts, accountOrigin, storages, storageOrigin, err = sc.encodeBinary() } + if err != nil { + return nil, err + } + return &triedb.StateSet{ + Accounts: accounts, + AccountsOrigin: accountOrigin, + Storages: storages, + StoragesOrigin: storageOrigin, + RawStorageKey: sc.rawStorageKey, + }, nil } // deriveCodeFields derives the missing fields of contract code changes @@ -246,30 +392,33 @@ func (sc *stateUpdate) ToTracingUpdate() (*tracing.StateUpdate, error) { if !exists { return nil, fmt.Errorf("account %x not found", addr) } + var hashes Hashes + if sc.secondaryHashes != nil { + var ok bool + hashes, ok = sc.secondaryHashes[addr] + if !ok { + return nil, fmt.Errorf("ToTracingUpdate: missing secondary hash for %x", addr) + } + } else { + // Bintrie: no per-account storage sub-tries, use empty root. + hashes = Hashes{Hash: types.EmptyRootHash, Prev: types.EmptyRootHash} + } change := &tracing.AccountChange{} - if len(oldData) > 0 { - acct, err := types.FullAccount(oldData) - if err != nil { - return nil, err - } + if oldData != nil { change.Prev = &types.StateAccount{ - Nonce: acct.Nonce, - Balance: acct.Balance, - Root: acct.Root, - CodeHash: acct.CodeHash, + Nonce: oldData.Nonce, + Balance: oldData.Balance, + Root: hashes.Prev, + CodeHash: oldData.CodeHash, } } - if len(newData) > 0 { - acct, err := types.FullAccount(newData) - if err != nil { - return nil, err - } + if newData != nil { change.New = &types.StateAccount{ - Nonce: acct.Nonce, - Balance: acct.Balance, - Root: acct.Root, - CodeHash: acct.CodeHash, + Nonce: newData.Nonce, + Balance: newData.Balance, + Root: hashes.Hash, + CodeHash: newData.CodeHash, } } update.AccountChanges[addr] = change @@ -284,40 +433,24 @@ func (sc *stateUpdate) ToTracingUpdate() (*tracing.StateUpdate, error) { } storageChanges := make(map[common.Hash]*tracing.StorageChange, len(slots)) - for key, encPrev := range slots { + for key, prev := range slots { // Get new value - handle both raw and hashed key formats var ( exists bool - encNew []byte - decPrev []byte - decNew []byte - err error + current common.Hash ) if sc.rawStorageKey { - encNew, exists = subset[crypto.Keccak256Hash(key.Bytes())] + current, exists = subset[crypto.Keccak256Hash(key.Bytes())] } else { - encNew, exists = subset[key] + current, exists = subset[key] } if !exists { return nil, fmt.Errorf("storage slot %x-%x not found", addr, key) } - // Decode the prev and new values - if len(encPrev) > 0 { - _, decPrev, _, err = rlp.Split(encPrev) - if err != nil { - return nil, fmt.Errorf("failed to decode prevValue: %v", err) - } - } - if len(encNew) > 0 { - _, decNew, _, err = rlp.Split(encNew) - if err != nil { - return nil, fmt.Errorf("failed to decode newValue: %v", err) - } - } storageChanges[key] = &tracing.StorageChange{ - Prev: common.BytesToHash(decPrev), - New: common.BytesToHash(decNew), + Prev: prev, + New: current, } } update.StorageChanges[addr] = storageChanges diff --git a/core/state/trie_prefetcher.go b/core/state/trie_prefetcher.go index a9faddcdff..69ebf599f2 100644 --- a/core/state/trie_prefetcher.go +++ b/core/state/trie_prefetcher.go @@ -22,451 +22,225 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" - "github.com/ethereum/go-ethereum/metrics" ) -var ( - // triePrefetchMetricsPrefix is the prefix under which to publish the metrics. - triePrefetchMetricsPrefix = "trie/prefetch/" +var errTerminated = errors.New("fetcher is already terminated") - // errTerminated is returned if a fetcher is attempted to be operated after it - // has already terminated. - errTerminated = errors.New("fetcher is already terminated") +type slotKey struct { + addr common.Address + slot common.Hash +} + +type taskKind uint8 + +const ( + kindAccount taskKind = iota + kindStorage ) -// triePrefetcher is an active prefetcher, which receives accounts or storage -// items and does trie-loading of them. The goal is to get as much useful content -// into the caches as possible. -// -// Note, the prefetcher's API is not thread safe. -type triePrefetcher struct { - verkle bool // Flag whether the prefetcher is in verkle mode - db Database // Database to fetch trie nodes through - root common.Hash // Root hash of the account trie for metrics - fetchers map[string]*subfetcher // Subfetchers for each trie - term chan struct{} // Channel to signal interruption - noreads bool // Whether to ignore state-read-only prefetch requests +type prefetchTask struct { + read bool + kind taskKind - deliveryMissMeter *metrics.Meter - - accountLoadReadMeter *metrics.Meter - accountLoadWriteMeter *metrics.Meter - accountDupReadMeter *metrics.Meter - accountDupWriteMeter *metrics.Meter - accountDupCrossMeter *metrics.Meter - accountWasteMeter *metrics.Meter - - storageLoadReadMeter *metrics.Meter - storageLoadWriteMeter *metrics.Meter - storageDupReadMeter *metrics.Meter - storageDupWriteMeter *metrics.Meter - storageDupCrossMeter *metrics.Meter - storageWasteMeter *metrics.Meter + accounts []common.Address // kindAccount: addresses to prefetch + account common.Address // kindStorage: owner address + slots []common.Hash // kindStorage: slot keys to prefetch } -func newTriePrefetcher(db Database, root common.Hash, namespace string, noreads bool) *triePrefetcher { - prefix := triePrefetchMetricsPrefix + namespace - return &triePrefetcher{ - verkle: db.TrieDB().IsVerkle(), - db: db, - root: root, - fetchers: make(map[string]*subfetcher), // Active prefetchers use the fetchers map - term: make(chan struct{}), - noreads: noreads, - - deliveryMissMeter: metrics.GetOrRegisterMeter(prefix+"/deliverymiss", nil), - - accountLoadReadMeter: metrics.GetOrRegisterMeter(prefix+"/account/load/read", nil), - accountLoadWriteMeter: metrics.GetOrRegisterMeter(prefix+"/account/load/write", nil), - accountDupReadMeter: metrics.GetOrRegisterMeter(prefix+"/account/dup/read", nil), - accountDupWriteMeter: metrics.GetOrRegisterMeter(prefix+"/account/dup/write", nil), - accountDupCrossMeter: metrics.GetOrRegisterMeter(prefix+"/account/dup/cross", nil), - accountWasteMeter: metrics.GetOrRegisterMeter(prefix+"/account/waste", nil), - - storageLoadReadMeter: metrics.GetOrRegisterMeter(prefix+"/storage/load/read", nil), - storageLoadWriteMeter: metrics.GetOrRegisterMeter(prefix+"/storage/load/write", nil), - storageDupReadMeter: metrics.GetOrRegisterMeter(prefix+"/storage/dup/read", nil), - storageDupWriteMeter: metrics.GetOrRegisterMeter(prefix+"/storage/dup/write", nil), - storageDupCrossMeter: metrics.GetOrRegisterMeter(prefix+"/storage/dup/cross", nil), - storageWasteMeter: metrics.GetOrRegisterMeter(prefix+"/storage/waste", nil), - } -} - -// terminate iterates over all the subfetchers and issues a termination request -// to all of them. Depending on the async parameter, the method will either block -// until all subfetchers spin down, or return immediately. -func (p *triePrefetcher) terminate(async bool) { - // Short circuit if the fetcher is already closed - select { - case <-p.term: - return - default: - } - // Terminate all sub-fetchers, sync or async, depending on the request - for _, fetcher := range p.fetchers { - fetcher.terminate(async) - } - close(p.term) -} - -// report aggregates the pre-fetching and usage metrics and reports them. -func (p *triePrefetcher) report() { - if !metrics.Enabled() { - return - } - for _, fetcher := range p.fetchers { - fetcher.wait() // ensure the fetcher's idle before poking in its internals - - if fetcher.root == p.root { - p.accountLoadReadMeter.Mark(int64(len(fetcher.seenReadAddr))) - p.accountLoadWriteMeter.Mark(int64(len(fetcher.seenWriteAddr))) - - p.accountDupReadMeter.Mark(int64(fetcher.dupsRead)) - p.accountDupWriteMeter.Mark(int64(fetcher.dupsWrite)) - p.accountDupCrossMeter.Mark(int64(fetcher.dupsCross)) - - for _, key := range fetcher.usedAddr { - delete(fetcher.seenReadAddr, key) - delete(fetcher.seenWriteAddr, key) - } - p.accountWasteMeter.Mark(int64(len(fetcher.seenReadAddr) + len(fetcher.seenWriteAddr))) - } else { - p.storageLoadReadMeter.Mark(int64(len(fetcher.seenReadSlot))) - p.storageLoadWriteMeter.Mark(int64(len(fetcher.seenWriteSlot))) - - p.storageDupReadMeter.Mark(int64(fetcher.dupsRead)) - p.storageDupWriteMeter.Mark(int64(fetcher.dupsWrite)) - p.storageDupCrossMeter.Mark(int64(fetcher.dupsCross)) - - for _, key := range fetcher.usedSlot { - delete(fetcher.seenReadSlot, key) - delete(fetcher.seenWriteSlot, key) - } - p.storageWasteMeter.Mark(int64(len(fetcher.seenReadSlot) + len(fetcher.seenWriteSlot))) - } - } -} - -// prefetch schedules a batch of trie items to prefetch. After the prefetcher is -// closed, all the following tasks scheduled will not be executed and an error -// will be returned. -// -// prefetch is called from two locations: -// -// 1. Finalize of the state-objects storage roots. This happens at the end -// of every transaction, meaning that if several transactions touches -// upon the same contract, the parameters invoking this method may be -// repeated. -// 2. Finalize of the main account trie. This happens only once per block. -func (p *triePrefetcher) prefetch(owner common.Hash, root common.Hash, addr common.Address, addrs []common.Address, slots []common.Hash, read bool) error { - // If the state item is only being read, but reads are disabled, return - if read && p.noreads { - return nil - } - // Ensure the subfetcher is still alive - select { - case <-p.term: - return errTerminated - default: - } - id := p.trieID(owner, root) - fetcher := p.fetchers[id] - if fetcher == nil { - fetcher = newSubfetcher(p.db, p.root, owner, root, addr) - p.fetchers[id] = fetcher - } - return fetcher.schedule(addrs, slots, read) -} - -// trie returns the trie matching the root hash, blocking until the fetcher of -// the given trie terminates. If no fetcher exists for the request, nil will be -// returned. -func (p *triePrefetcher) trie(owner common.Hash, root common.Hash) Trie { - // Bail if no trie was prefetched for this root - fetcher := p.fetchers[p.trieID(owner, root)] - if fetcher == nil { - log.Error("Prefetcher missed to load trie", "owner", owner, "root", root) - p.deliveryMissMeter.Mark(1) - return nil - } - // Subfetcher exists, retrieve its trie - return fetcher.peek() -} - -// used marks a batch of state items used to allow creating statistics as to -// how useful or wasteful the fetcher is. -func (p *triePrefetcher) used(owner common.Hash, root common.Hash, usedAddr []common.Address, usedSlot []common.Hash) { - if fetcher := p.fetchers[p.trieID(owner, root)]; fetcher != nil { - fetcher.wait() // ensure the fetcher's idle before poking in its internals - - fetcher.usedAddr = append(fetcher.usedAddr, usedAddr...) - fetcher.usedSlot = append(fetcher.usedSlot, usedSlot...) - } -} - -// trieID returns an unique trie identifier consists the trie owner and root hash. -func (p *triePrefetcher) trieID(owner common.Hash, root common.Hash) string { - // The trie in verkle is only identified by state root - if p.verkle { - return p.root.Hex() - } - // The trie in merkle is either identified by state root (account trie), - // or identified by the owner and trie root (storage trie) - trieID := make([]byte, common.HashLength*2) - copy(trieID, owner.Bytes()) - copy(trieID[common.HashLength:], root.Bytes()) - return string(trieID) -} - -// subfetcher is a trie fetcher goroutine responsible for pulling entries for a -// single trie. It is spawned when a new root is encountered and lives until the -// main prefetcher is paused and either all requested items are processed or if -// the trie being worked on is retrieved from the prefetcher. -type subfetcher struct { - db Database // Database to load trie nodes through - state common.Hash // Root hash of the state to prefetch - owner common.Hash // Owner of the trie, usually account hash - root common.Hash // Root hash of the trie to prefetch - addr common.Address // Address of the account that the trie belongs to - trie Trie // Trie being populated with nodes - - tasks []*subfetcherTask // Items queued up for retrieval - lock sync.Mutex // Lock protecting the task queue +// prefetcher is a background goroutine that preloads trie nodes for a single +// trie. It deduplicates requests and stops when explicitly terminated. +type prefetcher struct { + prefetchRead bool // Whether the state read will trigger preloading + trie Trie // Trie being populated with nodes + tasks []*prefetchTask // Items queued up for retrieval + lock sync.Mutex // Lock protecting the task queue wake chan struct{} // Wake channel if a new task is scheduled stop chan struct{} // Channel to interrupt processing term chan struct{} // Channel to signal interruption - seenReadAddr map[common.Address]struct{} // Tracks the accounts already loaded via read operations - seenWriteAddr map[common.Address]struct{} // Tracks the accounts already loaded via write operations - seenReadSlot map[common.Hash]struct{} // Tracks the storage already loaded via read operations - seenWriteSlot map[common.Hash]struct{} // Tracks the storage already loaded via write operations - - dupsRead int // Number of duplicate preload tasks via reads only - dupsWrite int // Number of duplicate preload tasks via writes only - dupsCross int // Number of duplicate preload tasks via read-write-crosses - - usedAddr []common.Address // Tracks the accounts used in the end - usedSlot []common.Hash // Tracks the storage used in the end + seenReadAddr map[common.Address]struct{} // Dedup: accounts loaded via reads + seenWriteAddr map[common.Address]struct{} // Dedup: accounts loaded via writes + seenReadSlot map[slotKey]struct{} // Dedup: slots loaded via reads + seenWriteSlot map[slotKey]struct{} // Dedup: slots loaded via writes } -// subfetcherTask is a trie path to prefetch, tagged with whether it originates -// from a read or a write request. -type subfetcherTask struct { - read bool - addr *common.Address - slot *common.Hash -} - -// newSubfetcher creates a goroutine to prefetch state items belonging to a -// particular root hash. -func newSubfetcher(db Database, state common.Hash, owner common.Hash, root common.Hash, addr common.Address) *subfetcher { - sf := &subfetcher{ - db: db, - state: state, - owner: owner, - root: root, - addr: addr, +// newPrefetcher creates a background goroutine to prefetch state items from the +// given trie. +func newPrefetcher(tr Trie, prefetchRead bool) *prefetcher { + p := &prefetcher{ + prefetchRead: prefetchRead, + trie: tr, wake: make(chan struct{}, 1), stop: make(chan struct{}), term: make(chan struct{}), seenReadAddr: make(map[common.Address]struct{}), seenWriteAddr: make(map[common.Address]struct{}), - seenReadSlot: make(map[common.Hash]struct{}), - seenWriteSlot: make(map[common.Hash]struct{}), + seenReadSlot: make(map[slotKey]struct{}), + seenWriteSlot: make(map[slotKey]struct{}), } - go sf.loop() - return sf + go p.loop() + return p } -// schedule adds a batch of trie keys to the queue to prefetch. -func (sf *subfetcher) schedule(addrs []common.Address, slots []common.Hash, read bool) error { - // Ensure the subfetcher is still alive +// scheduleAccounts adds a batch of accounts to the prefetch queue. +func (p *prefetcher) scheduleAccounts(addrs []common.Address, read bool) error { select { - case <-sf.term: + case <-p.term: return errTerminated default: } - // Append the tasks to the current queue - sf.lock.Lock() - for _, addr := range addrs { - sf.tasks = append(sf.tasks, &subfetcherTask{read: read, addr: &addr}) + if !p.prefetchRead && read { + return nil } - for _, slot := range slots { - sf.tasks = append(sf.tasks, &subfetcherTask{read: read, slot: &slot}) - } - sf.lock.Unlock() + p.lock.Lock() + p.tasks = append(p.tasks, &prefetchTask{ + read: read, + kind: kindAccount, + accounts: addrs, + }) + p.lock.Unlock() - // Notify the background thread to execute scheduled tasks select { - case sf.wake <- struct{}{}: - // Wake signal sent + case p.wake <- struct{}{}: default: - // Wake signal not sent as a previous one is already queued } return nil } -// wait blocks until the subfetcher terminates. This method is used to block on -// an async termination before accessing internal fields from the fetcher. -func (sf *subfetcher) wait() { - <-sf.term -} - -// peek retrieves the fetcher's trie, populated with any pre-fetched data. The -// returned trie will be a shallow copy, so modifying it will break subsequent -// peeks for the original data. The method will block until all the scheduled -// data has been loaded and the fethcer terminated. -func (sf *subfetcher) peek() Trie { - // Block until the fetcher terminates, then retrieve the trie - sf.wait() - return sf.trie -} - -// terminate requests the subfetcher to stop accepting new tasks and spin down -// as soon as everything is loaded. Depending on the async parameter, the method -// will either block until all disk loads finish or return immediately. -func (sf *subfetcher) terminate(async bool) { +// scheduleSlots adds a batch of storage slots to the prefetch queue. +func (p *prefetcher) scheduleSlots(addr common.Address, slots []common.Hash, read bool) error { select { - case <-sf.stop: + case <-p.term: + return errTerminated default: - close(sf.stop) } - if async { - return + if !p.prefetchRead && read { + return nil } - <-sf.term -} + p.lock.Lock() + p.tasks = append(p.tasks, &prefetchTask{ + read: read, + kind: kindStorage, + account: addr, + slots: slots, + }) + p.lock.Unlock() -// openTrie resolves the target trie from database for prefetching. -func (sf *subfetcher) openTrie() error { - // Open the verkle tree if the sub-fetcher is in verkle mode. Note, there is - // only a single fetcher for verkle. - if sf.db.TrieDB().IsVerkle() { - tr, err := sf.db.OpenTrie(sf.state) - if err != nil { - log.Warn("Trie prefetcher failed opening verkle trie", "root", sf.root, "err", err) - return err - } - sf.trie = tr - return nil + select { + case p.wake <- struct{}{}: + default: } - // Open the merkle tree if the sub-fetcher is in merkle mode - if sf.owner == (common.Hash{}) { - tr, err := sf.db.OpenTrie(sf.state) - if err != nil { - log.Warn("Trie prefetcher failed opening account trie", "root", sf.root, "err", err) - return err - } - sf.trie = tr - return nil - } - tr, err := sf.db.OpenStorageTrie(sf.state, sf.addr, sf.root, nil) - if err != nil { - log.Warn("Trie prefetcher failed opening storage trie", "root", sf.root, "err", err) - return err - } - sf.trie = tr return nil } -// loop loads newly-scheduled trie tasks as they are received and loads them, stopping -// when requested. -func (sf *subfetcher) loop() { - // No matter how the loop stops, signal anyone waiting that it's terminated - defer close(sf.term) - - if err := sf.openTrie(); err != nil { - return +// terminate requests the prefetcher to stop and optionally waits for it. +func (p *prefetcher) terminate() { + select { + case <-p.stop: + default: + close(p.stop) } + <-p.term +} + +// loop processes prefetch tasks until terminated. +func (p *prefetcher) loop() { + defer close(p.term) + for { select { - case <-sf.wake: - // Execute all remaining tasks in a single run - sf.lock.Lock() - tasks := sf.tasks - sf.tasks = nil - sf.lock.Unlock() + case <-p.wake: + p.lock.Lock() + tasks := p.tasks + p.tasks = nil + p.lock.Unlock() var ( - addresses []common.Address - slots [][]byte + addrs []common.Address + slots = make(map[common.Address][][]byte) ) for _, task := range tasks { - if task.addr != nil { - key := *task.addr - if task.read { - if _, ok := sf.seenReadAddr[key]; ok { - sf.dupsRead++ + if task.kind == kindAccount { + for _, addr := range task.accounts { + if p.dedupAddr(addr, task.read) { continue } - if _, ok := sf.seenWriteAddr[key]; ok { - sf.dupsCross++ - continue - } - sf.seenReadAddr[key] = struct{}{} - } else { - if _, ok := sf.seenReadAddr[key]; ok { - sf.dupsCross++ - continue - } - if _, ok := sf.seenWriteAddr[key]; ok { - sf.dupsWrite++ - continue - } - sf.seenWriteAddr[key] = struct{}{} + addrs = append(addrs, addr) } - addresses = append(addresses, *task.addr) } else { - key := *task.slot - if task.read { - if _, ok := sf.seenReadSlot[key]; ok { - sf.dupsRead++ + for _, slot := range task.slots { + if p.dedupSlot(task.account, slot, task.read) { continue } - if _, ok := sf.seenWriteSlot[key]; ok { - sf.dupsCross++ - continue - } - sf.seenReadSlot[key] = struct{}{} - } else { - if _, ok := sf.seenReadSlot[key]; ok { - sf.dupsCross++ - continue - } - if _, ok := sf.seenWriteSlot[key]; ok { - sf.dupsWrite++ - continue - } - sf.seenWriteSlot[key] = struct{}{} + slots[task.account] = append(slots[task.account], slot.Bytes()) } - slots = append(slots, key.Bytes()) } } - if len(addresses) != 0 { - if err := sf.trie.PrefetchAccount(addresses); err != nil { + if len(addrs) > 0 { + if err := p.trie.PrefetchAccount(addrs); err != nil { log.Error("Failed to prefetch accounts", "err", err) } } - if len(slots) != 0 { - if err := sf.trie.PrefetchStorage(sf.addr, slots); err != nil { + for addr, keys := range slots { + if err := p.trie.PrefetchStorage(addr, keys); err != nil { log.Error("Failed to prefetch storage", "err", err) } } - case <-sf.stop: - // Termination is requested, abort if no more tasks are pending. If - // there are some, exhaust them first. - sf.lock.Lock() - done := sf.tasks == nil - sf.lock.Unlock() + case <-p.stop: + p.lock.Lock() + done := p.tasks == nil + p.lock.Unlock() if done { return } - // Some tasks are pending, loop and pick them up (that wake branch - // will be selected eventually, whilst stop remains closed to this - // branch will also run afterwards). } } } + +// dedupAddr returns true if addr was already seen for this read/write category. +func (p *prefetcher) dedupAddr(addr common.Address, read bool) bool { + if read { + if _, ok := p.seenReadAddr[addr]; ok { + return true + } + if _, ok := p.seenWriteAddr[addr]; ok { + return true + } + p.seenReadAddr[addr] = struct{}{} + } else { + if _, ok := p.seenReadAddr[addr]; ok { + return true + } + if _, ok := p.seenWriteAddr[addr]; ok { + return true + } + p.seenWriteAddr[addr] = struct{}{} + } + return false +} + +// dedupSlot returns true if slot was already seen for this read/write category. +func (p *prefetcher) dedupSlot(addr common.Address, slot common.Hash, read bool) bool { + key := slotKey{addr: addr, slot: slot} + if read { + if _, ok := p.seenReadSlot[key]; ok { + return true + } + if _, ok := p.seenWriteSlot[key]; ok { + return true + } + p.seenReadSlot[key] = struct{}{} + } else { + if _, ok := p.seenReadSlot[key]; ok { + return true + } + if _, ok := p.seenWriteSlot[key]; ok { + return true + } + p.seenWriteSlot[key] = struct{}{} + } + return false +} diff --git a/core/state/trie_prefetcher_test.go b/core/state/trie_prefetcher_test.go index dad208d01a..556c48ed4c 100644 --- a/core/state/trie_prefetcher_test.go +++ b/core/state/trie_prefetcher_test.go @@ -21,86 +21,71 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/internal/testrand" - "github.com/ethereum/go-ethereum/triedb" + "github.com/ethereum/go-ethereum/trie" "github.com/holiman/uint256" ) func filledStateDB() *StateDB { state, _ := New(types.EmptyRootHash, NewDatabaseForTesting()) - // Create an account and check if the retrieved balance is correct addr := common.HexToAddress("0xaffeaffeaffeaffeaffeaffeaffeaffeaffeaffe") skey := common.HexToHash("aaa") sval := common.HexToHash("bbb") - state.SetBalance(addr, uint256.NewInt(42), tracing.BalanceChangeUnspecified) // Change the account trie - state.SetCode(addr, []byte("hello"), tracing.CodeChangeUnspecified) // Change an external metadata - state.SetState(addr, skey, sval) // Change the storage trie + state.SetBalance(addr, uint256.NewInt(42), tracing.BalanceChangeUnspecified) + state.SetCode(addr, []byte("hello"), tracing.CodeChangeUnspecified) + state.SetState(addr, skey, sval) for i := 0; i < 100; i++ { sk := common.BigToHash(big.NewInt(int64(i))) - state.SetState(addr, sk, sk) // Change the storage trie + state.SetState(addr, sk, sk) } return state } -func TestUseAfterTerminate(t *testing.T) { +func TestSubfetcherUseAfterTerminate(t *testing.T) { db := filledStateDB() - prefetcher := newTriePrefetcher(db.db, db.originalRoot, "", true) - skey := common.HexToHash("aaa") - if err := prefetcher.prefetch(common.Hash{}, db.originalRoot, common.Address{}, nil, []common.Hash{skey}, false); err != nil { - t.Errorf("Prefetch failed before terminate: %v", err) - } - prefetcher.terminate(false) - - if err := prefetcher.prefetch(common.Hash{}, db.originalRoot, common.Address{}, nil, []common.Hash{skey}, false); err == nil { - t.Errorf("Prefetch succeeded after terminate: %v", err) - } - if tr := prefetcher.trie(common.Hash{}, db.originalRoot); tr == nil { - t.Errorf("Prefetcher returned nil trie after terminate") - } -} - -func TestVerklePrefetcher(t *testing.T) { - disk := rawdb.NewMemoryDatabase() - db := triedb.NewDatabase(disk, triedb.VerkleDefaults) - sdb := NewDatabase(db, nil) - - state, err := New(types.EmptyRootHash, sdb) + // Open a trie and create a subfetcher for it. + id := trie.StateTrieID(db.originalRoot) + tr, err := trie.NewStateTrie(id, db.db.TrieDB()) if err != nil { - t.Fatalf("failed to initialize state: %v", err) + t.Fatalf("Failed to open trie: %v", err) } - // Create an account and check if the retrieved balance is correct - addr := testrand.Address() - skey := testrand.Hash() - sval := testrand.Hash() + sf := newPrefetcher(tr, false) + addr := common.HexToAddress("0xaffeaffeaffeaffeaffeaffeaffeaffeaffeaffe") - state.SetBalance(addr, uint256.NewInt(42), tracing.BalanceChangeUnspecified) // Change the account trie - state.SetCode(addr, []byte("hello"), tracing.CodeChangeUnspecified) // Change an external metadata - state.SetState(addr, skey, sval) // Change the storage trie - root, _ := state.Commit(0, true, false) + // Scheduling before termination should succeed. + if err := sf.scheduleAccounts([]common.Address{addr}, false); err != nil { + t.Fatalf("Schedule failed before terminate: %v", err) + } + // Terminate synchronously — waits for pending tasks. + sf.terminate() - state, _ = New(root, sdb) - fetcher := newTriePrefetcher(sdb, root, "", false) - - // Read account - fetcher.prefetch(common.Hash{}, root, common.Address{}, []common.Address{addr}, nil, false) - - // Read storage slot - fetcher.prefetch(crypto.Keccak256Hash(addr.Bytes()), common.Hash{}, addr, nil, []common.Hash{skey}, false) - - fetcher.terminate(false) - accountTrie := fetcher.trie(common.Hash{}, root) - storageTrie := fetcher.trie(crypto.Keccak256Hash(addr.Bytes()), common.Hash{}) - - rootA := accountTrie.Hash() - rootB := storageTrie.Hash() - if rootA != rootB { - t.Fatal("Two different tries are retrieved") + // Scheduling after termination should fail. + if err := sf.scheduleAccounts([]common.Address{addr}, false); err == nil { + t.Fatal("Schedule succeeded after terminate") + } +} + +func TestWrapTriePrefetch(t *testing.T) { + db := filledStateDB() + + // Create a wrapTrie with prefetching enabled. + id := trie.StateTrieID(db.originalRoot) + tr, err := newWrapTrie(id, db.db.TrieDB(), true, true) + if err != nil { + t.Fatalf("Failed to create wrapTrie: %v", err) + } + addr := common.HexToAddress("0xaffeaffeaffeaffeaffeaffeaffeaffeaffeaffe") + + // Schedule some prefetch work. + tr.prefetchAccounts([]common.Address{addr}, false) + + // Terminate and verify the trie is usable. + tr.term() + if tr.Hash() == (common.Hash{}) { + t.Fatal("wrapTrie hash is zero after prefetch") } } diff --git a/eth/api_debug.go b/eth/api_debug.go index 5dd535e672..b55c3c26c2 100644 --- a/eth/api_debug.go +++ b/eth/api_debug.go @@ -232,38 +232,31 @@ func (api *DebugAPI) StorageRangeAt(ctx context.Context, blockNrOrHash rpc.Block } func storageRangeAt(statedb *state.StateDB, root common.Hash, address common.Address, start []byte, maxResult int) (StorageRangeResult, error) { - storageRoot := statedb.GetStorageRoot(address) - if storageRoot == types.EmptyRootHash || storageRoot == (common.Hash{}) { - return StorageRangeResult{}, nil // empty storage + it, err := statedb.Database().Iteratee(root) + if err != nil { + return StorageRangeResult{}, err + } + storageIt, err := it.NewStorageIterator(crypto.Keccak256Hash(address.Bytes()), common.BytesToHash(start)) + if err != nil { + return StorageRangeResult{}, err } // TODO(rjl493456442) it's problematic for traversing the state with in-memory // state mutations, specifically txIndex != 0. - id := trie.StorageTrieID(root, crypto.Keccak256Hash(address.Bytes()), storageRoot) - tr, err := trie.NewStateTrie(id, statedb.Database().TrieDB()) - if err != nil { - return StorageRangeResult{}, err - } - trieIt, err := tr.NodeIterator(start) - if err != nil { - return StorageRangeResult{}, err - } - it := trie.NewIterator(trieIt) result := StorageRangeResult{Storage: storageMap{}} - for i := 0; i < maxResult && it.Next(); i++ { - _, content, _, err := rlp.Split(it.Value) + for i := 0; i < maxResult && storageIt.Next(); i++ { + _, content, _, err := rlp.Split(storageIt.Slot()) if err != nil { return StorageRangeResult{}, err } e := storageEntry{Value: common.BytesToHash(content)} - if preimage := tr.GetKey(it.Key); preimage != nil { - preimage := common.BytesToHash(preimage) + if preimage, err := storageIt.Key(); err == nil { e.Key = &preimage } - result.Storage[common.BytesToHash(it.Key)] = e + result.Storage[storageIt.Hash()] = e } // Add the 'next key' so clients can continue downloading. - if it.Next() { - next := common.BytesToHash(it.Key) + if storageIt.Next() { + next := storageIt.Hash() result.NextKey = &next } return result, nil diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 149e12c5b8..c67dff774c 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -388,17 +388,15 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address, return nil, err } codeHash := statedb.GetCodeHash(address) - storageRoot := statedb.GetStorageRoot(address) - + hasher, err := statedb.Database().Hasher(header.Root) + if err != nil { + return nil, err + } + prover, ok := hasher.(state.Prover) + if !ok { + return nil, errors.New("state proving is not supported") + } if len(keys) > 0 { - var storageTrie state.Trie - if storageRoot != types.EmptyRootHash && storageRoot != (common.Hash{}) { - st, err := statedb.Database().OpenStorageTrie(header.Root, address, storageRoot, nil) - if err != nil { - return nil, err - } - storageTrie = st - } // Create the proofs for the storageKeys. for i, key := range keys { if err := ctx.Err(); err != nil { @@ -414,12 +412,8 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address, } else { outputKey = hexutil.Encode(key[:]) } - if storageTrie == nil { - storageProof[i] = StorageResult{outputKey, &hexutil.Big{}, []string{}} - continue - } var proof proofList - if err := storageTrie.Prove(crypto.Keccak256(key.Bytes()), &proof); err != nil { + if err := prover.ProveStorage(address, crypto.Keccak256Hash(key.Bytes()), &proof); err != nil { return nil, err } value := (*hexutil.Big)(statedb.GetState(address, key).Big()) @@ -427,12 +421,8 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address, } } // Create the accountProof. - tr, err := statedb.Database().OpenTrie(header.Root) - if err != nil { - return nil, err - } var accountProof proofList - if err := tr.Prove(crypto.Keccak256(address.Bytes()), &accountProof); err != nil { + if err := prover.ProveAccount(address, &accountProof); err != nil { return nil, err } balance := statedb.GetBalance(address).ToBig() @@ -442,7 +432,7 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address, Balance: (*hexutil.Big)(balance), CodeHash: codeHash, Nonce: hexutil.Uint64(statedb.GetNonce(address)), - StorageHash: storageRoot, + //StorageHash: storageRoot, // TODO(rjl493456442) StorageProof: storageProof, }, statedb.Error() } diff --git a/miner/worker.go b/miner/worker.go index 39a61de318..7890e8e8e2 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -324,7 +324,10 @@ func (miner *Miner) prepareWork(ctx context.Context, genParams *generateParams, // makeEnv creates a new environment for the sealing block. func (miner *Miner) makeEnv(parent *types.Header, header *types.Header, coinbase common.Address, witness bool) (*environment, error) { // Retrieve the parent state to execute on top. - state, err := miner.chain.StateAt(parent.Root) + state, err := miner.chain.StateWithConfig(parent.Root, core.StateConfig{ + Prefetch: true, + PrefetchRead: witness, + }) if err != nil { return nil, err } @@ -334,8 +337,8 @@ func (miner *Miner) makeEnv(parent *types.Header, header *types.Header, coinbase if err != nil { return nil, err } + state.TraceWitness(bundle) } - state.StartPrefetcher("miner", bundle) // Note the passed coinbase may be different with header.Coinbase. return &environment{ signer: types.MakeSigner(miner.chainConfig, header.Number, header.Time), diff --git a/trie/bintrie/iterator.go b/trie/bintrie/iterator.go index 048d37f766..989f49244b 100644 --- a/trie/bintrie/iterator.go +++ b/trie/bintrie/iterator.go @@ -17,7 +17,9 @@ package bintrie import ( + "bytes" "errors" + "fmt" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/trie" @@ -38,15 +40,341 @@ type binaryNodeIterator struct { stack []binaryNodeIteratorState } -func newBinaryNodeIterator(t *BinaryTrie, _ []byte) (trie.NodeIterator, error) { +func newBinaryNodeIterator(t *BinaryTrie, start []byte) (trie.NodeIterator, error) { if t.Hash() == zero { return &binaryNodeIterator{trie: t, lastErr: errIteratorEnd}, nil } it := &binaryNodeIterator{trie: t, current: t.root} - // it.err = it.seek(start) + if len(start) > 0 { + if err := it.seek(start); err != nil { + return nil, err + } + } return it, nil } +// seek positions the iterator so that the next call to Next(true) advances to +// the first leaf with key >= start. It walks down the trie following start's +// bit path, building the iterator stack along the way. When the chosen path +// dead-ends (Empty, missing child, or a stem strictly less than start), the +// implementation backtracks through the existing stack to find the next +// in-order subtree and descends to its leftmost leaf. +// +// A nil/empty start is a no-op; iteration begins at the trie root as usual. +// +// This is required for resumable bintrie generators (snapshot generation, +// pathdb flat-state population) so that an interrupted run can pick up where +// it left off after a crash or graceful shutdown. +func (it *binaryNodeIterator) seek(start []byte) error { + if len(start) == 0 { + return nil + } + // Pad start to a 32-byte key (the trie's natural key length). + var key [32]byte + copy(key[:], start) + + // Reset state + it.stack = it.stack[:0] + it.current = nil + it.lastErr = nil + + root := it.trie.root + if root == nil { + it.lastErr = errIteratorEnd + return nil + } + if _, isEmpty := root.(Empty); isEmpty { + it.lastErr = errIteratorEnd + return nil + } + + // Resolve the root if it's a HashedNode + resolved, err := it.resolveIfHashed(root, nil, 0) + if err != nil { + return err + } + if resolved == nil { + it.lastErr = errIteratorEnd + return nil + } + if resolved != root { + it.trie.root = resolved + root = resolved + } + + return it.seekDescend(root, key[:]) +} + +// seekDescend walks down from `node` following key's bit path. For each +// InternalNode encountered, it pushes the node onto the stack with Index set +// to the bit it descended into (0 for left, 1 for right) and recurses into +// the chosen child. On a StemNode it positions at the appropriate value +// offset and returns. On a dead end (Empty, nil, stem < key), it delegates +// to seekBacktrack to find the next valid subtree. +func (it *binaryNodeIterator) seekDescend(node BinaryNode, key []byte) error { + for { + switch n := node.(type) { + case *InternalNode: + depth := n.depth + if depth >= 31*8 { + return errors.New("seek: internal node too deep") + } + bit := key[depth/8] >> (7 - uint(depth%8)) & 1 + + // Push this internal node with Index = chosen bit. The Next() + // loop interprets Index as "the side currently being explored", + // so this is consistent with normal iteration state. + it.stack = append(it.stack, binaryNodeIteratorState{Node: n, Index: int(bit)}) + it.current = n + + var child BinaryNode + if bit == 0 { + child = n.left + } else { + child = n.right + } + if child == nil { + return it.seekBacktrack() + } + if _, isEmpty := child.(Empty); isEmpty { + return it.seekBacktrack() + } + // Resolve a hashed child using the current key as the path source. + resolved, err := it.resolveIfHashed(child, key, depth+1) + if err != nil { + return err + } + if resolved == nil { + return it.seekBacktrack() + } + if resolved != child { + if bit == 0 { + n.left = resolved + } else { + n.right = resolved + } + } + node = resolved + + case *StemNode: + cmp := bytes.Compare(n.Stem, key[:StemSize]) + if cmp < 0 { + // Stem is strictly before our target. Don't push it; backtrack + // to find the next subtree to the right. + return it.seekBacktrack() + } + startOffset := 0 + if cmp == 0 { + startOffset = int(key[StemSize]) + } + it.stack = append(it.stack, binaryNodeIteratorState{Node: n, Index: startOffset}) + it.current = n + return nil + + default: + return fmt.Errorf("seek: unexpected node type %T", node) + } + } +} + +// seekBacktrack walks the existing stack backward looking for the first +// InternalNode whose right subtree hasn't been considered yet. If found, it +// flips that node's Index to 1 and descends into the leftmost leaf of the +// right subtree. If no such ancestor exists, it sets errIteratorEnd. +func (it *binaryNodeIterator) seekBacktrack() error { + for len(it.stack) > 0 { + top := &it.stack[len(it.stack)-1] + n, ok := top.Node.(*InternalNode) + if !ok { + // Not an InternalNode (e.g., a StemNode pushed elsewhere). Pop and + // continue. seekDescend never pushes non-internal nodes before + // returning, so this is a defensive fallback. + it.stack = it.stack[:len(it.stack)-1] + continue + } + if top.Index == 0 { + // We were positioned in the left subtree. Try the right sibling. + top.Index = 1 + right := n.right + if right == nil { + it.stack = it.stack[:len(it.stack)-1] + continue + } + if _, isEmpty := right.(Empty); isEmpty { + it.stack = it.stack[:len(it.stack)-1] + continue + } + // Resolve the right child if it's hashed. Use a synthetic path + // where the bit at this depth is 1 (we're descending right). + resolved, err := it.resolveRightChild(n) + if err != nil { + return err + } + if resolved == nil { + it.stack = it.stack[:len(it.stack)-1] + continue + } + if resolved != right { + n.right = resolved + right = resolved + } + it.current = right + return it.seekLeftmost(right) + } + // Index == 1: we were already in the right subtree. Both subtrees of + // this internal node have been considered. Pop and try higher. + it.stack = it.stack[:len(it.stack)-1] + } + it.lastErr = errIteratorEnd + return nil +} + +// seekLeftmost descends into the leftmost leaf of the subtree rooted at +// `node`, pushing internal nodes onto the stack with Index = 0 (left first). +// It positions the iterator at a StemNode with Index = 0, ready to scan +// values from offset 0. +func (it *binaryNodeIterator) seekLeftmost(node BinaryNode) error { + for { + switch n := node.(type) { + case *InternalNode: + it.stack = append(it.stack, binaryNodeIteratorState{Node: n, Index: 0}) + it.current = n + + child := n.left + pickedRight := false + if child == nil { + child = n.right + pickedRight = true + } + if child != nil { + if _, isEmpty := child.(Empty); isEmpty { + if !pickedRight { + child = n.right + pickedRight = true + } + if child != nil { + if _, isEmpty2 := child.(Empty); isEmpty2 { + child = nil + } + } + } + } + if child == nil { + // Both children are empty/nil — degenerate. Pop and let seek + // backtrack handle it. (This shouldn't normally happen for a + // well-formed trie because internal nodes always have at least + // two non-empty children at construction time.) + it.stack = it.stack[:len(it.stack)-1] + return it.seekBacktrack() + } + if pickedRight { + it.stack[len(it.stack)-1].Index = 1 + } + // Resolve hashed child + resolved, err := it.resolveIfHashed(child, nil, n.depth+1) + if err != nil { + return err + } + if resolved == nil { + // Resolution failed; treat as empty and try the other side. + if pickedRight { + // Already tried right; nothing left. + it.stack = it.stack[:len(it.stack)-1] + return it.seekBacktrack() + } + // Try right + right := n.right + if right == nil { + it.stack = it.stack[:len(it.stack)-1] + return it.seekBacktrack() + } + if _, isEmpty := right.(Empty); isEmpty { + it.stack = it.stack[:len(it.stack)-1] + return it.seekBacktrack() + } + it.stack[len(it.stack)-1].Index = 1 + resolved, err = it.resolveIfHashed(right, nil, n.depth+1) + if err != nil { + return err + } + if resolved == nil { + it.stack = it.stack[:len(it.stack)-1] + return it.seekBacktrack() + } + n.right = resolved + node = resolved + continue + } + if resolved != child { + if pickedRight { + n.right = resolved + } else { + n.left = resolved + } + } + node = resolved + + case *StemNode: + it.stack = append(it.stack, binaryNodeIteratorState{Node: n, Index: 0}) + it.current = n + return nil + + default: + return fmt.Errorf("seekLeftmost: unexpected node type %T", node) + } + } +} + +// resolveIfHashed checks whether the given node is a HashedNode and, if so, +// uses the trie's nodeResolver to load and deserialize the underlying node. +// Returns the resolved node or the original if no resolution was needed. +// Returns (nil, nil) if the resolver returned no data (e.g., zero hash). +// +// keyForPath supplies the bit path used to address the node; for the root +// this is unused (path is empty). depth is the depth of the node being +// resolved, used for the deserialized node's internal depth field. +func (it *binaryNodeIterator) resolveIfHashed(node BinaryNode, keyForPath []byte, depth int) (BinaryNode, error) { + hn, ok := node.(HashedNode) + if !ok { + return node, nil + } + var path []byte + if depth > 0 && keyForPath != nil { + var err error + path, err = keyToPath(depth-1, keyForPath) + if err != nil { + return nil, err + } + } + data, err := it.trie.nodeResolver(path, common.Hash(hn)) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + resolved, err := DeserializeNodeWithHash(data, depth, common.Hash(hn)) + if err != nil { + return nil, err + } + return resolved, nil +} + +// resolveRightChild resolves the right child of an InternalNode using a +// synthetic path that ends in bit=1. This is used by seekBacktrack when +// flipping from left to right exploration. +func (it *binaryNodeIterator) resolveRightChild(parent *InternalNode) (BinaryNode, error) { + right := parent.right + if _, ok := right.(HashedNode); !ok { + return right, nil + } + // Build a 32-byte key whose bit at parent.depth is 1; rest doesn't matter + // for the path computation. + var key [32]byte + key[parent.depth/8] |= 1 << (7 - uint(parent.depth%8)) + return it.resolveIfHashed(right, key[:], parent.depth+1) +} + // Next moves the iterator to the next node. If the parameter is false, any child // nodes will be skipped. func (it *binaryNodeIterator) Next(descend bool) bool { diff --git a/trie/bintrie/iterator_test.go b/trie/bintrie/iterator_test.go index 3e717c07ba..6ee03525be 100644 --- a/trie/bintrie/iterator_test.go +++ b/trie/bintrie/iterator_test.go @@ -18,6 +18,7 @@ package bintrie import ( "bytes" + "slices" "testing" "github.com/ethereum/go-ethereum/common" @@ -206,6 +207,241 @@ func TestIteratorDeepTree(t *testing.T) { } } +// collectLeaves iterates the trie and returns all (key, value) pairs visited. +func collectLeaves(t *testing.T, tr *BinaryTrie, start []byte) [][2][]byte { + t.Helper() + it, err := newBinaryNodeIterator(tr, start) + if err != nil { + t.Fatal(err) + } + var out [][2][]byte + for it.Next(true) { + if it.Leaf() { + k := slices.Clone(it.LeafKey()) + v := slices.Clone(it.LeafBlob()) + out = append(out, [2][]byte{k, v}) + } + } + if it.Error() != nil { + t.Fatalf("iterator error: %v", it.Error()) + } + return out +} + +// TestSeekEmptyStart verifies that seek with a nil/empty start behaves like +// a fresh iterator (no skipping). +func TestSeekEmptyStart(t *testing.T) { + tr := makeTrie(t, [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("8000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + }) + // Both nil and empty slice should iterate everything. + if got := len(collectLeaves(t, tr, nil)); got != 2 { + t.Fatalf("nil start: expected 2 leaves, got %d", got) + } + if got := len(collectLeaves(t, tr, []byte{})); got != 2 { + t.Fatalf("empty start: expected 2 leaves, got %d", got) + } +} + +// TestSeekToExactKey verifies that seeking to an existing leaf key positions +// the iterator at that exact leaf. +func TestSeekToExactKey(t *testing.T) { + keys := [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000002"), twoKey}, + {common.HexToHash("8000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + } + tr := makeTrie(t, keys) + + // Seek to the second key. We expect to see [key2, key3]. + start := keys[1][0] + got := collectLeaves(t, tr, start[:]) + if len(got) != 2 { + t.Fatalf("expected 2 leaves after seek to %x, got %d", start, len(got)) + } + if !bytes.Equal(got[0][0], keys[1][0][:]) { + t.Fatalf("first leaf after seek: got %x, want %x", got[0][0], keys[1][0]) + } + if !bytes.Equal(got[1][0], keys[2][0][:]) { + t.Fatalf("second leaf after seek: got %x, want %x", got[1][0], keys[2][0]) + } +} + +// TestSeekToBetweenKeys verifies that seeking to a key that doesn't exist +// positions the iterator at the next existing key (in-order). +func TestSeekToBetweenKeys(t *testing.T) { + keys := [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000005"), twoKey}, + {common.HexToHash("8000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + } + tr := makeTrie(t, keys) + + // Seek to a key between key0 and key1: should land at key1. + between := common.HexToHash("0000000000000000000000000000000000000000000000000000000000000003") + got := collectLeaves(t, tr, between[:]) + if len(got) != 2 { + t.Fatalf("expected 2 leaves after seek between, got %d", len(got)) + } + if !bytes.Equal(got[0][0], keys[1][0][:]) { + t.Fatalf("first leaf: got %x, want %x", got[0][0], keys[1][0]) + } + if !bytes.Equal(got[1][0], keys[2][0][:]) { + t.Fatalf("second leaf: got %x, want %x", got[1][0], keys[2][0]) + } +} + +// TestSeekIntoEmptySubtree verifies that seeking into a subtree where the +// chosen path is empty correctly backtracks to the next populated subtree. +func TestSeekIntoEmptySubtree(t *testing.T) { + // Build a trie with stems split across the bit-0 and bit-1 subtrees. + keys := [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("8000000000000000000000000000000000000000000000000000000000000001"), twoKey}, + } + tr := makeTrie(t, keys) + + // Seek to a key in a subtree that's entirely missing (e.g., 0x40...). + // The high bit is 0, so we'd descend left, but the left subtree only has + // keys with the FIRST bit being 0 — and the seek bit pattern would walk + // into a position that has no leaves at or after it on the left side, + // requiring backtrack to the right subtree. + missing := common.HexToHash("4000000000000000000000000000000000000000000000000000000000000001") + got := collectLeaves(t, tr, missing[:]) + // Should land at key1 (the right subtree leaf). + if len(got) != 1 { + t.Fatalf("expected 1 leaf after seek into missing subtree, got %d", len(got)) + } + if !bytes.Equal(got[0][0], keys[1][0][:]) { + t.Fatalf("leaf: got %x, want %x", got[0][0], keys[1][0]) + } +} + +// TestSeekPastEnd verifies that seeking past the last key returns no leaves. +func TestSeekPastEnd(t *testing.T) { + keys := [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000002"), oneKey}, + } + tr := makeTrie(t, keys) + + // Seek past the maximum key. + beyond := common.HexToHash("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + got := collectLeaves(t, tr, beyond[:]) + if len(got) != 0 { + t.Fatalf("expected 0 leaves after seek past end, got %d: %x", len(got), got) + } +} + +// TestSeekWithinSameStem verifies that seeking within a single stem (multiple +// values at different offsets) positions correctly at the requested offset. +func TestSeekWithinSameStem(t *testing.T) { + // All three keys share the same stem; only the last byte differs. + keys := [][2]common.Hash{ + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000001"), oneKey}, + {common.HexToHash("0000000000000000000000000000000000000000000000000000000000000005"), twoKey}, + {common.HexToHash("00000000000000000000000000000000000000000000000000000000000000ff"), oneKey}, + } + tr := makeTrie(t, keys) + + // Seek to offset 5: should yield keys 1 (offset 5) and 2 (offset 0xff). + start := common.HexToHash("0000000000000000000000000000000000000000000000000000000000000005") + got := collectLeaves(t, tr, start[:]) + if len(got) != 2 { + t.Fatalf("expected 2 leaves, got %d", len(got)) + } + if got[0][0][31] != 0x05 { + t.Fatalf("first leaf offset: got 0x%02x, want 0x05", got[0][0][31]) + } + if got[1][0][31] != 0xff { + t.Fatalf("second leaf offset: got 0x%02x, want 0xff", got[1][0][31]) + } + + // Seek to offset 6 (between 5 and 0xff): should yield only key 2. + start[31] = 0x06 + got = collectLeaves(t, tr, start[:]) + if len(got) != 1 { + t.Fatalf("expected 1 leaf after seek to offset 6, got %d", len(got)) + } + if got[0][0][31] != 0xff { + t.Fatalf("leaf offset: got 0x%02x, want 0xff", got[0][0][31]) + } +} + +// TestSeekResumeSimulation simulates a generator interruption: iterate halfway, +// extract the last leaf key, build a fresh iterator, seek to the next key, and +// verify that the resumed iteration produces the remaining leaves. +func TestSeekResumeSimulation(t *testing.T) { + // Construct a deterministic set of keys. + var keys [][2]common.Hash + for i := range 16 { + var k common.Hash + k[0] = byte(i << 4) // distribute across the high nibble + k[31] = 0x01 + keys = append(keys, [2]common.Hash{k, oneKey}) + } + tr := makeTrie(t, keys) + + // First pass: collect all leaves. + all := collectLeaves(t, tr, nil) + if len(all) != 16 { + t.Fatalf("first pass: expected 16 leaves, got %d", len(all)) + } + + // Stop after the 7th leaf and resume. + stopIdx := 7 + lastKey := all[stopIdx][0] + + // Resume: seek to the byte AFTER lastKey (we use lastKey + 1 in the last + // byte; for our keys this is sufficient because each key's last byte is + // 0x01 and we want to go to the NEXT stem). + resumeKey := slices.Clone(lastKey) + // Increment the last byte; if it overflows, that's fine for these keys + // because all our last bytes are 0x01. + resumeKey[31]++ + // But actually we want to start AT lastKey + 1, which for our keys means + // we want the NEXT stem. Since each stem has only one value at offset 0x01 + // and we want everything strictly after lastKey, set offset to 0x02. + got := collectLeaves(t, tr, resumeKey) + if len(got) != len(all)-stopIdx-1 { + t.Fatalf("resume: expected %d leaves, got %d", len(all)-stopIdx-1, len(got)) + } + for i, leaf := range got { + want := all[stopIdx+1+i] + if !bytes.Equal(leaf[0], want[0]) { + t.Fatalf("resume leaf %d: got %x, want %x", i, leaf[0], want[0]) + } + } +} + +// TestSeekDeepTree verifies seek works on a tree with a long shared prefix. +func TestSeekDeepTree(t *testing.T) { + keys := [][2]common.Hash{ + {common.HexToHash("0000000000C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0C0"), oneKey}, + {common.HexToHash("0000000000E00000000000000000000000000000000000000000000000000000"), twoKey}, + } + tr := makeTrie(t, keys) + + // Seek to the first key exactly. + got := collectLeaves(t, tr, keys[0][0][:]) + if len(got) != 2 { + t.Fatalf("seek to first: expected 2 leaves, got %d", len(got)) + } + if !bytes.Equal(got[0][0], keys[0][0][:]) { + t.Fatalf("first leaf: got %x, want %x", got[0][0], keys[0][0]) + } + + // Seek to the second key exactly. + got = collectLeaves(t, tr, keys[1][0][:]) + if len(got) != 1 { + t.Fatalf("seek to second: expected 1 leaf, got %d", len(got)) + } + if !bytes.Equal(got[0][0], keys[1][0][:]) { + t.Fatalf("leaf: got %x, want %x", got[0][0], keys[1][0]) + } +} + // TestIteratorNodeCount verifies the total number of Next(true) calls // for a known tree structure. func TestIteratorNodeCount(t *testing.T) { diff --git a/trie/bintrie/pack.go b/trie/bintrie/pack.go new file mode 100644 index 0000000000..84c8efb8f4 --- /dev/null +++ b/trie/bintrie/pack.go @@ -0,0 +1,78 @@ +// Copyright 2026 go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package bintrie + +import ( + "encoding/binary" + + "github.com/holiman/uint256" +) + +// PackBasicData encodes an account's basic metadata (code size, nonce, +// balance) into the 32-byte BasicData leaf value defined by EIP-7864. +// +// The canonical spec layout is: +// +// byte 0 version (currently always 0, left as the implicit zero) +// bytes 1..4 reserved +// bytes 5..7 code_size (big-endian, 3 bytes, max 2^24-1) +// bytes 8..15 nonce (big-endian, 8 bytes) +// bytes 16..31 balance (big-endian, right-justified, 16 bytes) +// +// For historical reasons the existing BinaryTrie implementation writes +// code_size as a 4-byte big-endian uint32 starting at byte 4 rather than a +// 3-byte big-endian field starting at byte 5. Byte 4 is reserved per the +// EIP, so for any realistic code size (below 2^24 ≈ 16 MB, well under the +// EIP-170 24 KB contract limit) the high byte is always 0 and the two +// encodings are bit-equivalent. This function preserves that existing +// behavior byte-for-byte so callers can substitute it for the inlined +// encoding in BinaryTrie.UpdateAccount without changing any state root. +// +// Any future correction of the byte offset is a consensus-level change +// and must be coordinated across clients. +func PackBasicData(nonce uint64, balance *uint256.Int, codeSize int) [HashSize]byte { + var data [HashSize]byte + binary.BigEndian.PutUint32(data[BasicDataCodeSizeOffset-1:], uint32(codeSize)) + binary.BigEndian.PutUint64(data[BasicDataNonceOffset:], nonce) + + // Balance is a 256-bit uint stored right-justified in the lower 16 + // bytes of BasicData. For dev-mode accounts whose balance exceeds + // 2^128 - 1 (e.g. 0xff × HashSize), truncate to the upper 16 bytes to + // match the existing BinaryTrie behavior rather than panicking. + balanceBytes := balance.Bytes() + if len(balanceBytes) > 16 { + balanceBytes = balanceBytes[16:] + } + copy(data[HashSize-len(balanceBytes):], balanceBytes[:]) + return data +} + +// UnpackBasicData is the inverse of PackBasicData. It decodes the code +// size, nonce, and balance fields from a BasicData leaf value. +// +// Note: the returned balance is always 128-bit or smaller because the +// encoding reserves 16 bytes for it; dev-mode accounts whose pre-encoded +// balance exceeded 2^128 - 1 are not recoverable losslessly. +func UnpackBasicData(data [HashSize]byte) (nonce uint64, balance *uint256.Int, codeSize int) { + codeSize = int(binary.BigEndian.Uint32(data[BasicDataCodeSizeOffset-1:])) + nonce = binary.BigEndian.Uint64(data[BasicDataNonceOffset:]) + + var b [16]byte + copy(b[:], data[BasicDataBalanceOffset:]) + balance = new(uint256.Int).SetBytes(b[:]) + return +} diff --git a/trie/bintrie/trie.go b/trie/bintrie/trie.go index b1e3c991c0..727ffea389 100644 --- a/trie/bintrie/trie.go +++ b/trie/bintrie/trie.go @@ -242,29 +242,21 @@ func (t *BinaryTrie) GetStorage(addr common.Address, key []byte) ([]byte, error) } // UpdateAccount updates the account information for the given address. +// +// The BasicData encoding (nonce, balance, code size packed into 32 bytes) +// is delegated to PackBasicData so that callers outside the trie layer — +// notably the flat-state codec that writes stem blobs to pathdb — can +// produce a bit-identical value without duplicating the layout logic. func (t *BinaryTrie) UpdateAccount(addr common.Address, acc *types.StateAccount, codeLen int) error { var ( - err error - basicData [HashSize]byte - values = make([][]byte, StemNodeWidth) - stem = GetBinaryTreeKey(addr, zero[:]) + values = make([][]byte, StemNodeWidth) + stem = GetBinaryTreeKey(addr, zero[:]) ) - binary.BigEndian.PutUint32(basicData[BasicDataCodeSizeOffset-1:], uint32(codeLen)) - binary.BigEndian.PutUint64(basicData[BasicDataNonceOffset:], acc.Nonce) - - // Because the balance is a max of 16 bytes, truncate - // the extra values. This happens in devmode, where - // 0xff**HashSize is allocated to the developer account. - balanceBytes := acc.Balance.Bytes() - // TODO: reduce the size of the allocation in devmode, then panic instead - // of truncating. - if len(balanceBytes) > 16 { - balanceBytes = balanceBytes[16:] - } - copy(basicData[HashSize-len(balanceBytes):], balanceBytes[:]) + basicData := PackBasicData(acc.Nonce, acc.Balance, codeLen) values[BasicDataLeafKey] = basicData[:] values[CodeHashLeafKey] = acc.CodeHash[:] + var err error t.root, err = t.root.InsertValuesAtStem(stem, values, t.nodeResolver, 0) return err } @@ -352,9 +344,10 @@ func (t *BinaryTrie) Commit(_ bool) (common.Hash, *trienode.NodeSet) { } // NodeIterator returns an iterator that returns nodes of the trie. Iteration -// starts at the key after the given start key. +// starts at the first leaf with key >= startKey. A nil/empty startKey iterates +// the whole trie. func (t *BinaryTrie) NodeIterator(startKey []byte) (trie.NodeIterator, error) { - return newBinaryNodeIterator(t, nil) + return newBinaryNodeIterator(t, startKey) } // Prove constructs a Merkle proof for key. The result contains all encoded nodes diff --git a/trie/trienode/node.go b/trie/trienode/node.go index 228a64f04c..0bd630c3b3 100644 --- a/trie/trienode/node.go +++ b/trie/trienode/node.go @@ -259,6 +259,16 @@ func (set *MergedNodeSet) Merge(other *NodeSet) error { return nil } +// MergeSet merges the provided set into local one. +func (set *MergedNodeSet) MergeSet(other *MergedNodeSet) error { + for _, subset := range other.Sets { + if err := set.Merge(subset); err != nil { + return err + } + } + return nil +} + // Nodes returns a two-dimensional map for internal nodes. func (set *MergedNodeSet) Nodes() map[common.Hash]map[string]*Node { nodes := make(map[common.Hash]map[string]*Node, len(set.Sets)) diff --git a/triedb/pathdb/buffer.go b/triedb/pathdb/buffer.go index 5d3099285f..1efed91e7a 100644 --- a/triedb/pathdb/buffer.go +++ b/triedb/pathdb/buffer.go @@ -132,7 +132,10 @@ func (b *buffer) size() uint64 { // flush persists the in-memory dirty trie node into the disk if the configured // memory threshold is reached. Note, all data must be written atomically. -func (b *buffer) flush(root common.Hash, db ethdb.KeyValueStore, freezers []ethdb.AncientWriter, progress []byte, nodesCache, statesCache *fastcache.Cache, id uint64, postFlush func()) { +// +// codec is the flat-state codec used for state persistence and cache key +// derivation. It is supplied by the disk layer's owning Database. +func (b *buffer) flush(root common.Hash, db ethdb.KeyValueStore, codec flatStateCodec, freezers []ethdb.AncientWriter, progress []byte, nodesCache, statesCache *fastcache.Cache, id uint64, postFlush func()) { if b.done != nil { panic("duplicated flush operation") } @@ -158,7 +161,7 @@ func (b *buffer) flush(root common.Hash, db ethdb.KeyValueStore, freezers []ethd // Terminate the state snapshot generation if it's active var ( start = time.Now() - batch = db.NewBatchWithSize((b.nodes.dbsize() + b.states.dbsize()) * 11 / 10) // extra 10% for potential pebble internal stuff + batch = db.NewBatchWithSize((b.nodes.dbsize() + b.states.dbsize(codec)) * 11 / 10) // extra 10% for potential pebble internal stuff ) // Explicitly sync the state freezer to ensure all written data is persisted to disk // before updating the key-value store. @@ -170,7 +173,11 @@ func (b *buffer) flush(root common.Hash, db ethdb.KeyValueStore, freezers []ethd return } nodes := b.nodes.write(batch, nodesCache) - accounts, slots := b.states.write(batch, progress, statesCache) + accounts, slots, flushErr := b.states.write(batch, codec, progress, statesCache) + if flushErr != nil { + b.flushErr = flushErr + return + } rawdb.WritePersistentStateID(batch, id) rawdb.WriteSnapshotRoot(batch, root) diff --git a/triedb/pathdb/context.go b/triedb/pathdb/context.go index a5704de81a..dee0e0c657 100644 --- a/triedb/pathdb/context.go +++ b/triedb/pathdb/context.go @@ -95,19 +95,21 @@ type generatorContext struct { account *holdableIterator // Iterator of account snapshot data storage *holdableIterator // Iterator of storage snapshot data db ethdb.KeyValueStore // Key-value store containing the snapshot data + codec flatStateCodec // Flat-state codec for prefix/key-length selection batch ethdb.Batch // Database batch for writing data atomically logged time.Time // The timestamp when last generation progress was displayed } // newGeneratorContext initializes the context for generation. -func newGeneratorContext(root common.Hash, marker []byte, db ethdb.KeyValueStore) *generatorContext { +func newGeneratorContext(root common.Hash, marker []byte, db ethdb.KeyValueStore, codec flatStateCodec) *generatorContext { ctx := &generatorContext{ root: root, db: db, + codec: codec, batch: db.NewBatch(), logged: time.Now(), } - accMarker, storageMarker := splitMarker(marker) + accMarker, storageMarker := codec.SplitMarker(marker) ctx.openIterator(snapAccount, accMarker) ctx.openIterator(snapStorage, storageMarker) return ctx @@ -118,12 +120,12 @@ func newGeneratorContext(root common.Hash, marker []byte, db ethdb.KeyValueStore // to time to avoid blocking leveldb compaction for a long time. func (ctx *generatorContext) openIterator(kind string, start []byte) { if kind == snapAccount { - iter := ctx.db.NewIterator(rawdb.SnapshotAccountPrefix, start) - ctx.account = newHoldableIterator(rawdb.NewKeyLengthIterator(iter, 1+common.HashLength)) + iter := ctx.db.NewIterator(ctx.codec.AccountPrefix(), start) + ctx.account = newHoldableIterator(rawdb.NewKeyLengthIterator(iter, ctx.codec.AccountKeyLength())) return } - iter := ctx.db.NewIterator(rawdb.SnapshotStoragePrefix, start) - ctx.storage = newHoldableIterator(rawdb.NewKeyLengthIterator(iter, 1+2*common.HashLength)) + iter := ctx.db.NewIterator(ctx.codec.StoragePrefix(), start) + ctx.storage = newHoldableIterator(rawdb.NewKeyLengthIterator(iter, ctx.codec.StorageKeyLength())) } // reopenIterator releases the specified snapshot iterator and re-open it diff --git a/triedb/pathdb/database.go b/triedb/pathdb/database.go index a61d302b1d..ef66420be6 100644 --- a/triedb/pathdb/database.go +++ b/triedb/pathdb/database.go @@ -125,10 +125,11 @@ type Database struct { // readOnly is the flag whether the mutation is allowed to be applied. // It will be set automatically when the database is journaled during // the shutdown to reject all following unexpected mutations. - readOnly bool // Flag if database is opened in read only mode - waitSync bool // Flag if database is deactivated due to initial state sync - isVerkle bool // Flag if database is used for verkle tree - hasher nodeHasher // Trie node hasher + readOnly bool // Flag if database is opened in read only mode + waitSync bool // Flag if database is deactivated due to initial state sync + isVerkle bool // Flag if database is used for verkle tree + hasher nodeHasher // Trie node hasher + flatCodec flatStateCodec // Flat-state key derivation, persistence and iteration config *Config // Configuration for database diskdb ethdb.Database // Persistent storage for matured trie nodes @@ -153,11 +154,12 @@ func New(diskdb ethdb.Database, config *Config, isVerkle bool) *Database { config = config.sanitize() db := &Database{ - readOnly: config.ReadOnly, - isVerkle: isVerkle, - config: config, - diskdb: diskdb, - hasher: merkleNodeHasher, + readOnly: config.ReadOnly, + isVerkle: isVerkle, + config: config, + diskdb: diskdb, + hasher: merkleNodeHasher, + flatCodec: &merkleFlatCodec{}, } // Establish a dedicated database namespace tailored for verkle-specific // data, ensuring the isolation of both verkle and merkle tree data. It's @@ -167,6 +169,12 @@ func New(diskdb ethdb.Database, config *Config, isVerkle bool) *Database { if isVerkle { db.diskdb = rawdb.NewTable(diskdb, string(rawdb.VerklePrefix)) db.hasher = binaryNodeHasher + // Wire the bintrie flat-state codec so the disklayer/buffer/generator + // all use the per-stem on-disk layout. The codec needs a reader for + // the read-modify-write performed by applyWrites; the namespaced + // db.diskdb is the right backing store because all bintrie keys + // (trie nodes AND stem blobs) live under the verkle prefix. + db.flatCodec = newBintrieFlatCodec(db.diskdb) } // Construct the layer tree by resolving the in-disk singleton state // and in-memory layer journal. @@ -232,7 +240,7 @@ func (db *Database) setHistoryIndexer() { func (db *Database) setStateGenerator() error { // Load the state snapshot generation progress marker to prevent access // to uncovered states. - generator, root, err := loadGenerator(db.diskdb, db.hasher) + generator, root, err := loadGenerator(db.diskdb, db.hasher, db.isVerkle) if err != nil { return err } @@ -264,13 +272,18 @@ func (db *Database) setStateGenerator() error { // Disable the background snapshot building in these circumstances: // - the database is opened in read only mode // - the snapshot build is explicitly disabled - // - the database is opened in verkle tree mode - noBuild := db.readOnly || db.config.SnapshotNoBuild || db.isVerkle + // + // Note: bintrie/verkle mode is no longer excluded here. The bintrie + // codec ships its own snapshot generator (see generate_bintrie.go) so + // the unified flat-state path can populate stem blobs from an existing + // trie. Generator dispatch in newGenerator/generator.run picks the + // right routine based on the active flatStateCodec. + noBuild := db.readOnly || db.config.SnapshotNoBuild // Construct the generator and link it to the disk layer, ensuring that the // generation progress is resolved to prevent accessing uncovered states // regardless of whether background state snapshot generation is allowed. - dl.setGenerator(newGenerator(db.diskdb, noBuild, generator.Marker, stats)) + dl.setGenerator(newGenerator(db.diskdb, db.flatCodec, noBuild, generator.Marker, stats)) // Short circuit if the background generation is not permitted if noBuild || db.waitSync { @@ -408,7 +421,9 @@ func (db *Database) Enable(root common.Hash) error { // Re-construct a new disk layer backed by persistent state // and schedule the state snapshot generation if it's permitted. - db.tree.init(generateSnapshot(db, root, db.isVerkle || db.config.SnapshotNoBuild)) + // Bintrie/verkle is no longer treated as "noBuild" — the bintrie + // generator (Commit 9) handles regeneration from the unified trie. + db.tree.init(generateSnapshot(db, root, db.config.SnapshotNoBuild)) // After snap sync, the state of the database may have changed completely. // To ensure the history indexer always matches the current state, we must: diff --git a/triedb/pathdb/disklayer.go b/triedb/pathdb/disklayer.go index 50c7279d0e..b5554e40c5 100644 --- a/triedb/pathdb/disklayer.go +++ b/triedb/pathdb/disklayer.go @@ -17,7 +17,7 @@ package pathdb import ( - "bytes" + "errors" "fmt" "sync" "time" @@ -25,7 +25,6 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" ) @@ -141,7 +140,13 @@ func (dl *diskLayer) node(owner common.Hash, path []byte, depth int) ([]byte, co if blob := dl.nodes.Get(nil, key); len(blob) > 0 { cleanNodeHitMeter.Mark(1) cleanNodeReadMeter.Mark(int64(len(blob))) - return blob, crypto.Keccak256Hash(blob), nodeLoc{loc: locCleanCache, depth: depth}, nil + // Use the scheme-appropriate hasher (keccak256 for merkle, + // sha256-via-bintrie for binary trie). + h, err := dl.db.hasher(blob) + if err != nil { + return nil, common.Hash{}, nodeLoc{}, fmt.Errorf("hash cached trie node: %w", err) + } + return blob, h, nodeLoc{loc: locCleanCache, depth: depth}, nil } cleanNodeMissMeter.Mark(1) } @@ -161,7 +166,11 @@ func (dl *diskLayer) node(owner common.Hash, path []byte, depth int) ([]byte, co dl.nodes.Set(key, blob) cleanNodeWriteMeter.Mark(int64(len(blob))) } - return blob, crypto.Keccak256Hash(blob), nodeLoc{loc: locDiskLayer, depth: depth}, nil + h, err := dl.db.hasher(blob) + if err != nil { + return nil, common.Hash{}, nodeLoc{}, fmt.Errorf("hash disk trie node: %w", err) + } + return blob, h, nodeLoc{loc: locDiskLayer, depth: depth}, nil } // account directly retrieves the account RLP associated with a particular @@ -199,13 +208,15 @@ func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { // If the layer is being generated, ensure the requested account has // already been covered by the generator. + codec := dl.db.flatCodec marker := dl.genMarker() - if marker != nil && bytes.Compare(hash.Bytes(), marker) > 0 { + if marker != nil && codec.MarkerCompare(hash.Bytes(), marker) > 0 { return nil, errNotCoveredYet } // Try to retrieve the account from the memory cache + cacheKey := codec.AccountCacheKey(hash) if dl.states != nil { - if blob, found := dl.states.HasGet(nil, hash[:]); found { + if blob, found := dl.states.HasGet(nil, cacheKey); found { cleanStateHitMeter.Mark(1) cleanStateReadMeter.Mark(int64(len(blob))) @@ -219,7 +230,7 @@ func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { cleanStateMissMeter.Mark(1) } // Try to retrieve the account from the disk. - blob := rawdb.ReadAccountSnapshot(dl.db.diskdb, hash) + blob := codec.ReadAccount(dl.db.diskdb, hash) // Store the resolved data in the clean cache. The background buffer flusher // may also write to the clean cache concurrently, but two writers cannot @@ -227,7 +238,7 @@ func (dl *diskLayer) account(hash common.Hash, depth int) ([]byte, error) { // it will be found in the frozen buffer, eliminating the need to check the // database. if dl.states != nil { - dl.states.Set(hash[:], blob) + dl.states.Set(cacheKey, blob) cleanStateWriteMeter.Mark(int64(len(blob))) } if len(blob) == 0 { @@ -276,14 +287,27 @@ func (dl *diskLayer) storage(accountHash, storageHash common.Hash, depth int) ([ // If the layer is being generated, ensure the requested storage slot // has already been covered by the generator. - key := storageKeySlice(accountHash, storageHash) + // + // The codec derives the scheme-appropriate marker comparison key: + // merkle uses the 64-byte (accountHash||storageHash) concatenation; + // bintrie uses the 32-byte storageHash directly (which is the full + // stem||offset key matching the bintrie generator's 32-byte marker). + // Pre-A4 this always used the 64-byte shape, which was fail-open + // for bintrie because the zero accountHash sorts before any + // sha256-derived marker byte. + codec := dl.db.flatCodec + markerKey := codec.StorageMarkerKey(accountHash, storageHash) marker := dl.genMarker() - if marker != nil && bytes.Compare(key, marker) > 0 { + if marker != nil && codec.MarkerCompare(markerKey, marker) > 0 { return nil, errNotCoveredYet } - // Try to retrieve the storage slot from the memory cache + // Try to retrieve the storage slot from the memory cache. The codec + // decides the cache key shape so it can avoid colliding with account + // keys (relevant once the bintrie codec lands; for merkle this remains + // the historical 64-byte combined key). + cacheKey := codec.StorageCacheKey(accountHash, storageHash) if dl.states != nil { - if blob, found := dl.states.HasGet(nil, key); found { + if blob, found := dl.states.HasGet(nil, cacheKey); found { cleanStateHitMeter.Mark(1) cleanStateReadMeter.Mark(int64(len(blob))) @@ -296,8 +320,8 @@ func (dl *diskLayer) storage(accountHash, storageHash common.Hash, depth int) ([ } cleanStateMissMeter.Mark(1) } - // Try to retrieve the account from the disk - blob := rawdb.ReadStorageSnapshot(dl.db.diskdb, accountHash, storageHash) + // Try to retrieve the storage slot from the disk + blob := codec.ReadStorage(dl.db.diskdb, accountHash, storageHash) // Store the resolved data in the clean cache. The background buffer flusher // may also write to the clean cache concurrently, but two writers cannot @@ -305,7 +329,7 @@ func (dl *diskLayer) storage(accountHash, storageHash common.Hash, depth int) ([ // it will be found in the frozen buffer, eliminating the need to check the // database. if dl.states != nil { - dl.states.Set(key, blob) + dl.states.Set(cacheKey, blob) cleanStateWriteMeter.Mark(int64(len(blob))) } if len(blob) == 0 { @@ -491,7 +515,7 @@ func (dl *diskLayer) commit(bottom *diffLayer, force bool) (*diskLayer, error) { // Freeze the live buffer and schedule background flushing dl.frozen = combined - dl.frozen.flush(bottom.root, dl.db.diskdb, []ethdb.AncientWriter{dl.db.stateFreezer, dl.db.trienodeFreezer}, progress, dl.nodes, dl.states, bottom.stateID(), func() { + dl.frozen.flush(bottom.root, dl.db.diskdb, dl.db.flatCodec, []ethdb.AncientWriter{dl.db.stateFreezer, dl.db.trienodeFreezer}, progress, dl.nodes, dl.states, bottom.stateID(), func() { // Resume the background generation if it's not completed yet. // The generator is assumed to be available if the progress is // not nil. @@ -530,6 +554,14 @@ func (dl *diskLayer) revert(h *stateHistory) (*diskLayer, error) { if dl.id == 0 { return nil, fmt.Errorf("%w: zero state id", errStateUnrecoverable) } + // Bintrie flat state does not yet support revert. State history for + // bintrie carries keccak-keyed account/storage entries (the merkle + // shape), but the bintrie disk layout is per-stem and the merkle + // origin maps cannot be replayed onto it. Reorgs would silently + // produce wrong answers — fail loudly here so misuse is obvious. + if _, isBintrie := dl.db.flatCodec.(*bintrieFlatCodec); isBintrie { + return nil, errors.New("bintrie flat state revert is not supported") + } // Apply the reverse state changes upon the current state. This must // be done before holding the lock in order to access state in "this" // layer. @@ -599,7 +631,9 @@ func (dl *diskLayer) revert(h *stateHistory) (*diskLayer, error) { writeNodes(batch, nodes, dl.nodes) // Provide the original values of modified accounts and storages for revert - writeStates(batch, progress, accounts, storages, dl.states) + if _, _, err := writeStates(batch, dl.db.flatCodec, progress, accounts, storages, dl.states); err != nil { + return nil, err + } rawdb.WritePersistentStateID(batch, dl.id-1) rawdb.WriteSnapshotRoot(batch, h.meta.parent) if err := batch.Write(); err != nil { diff --git a/triedb/pathdb/flat_codec.go b/triedb/pathdb/flat_codec.go new file mode 100644 index 0000000000..9643933061 --- /dev/null +++ b/triedb/pathdb/flat_codec.go @@ -0,0 +1,316 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + + "github.com/VictoriaMetrics/fastcache" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb" +) + +// flatStateCodec abstracts the trie-specific aspects of flat-state storage: +// key derivation from (address, slot), persistence of account/storage entries +// to disk, clean-cache key disambiguation, and iterator construction. +// +// It mirrors the existing nodeHasher pattern (a hot, small interface plugged +// into the Database struct), and complements the Hasher interface from +// state-hasher-iface-2 which abstracts trie-side hashing/commit. +// +// Two implementations are provided: +// - merkleFlatCodec: keccak-keyed flat state, the historical MPT scheme. +// - bintrieFlatCodec: per-stem flat state for the unified binary trie. +// Wired into pathdb.Database.New when isVerkle is true. +// +// All methods MUST be safe for concurrent use; the codec is shared across +// goroutines (the disk layer's read path, the buffer flush path, and the +// background generator may all call into it simultaneously). +type flatStateCodec interface { + // AccountKey derives the flat-state lookup key for an account. + // + // For Merkle: returns keccak256(addr). + // For Bintrie: returns the full 32-byte tree key (stem || offset) for + // the BasicData leaf. Since BasicDataLeafKey is 0, the last byte is + // zero, but the result is a full key — callers use stemFromKey / + // offsetFromKey to decompose it. + AccountKey(addr common.Address) common.Hash + + // StorageKey derives the flat-state lookup keys for a storage slot. + // + // The first return value carries the account-side hash (e.g. + // keccak256(addr) for Merkle, or zero for bintrie which has no per-account + // grouping). The second return value carries the slot-side hash + // (keccak256(slot) for Merkle, or the full bintrie key for bintrie). + // + // Read/Write methods receive the same pair, so the codec implementation + // is the only place that has to interpret them. + StorageKey(addr common.Address, slot common.Hash) (accountKey common.Hash, storageKey common.Hash) + + // ReadAccount loads an account flat-state entry from persistent storage. + // Returns nil if the entry is not present. + ReadAccount(db ethdb.KeyValueReader, key common.Hash) []byte + + // ReadStorage loads a storage flat-state entry from persistent storage. + // Returns nil if the entry is not present. + ReadStorage(db ethdb.KeyValueReader, accountKey common.Hash, storageKey common.Hash) []byte + + // WriteAccount persists an account flat-state entry into the supplied batch. + WriteAccount(batch ethdb.Batch, key common.Hash, blob []byte) + + // DeleteAccount removes an account flat-state entry via the supplied batch. + DeleteAccount(batch ethdb.Batch, key common.Hash) + + // WriteStorage persists a storage flat-state entry into the supplied batch. + WriteStorage(batch ethdb.Batch, accountKey common.Hash, storageKey common.Hash, blob []byte) + + // DeleteStorage removes a storage flat-state entry via the supplied batch. + DeleteStorage(batch ethdb.Batch, accountKey common.Hash, storageKey common.Hash) + + // AccountCacheKey returns the byte key used in the disk-layer clean state + // cache (fastcache) for an account entry. The cache is shared between + // account and storage lookups, so codecs must ensure their key spaces are + // disjoint to avoid collisions. + AccountCacheKey(key common.Hash) []byte + + // StorageCacheKey returns the byte key used in the disk-layer clean state + // cache (fastcache) for a storage entry. See AccountCacheKey for the + // disjointness requirement. + StorageCacheKey(accountKey common.Hash, storageKey common.Hash) []byte + + // AccountPrefix returns the rawdb key prefix used by account entries on + // disk. Used by the generator to set up its account-range iterator. + AccountPrefix() []byte + + // StoragePrefix returns the rawdb key prefix used by storage entries on + // disk. Used by the generator to set up its storage-range iterator. + StoragePrefix() []byte + + // AccountKeyLength returns the expected total length (prefix + payload) + // of an on-disk account key. The generator uses this to filter spurious + // matches when iterating with a length-bounded iterator. + AccountKeyLength() int + + // StorageKeyLength returns the expected total length (prefix + payload) + // of an on-disk storage key. See AccountKeyLength. + StorageKeyLength() int + + // AccountPrefixSize returns the per-entry on-disk overhead used by the + // stateSet to estimate flush sizes. This is just the prefix length for + // merkle codecs; bintrie codecs may use a different convention. + AccountPrefixSize() int + + // StoragePrefixSize returns the per-entry on-disk overhead for storage + // entries. + StoragePrefixSize() int + + // SplitMarker decomposes a generation progress marker into the account + // portion and the full marker. For Merkle the account part is the first + // 32 bytes; for bintrie both halves are the same single 32-byte stem. + SplitMarker(marker []byte) (accountMarker []byte, fullMarker []byte) + + // MarkerCompare compares a flat-state key against a generation progress + // marker. Returns the same semantics as bytes.Compare. Used by the + // disklayer.account/storage gating logic and by writeStates. + MarkerCompare(key []byte, marker []byte) int + + // StorageMarkerKey returns the byte representation used to compare a + // (accountHash, storageHash) pair against the generator progress + // marker in disklayer.storage's generation-progress gate. Merkle + // uses the 64-byte concatenation (two-tier keying); bintrie uses + // the 32-byte storageHash directly (single-tier, stem||offset key + // space matching the bintrie generator's 32-byte marker). + StorageMarkerKey(accountHash, storageHash common.Hash) []byte + + // Flush drains all pending mutations from the in-memory accountData and + // storageData maps into the supplied batch and updates the clean cache + // in lockstep. The codec controls iteration order, key derivation, and + // any aggregation that may be required (e.g. the bintrie codec must + // merge per-offset writes into per-stem read-modify-writes to avoid + // quadratic disk reads). + // + // Entries strictly past genMarker (per the codec's MarkerCompare + // semantics) are skipped because they will be regenerated by the + // background snapshot generator. + // + // Returns (account-entry count, storage-entry count) for metric + // reporting; the merkle codec reports one per map entry, while the + // bintrie codec reports one per logical offset write (so the metrics + // remain comparable across schemes). + Flush(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int, error) +} + +// merkleFlatCodec implements flatStateCodec for the keccak-keyed MPT flat +// state scheme. All methods are thin wrappers over rawdb accessors and +// existing helpers; this codec preserves the historical behavior bit-for-bit. +type merkleFlatCodec struct{} + +// Compile-time interface check. +var _ flatStateCodec = (*merkleFlatCodec)(nil) + +func (c *merkleFlatCodec) AccountKey(addr common.Address) common.Hash { + return crypto.Keccak256Hash(addr.Bytes()) +} + +func (c *merkleFlatCodec) StorageKey(addr common.Address, slot common.Hash) (common.Hash, common.Hash) { + return crypto.Keccak256Hash(addr.Bytes()), crypto.Keccak256Hash(slot.Bytes()) +} + +func (c *merkleFlatCodec) ReadAccount(db ethdb.KeyValueReader, key common.Hash) []byte { + return rawdb.ReadAccountSnapshot(db, key) +} + +func (c *merkleFlatCodec) ReadStorage(db ethdb.KeyValueReader, accountKey, storageKey common.Hash) []byte { + return rawdb.ReadStorageSnapshot(db, accountKey, storageKey) +} + +func (c *merkleFlatCodec) WriteAccount(batch ethdb.Batch, key common.Hash, blob []byte) { + rawdb.WriteAccountSnapshot(batch, key, blob) +} + +func (c *merkleFlatCodec) DeleteAccount(batch ethdb.Batch, key common.Hash) { + rawdb.DeleteAccountSnapshot(batch, key) +} + +func (c *merkleFlatCodec) WriteStorage(batch ethdb.Batch, accountKey, storageKey common.Hash, blob []byte) { + rawdb.WriteStorageSnapshot(batch, accountKey, storageKey, blob) +} + +func (c *merkleFlatCodec) DeleteStorage(batch ethdb.Batch, accountKey, storageKey common.Hash) { + rawdb.DeleteStorageSnapshot(batch, accountKey, storageKey) +} + +func (c *merkleFlatCodec) AccountCacheKey(key common.Hash) []byte { + // The historical merkle clean cache uses the bare 32-byte account hash. + // This is a slice into the caller's hash; callers must not retain it. + return key[:] +} + +func (c *merkleFlatCodec) StorageCacheKey(accountKey, storageKey common.Hash) []byte { + return storageKeySlice(accountKey, storageKey) +} + +func (c *merkleFlatCodec) AccountPrefix() []byte { + return rawdb.SnapshotAccountPrefix +} + +func (c *merkleFlatCodec) StoragePrefix() []byte { + return rawdb.SnapshotStoragePrefix +} + +func (c *merkleFlatCodec) AccountKeyLength() int { + return len(rawdb.SnapshotAccountPrefix) + common.HashLength +} + +func (c *merkleFlatCodec) StorageKeyLength() int { + return len(rawdb.SnapshotStoragePrefix) + 2*common.HashLength +} + +func (c *merkleFlatCodec) AccountPrefixSize() int { + return len(rawdb.SnapshotAccountPrefix) +} + +func (c *merkleFlatCodec) StoragePrefixSize() int { + return len(rawdb.SnapshotStoragePrefix) +} + +func (c *merkleFlatCodec) SplitMarker(marker []byte) ([]byte, []byte) { + var accMarker []byte + if len(marker) > 0 { + accMarker = marker[:common.HashLength] + } + return accMarker, marker +} + +func (c *merkleFlatCodec) MarkerCompare(key []byte, marker []byte) int { + return bytes.Compare(key, marker) +} + +func (c *merkleFlatCodec) StorageMarkerKey(accountHash, storageHash common.Hash) []byte { + return storageKeySlice(accountHash, storageHash) +} + +// Flush drains the supplied account/storage maps into the batch using the +// historical merkle per-entry layout: one rawdb write per accountData entry +// and one per storage slot. Entries past the genMarker are skipped (the +// generator will fill them in). The clean cache is kept in sync with each +// write so subsequent reads do not stale. +// +// This is the implementation that previously lived directly in writeStates. +// It has been moved into the codec so the bintrie codec can supply its own +// per-stem aggregating implementation alongside this one. +func (c *merkleFlatCodec) Flush(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int, error) { + var ( + accounts int + slots int + ) + for addrHash, blob := range accountData { + // Skip any account not yet covered by the snapshot. The account + // at the generation marker position (addrHash == genMarker[:common.HashLength]) + // should still be updated, as it would be skipped in the next + // generation cycle. + if genMarker != nil && bytes.Compare(addrHash[:], genMarker) > 0 { + continue + } + accounts++ + cacheKey := c.AccountCacheKey(addrHash) + if len(blob) == 0 { + c.DeleteAccount(batch, addrHash) + if clean != nil { + clean.Set(cacheKey, []byte{}) + } + } else { + c.WriteAccount(batch, addrHash, blob) + if clean != nil { + clean.Set(cacheKey, blob) + } + } + } + for addrHash, storages := range storageData { + // Skip any account not covered yet by the snapshot + if genMarker != nil && bytes.Compare(addrHash[:], genMarker) > 0 { + continue + } + midAccount := genMarker != nil && bytes.Equal(addrHash[:], genMarker[:common.HashLength]) + + for storageHash, blob := range storages { + // Skip any storage slot not yet covered by the snapshot. The storage slot + // at the generation marker position (addrHash == genMarker[:common.HashLength] + // and storageHash == genMarker[common.HashLength:]) should still be updated, + // as it would be skipped in the next generation cycle. + if midAccount && bytes.Compare(storageHash[:], genMarker[common.HashLength:]) > 0 { + continue + } + slots++ + cacheKey := c.StorageCacheKey(addrHash, storageHash) + if len(blob) == 0 { + c.DeleteStorage(batch, addrHash, storageHash) + if clean != nil { + clean.Set(cacheKey, []byte{}) + } + } else { + c.WriteStorage(batch, addrHash, storageHash, blob) + if clean != nil { + clean.Set(cacheKey, blob) + } + } + } + } + return accounts, slots, nil +} diff --git a/triedb/pathdb/flat_codec_bintrie.go b/triedb/pathdb/flat_codec_bintrie.go new file mode 100644 index 0000000000..f0c6def7d8 --- /dev/null +++ b/triedb/pathdb/flat_codec_bintrie.go @@ -0,0 +1,515 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "fmt" + + "github.com/VictoriaMetrics/fastcache" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/trie/bintrie" +) + +// bintrieFlatCodec implements flatStateCodec for the binary trie using the +// stem-blob on-disk layout defined in stem_blob.go. Keys are the 32-byte +// stems of the EIP-7864 binary state tree (the first 31 bytes of the full +// bintrie key, zero-padded into a common.Hash) and values are packed stem +// blobs containing the subset of 256 offsets that have been written at +// that stem. +// +// Unlike merkleFlatCodec (which is a stateless singleton), this codec +// holds a reference to the underlying key-value store so its Write/Delete +// methods can perform a read-modify-write on the existing stem blob +// before merging in the new (offset, value) pair. ethdb.Batch is +// write-only, so the batch passed to Write* cannot be used to fetch the +// current state of a stem. +// +// Pre-aggregation requirement: within a single flush pass, the caller +// must NOT issue two Write* calls targeting the same stem. The codec +// reads the stem from the store (not from the in-flight batch), so a +// second write at the same stem would re-read the pre-flush state and +// clobber the first write. The codec's public surface area is designed +// around this assumption; the Flush method pre-aggregates per-stem +// writes so callers do not have to handle this manually. +// +// This codec is wired into pathdb.Database.New when isVerkle is true +// (see database.go). The leaf-production hook in binaryHasher emits +// per-offset writes via DrainStemWrites, which encodeBinary routes +// into the per-offset accountData map consumed by Flush. +type bintrieFlatCodec struct { + // db is the underlying key-value store used by applyWrites to read + // the current stem blob before merging in new (offset, value) pairs. + // It is always the pathdb Database's already-wrapped diskdb (the + // VerklePrefix-namespaced table) so reads and writes share the same + // on-disk key space. + db ethdb.KeyValueReader +} + +// newBintrieFlatCodec constructs a bintrieFlatCodec bound to the given +// key-value reader. The reader is used for read-modify-write on stem +// blobs; writes still flow through the ethdb.Batch passed to each +// Write*/Delete* call. +func newBintrieFlatCodec(db ethdb.KeyValueReader) *bintrieFlatCodec { + return &bintrieFlatCodec{db: db} +} + +// Compile-time interface assertion. +var _ flatStateCodec = (*bintrieFlatCodec)(nil) + +// bintrieCacheKeyPrefix is a one-byte prefix applied to all bintrie cache +// keys to keep them disjoint from merkle account keys (which are raw +// 32-byte hashes) and merkle storage keys (which are 64-byte +// accountHash||storageHash) in the shared clean-state fastcache. Without a +// prefix, a 32-byte merkle account hash and a 32-byte bintrie stem could +// collide on the same cache slot and return wrong data on read. +const bintrieCacheKeyPrefix byte = 0x01 + +// stemFromKey extracts the 31-byte stem from a 32-byte flat-state key. +// Bintrie keys follow the "stem || offset" layout (EIP-7864), so the stem +// is always bytes [0..30] and the byte at index 31 is the offset within +// the stem. Callers that use AccountKey()/StorageKey() followed by +// Read/Write never need to look at the offset themselves — the codec +// handles offset extraction internally. +func stemFromKey(key common.Hash) []byte { + return key[:bintrie.StemSize] +} + +// offsetFromKey returns the offset byte of a 32-byte flat-state key. +func offsetFromKey(key common.Hash) byte { + return key[bintrie.StemSize] +} + +// --------------------------------------------------------------------- +// Key derivation +// --------------------------------------------------------------------- + +// AccountKey returns the bintrie BasicData key for the given address. +// The result has the account's 31-byte stem in bytes [0..30] and offset 0 +// (BasicDataLeafKey) in byte 31. The CodeHash leaf lives at the same stem +// with offset 1, so a single ReadAccount is enough to materialize both +// offsets via the returned stem blob. +func (c *bintrieFlatCodec) AccountKey(addr common.Address) common.Hash { + return common.BytesToHash(bintrie.GetBinaryTreeKeyBasicData(addr)) +} + +// StorageKey returns the bintrie key for a storage slot. The first return +// value (the "account key" in the merkle naming convention) is the zero +// hash because bintrie has no per-account grouping at the flat-state +// level; the second return value is the full 32-byte slot key (stem || +// offset). Callers must pass both values back through the Read/Write +// storage methods so the codec can recover the stem and offset. +func (c *bintrieFlatCodec) StorageKey(addr common.Address, slot common.Hash) (common.Hash, common.Hash) { + full := bintrie.GetBinaryTreeKeyStorageSlot(addr, slot[:]) + return common.Hash{}, common.BytesToHash(full) +} + +// --------------------------------------------------------------------- +// Disk reads +// --------------------------------------------------------------------- + +// ReadAccount returns the 32-byte value stored at the offset indicated +// by the input key (the final byte of `key` is the bintrie offset). +// Returns nil if the offset is not populated in the on-disk stem blob. +// +// The per-offset return shape matches ReadStorage and, crucially, +// matches the buffer-path return shape: the pathdb diff-layer buffer +// stores per-offset entries (keyed by the full 32-byte stem||offset +// key) holding 32-byte leaf values. When `disklayer.account()` falls +// through from the buffer to the codec's disk read, both sides must +// agree on the per-offset representation — otherwise a length check in +// the consumer (bintrieFlatReader.Account) fails on every +// post-buffer-flush read. Prior to this commit the disk path returned +// the whole stem blob while the buffer path returned a 32-byte value, +// which caused every real-world read to error once the buffer spilled +// to disk. +// +// A malformed stem blob is treated as "entry absent" (returning nil) +// to match the behavior of rawdb.ReadStorageSnapshot on the merkle +// path — the interface has no error channel, and propagating nil lets +// the multi-reader fall through to the trie reader as a gatekeeper. +func (c *bintrieFlatCodec) ReadAccount(db ethdb.KeyValueReader, key common.Hash) []byte { + blob := rawdb.ReadBinTrieStem(db, stemFromKey(key)) + if len(blob) == 0 { + return nil + } + val, err := extractStemOffset(blob, offsetFromKey(key)) + if err != nil { + log.Error("Corrupt bintrie stem blob in ReadAccount", "key", key, "err", err) + return nil + } + return val +} + +// ReadStorage returns the 32-byte value stored at the storage slot's +// offset within its stem, or nil if the offset is not populated. +// Like ReadAccount, it extracts a single offset from the on-disk stem +// blob. A malformed stem blob is treated as absent and logged. +// +// The first parameter (accountKey) is ignored: see StorageKey for the +// reasoning behind the bintrie's zero-hash convention. +func (c *bintrieFlatCodec) ReadStorage(db ethdb.KeyValueReader, _ common.Hash, storageKey common.Hash) []byte { + blob := rawdb.ReadBinTrieStem(db, stemFromKey(storageKey)) + if len(blob) == 0 { + return nil + } + val, err := extractStemOffset(blob, offsetFromKey(storageKey)) + if err != nil { + log.Error("Corrupt bintrie stem blob in ReadStorage", "key", storageKey, "err", err) + return nil + } + return val +} + +// --------------------------------------------------------------------- +// Disk writes +// --------------------------------------------------------------------- + +// WriteAccount writes an account entry. The blob is expected to be a +// two-slot payload containing BasicData (bytes 0..31) followed by the +// code hash (bytes 32..63) — the caller (binaryHasher) packs these +// together because they live at the same stem and benefit from a +// single read-modify-write pass. +// +// Writing nil or an empty blob is equivalent to clearing offsets 0 and 1 +// at this stem (a partial account deletion); the codec merges the +// resulting bitmap into the existing stem blob and deletes the key +// entirely if no offsets remain set. +// +// An error from mergeStemBlob (e.g. malformed existing blob) is logged +// via log.Crit because flat-state corruption is unrecoverable at this +// layer — same policy as rawdb.WriteAccountSnapshot. +func (c *bintrieFlatCodec) WriteAccount(batch ethdb.Batch, key common.Hash, blob []byte) { + writes, err := splitAccountBlob(blob) + if err != nil { + log.Crit("bintrie WriteAccount: split failed", "key", key, "err", err) + } + if _, err := c.applyWrites(batch, stemFromKey(key), writes); err != nil { + log.Crit("bintrie WriteAccount: apply failed", "key", key, "err", err) + } +} + +// DeleteAccount clears offsets 0 (BasicData) and 1 (CodeHash) at the +// account's stem. Other offsets at the same stem (e.g. header storage +// slots) are NOT touched — callers that want a full account wipe must +// walk storage separately, which is consistent with the bintrie's +// DeleteAccount semantics (see trie/bintrie/trie.go). +func (c *bintrieFlatCodec) DeleteAccount(batch ethdb.Batch, key common.Hash) { + writes := []stemOffsetValue{ + {Offset: bintrie.BasicDataLeafKey, Value: nil}, + {Offset: bintrie.CodeHashLeafKey, Value: nil}, + } + if _, err := c.applyWrites(batch, stemFromKey(key), writes); err != nil { + log.Crit("bintrie DeleteAccount: apply failed", "key", key, "err", err) + } +} + +// WriteStorage writes a single storage-slot value. The blob must be 32 +// bytes (the canonical storage value width); a shorter/longer blob is a +// caller bug and is logged via log.Crit. +// +// The first parameter (accountKey) is ignored — see StorageKey. +func (c *bintrieFlatCodec) WriteStorage(batch ethdb.Batch, _ common.Hash, storageKey common.Hash, blob []byte) { + if len(blob) != stemBlobValueSize { + log.Crit("bintrie WriteStorage: wrong value length", "key", storageKey, "len", len(blob), "want", stemBlobValueSize) + } + writes := []stemOffsetValue{{Offset: offsetFromKey(storageKey), Value: blob}} + if _, err := c.applyWrites(batch, stemFromKey(storageKey), writes); err != nil { + log.Crit("bintrie WriteStorage: apply failed", "key", storageKey, "err", err) + } +} + +// DeleteStorage clears a single offset at a stem. If the stem has no +// other populated offsets afterwards, the key is removed entirely. +func (c *bintrieFlatCodec) DeleteStorage(batch ethdb.Batch, _ common.Hash, storageKey common.Hash) { + writes := []stemOffsetValue{{Offset: offsetFromKey(storageKey), Value: nil}} + if _, err := c.applyWrites(batch, stemFromKey(storageKey), writes); err != nil { + log.Crit("bintrie DeleteStorage: apply failed", "key", storageKey, "err", err) + } +} + +// applyWrites performs a read-modify-write on the given stem: reads the +// existing blob via the codec's bound reader, merges in the supplied +// (offset, value) pairs, and writes the result back via the batch — or +// deletes the key if the merged result is empty. Shared by all four +// Write/Delete methods to ensure the policy (nil value clears, empty +// blob deletes) is consistent. +// +// Returns the merged blob (or nil if the stem was deleted) so callers +// such as Flush can repopulate the clean cache without an extra disk +// read. The returned slice is freshly allocated and owned by the caller. +// +// Important: the read comes from c.db, NOT from the batch. A second +// call for the same stem within a flush would re-read the pre-flush +// state; see the pre-aggregation requirement documented on +// bintrieFlatCodec. +func (c *bintrieFlatCodec) applyWrites(batch ethdb.Batch, stem []byte, writes []stemOffsetValue) ([]byte, error) { + existing := rawdb.ReadBinTrieStem(c.db, stem) + merged, err := mergeStemBlob(existing, writes) + if err != nil { + return nil, fmt.Errorf("bintrie stem %x: %w", stem, err) + } + if merged == nil { + rawdb.DeleteBinTrieStem(batch, stem) + return nil, nil + } + rawdb.WriteBinTrieStem(batch, stem, merged) + return merged, nil +} + +// splitAccountBlob validates and splits the two-slot account payload +// passed to WriteAccount. A nil or empty blob is interpreted as +// "clear both offsets". +func splitAccountBlob(blob []byte) ([]stemOffsetValue, error) { + if len(blob) == 0 { + return []stemOffsetValue{ + {Offset: bintrie.BasicDataLeafKey, Value: nil}, + {Offset: bintrie.CodeHashLeafKey, Value: nil}, + }, nil + } + if len(blob) != 2*stemBlobValueSize { + return nil, fmt.Errorf("account blob len %d, want %d (BasicData || CodeHash)", len(blob), 2*stemBlobValueSize) + } + return []stemOffsetValue{ + {Offset: bintrie.BasicDataLeafKey, Value: blob[:stemBlobValueSize]}, + {Offset: bintrie.CodeHashLeafKey, Value: blob[stemBlobValueSize:]}, + }, nil +} + +// --------------------------------------------------------------------- +// Clean-cache keys +// --------------------------------------------------------------------- + +// AccountCacheKey returns a disambiguated byte key for the shared +// fastcache-backed clean state cache. The prefix byte +// bintrieCacheKeyPrefix keeps bintrie lookups disjoint from merkle +// account lookups (32-byte keys) and from merkle storage lookups +// (64-byte keys). +// +// The full 32-byte (stem || offset) key is embedded after the prefix +// so each offset at a given stem gets its own cache entry. This is +// required because ReadAccount now returns the per-offset 32-byte +// leaf value rather than the whole stem blob: caching under a +// stem-only key would collapse BasicData and CodeHash (or any two +// offsets at the same stem) into a single slot and the second hit +// would return the wrong offset's value. +// +// Resulting layout: 1 byte prefix + 32 bytes full key = 33 bytes +// total. The stem is at bytes[1..31]; the offset is at byte[32]. +func (c *bintrieFlatCodec) AccountCacheKey(key common.Hash) []byte { + out := make([]byte, 1+common.HashLength) + out[0] = bintrieCacheKeyPrefix + copy(out[1:], key[:]) + return out +} + +// StorageCacheKey returns the cache key for a storage entry. The +// accountKey parameter is ignored (see StorageKey). The full storage +// key — which already encodes (stem || offset) via +// GetBinaryTreeKeyStorageSlot — is embedded directly so each slot at +// a stem has its own cache entry, matching the per-offset semantics +// of AccountCacheKey. +func (c *bintrieFlatCodec) StorageCacheKey(_ common.Hash, storageKey common.Hash) []byte { + out := make([]byte, 1+common.HashLength) + out[0] = bintrieCacheKeyPrefix + copy(out[1:], storageKey[:]) + return out +} + +// --------------------------------------------------------------------- +// Generator iterator configuration +// --------------------------------------------------------------------- + +// AccountPrefix returns the rawdb key prefix used for bintrie flat-state +// entries. The generator iterator uses this prefix to walk all stem +// blobs for the initial population of the flat state from an existing +// bintrie. +func (c *bintrieFlatCodec) AccountPrefix() []byte { + return rawdb.BinTrieStemPrefix +} + +// StoragePrefix returns the same prefix as AccountPrefix because bintrie +// flat-state entries are stored in a single namespace (stems contain +// both account and storage data). The bintrie generator +// (generate_bintrie.go) uses a single iterator over this prefix +// rather than the two-tier account-then-storage walk used by the +// merkle generator. +func (c *bintrieFlatCodec) StoragePrefix() []byte { + return rawdb.BinTrieStemPrefix +} + +// AccountKeyLength returns the expected on-disk key length for a stem +// entry: 1 byte of prefix + 31 bytes of stem = 32 bytes total. +func (c *bintrieFlatCodec) AccountKeyLength() int { + return len(rawdb.BinTrieStemPrefix) + bintrie.StemSize +} + +// StorageKeyLength returns the same length as AccountKeyLength because +// bintrie stems are a single unified namespace. +func (c *bintrieFlatCodec) StorageKeyLength() int { + return len(rawdb.BinTrieStemPrefix) + bintrie.StemSize +} + +// AccountPrefixSize returns the per-entry on-disk overhead used by the +// stateSet to estimate flush sizes. For bintrie this is just the single +// byte of BinTrieStemPrefix. +func (c *bintrieFlatCodec) AccountPrefixSize() int { + return len(rawdb.BinTrieStemPrefix) +} + +// StoragePrefixSize returns the same as AccountPrefixSize. +func (c *bintrieFlatCodec) StoragePrefixSize() int { + return len(rawdb.BinTrieStemPrefix) +} + +// --------------------------------------------------------------------- +// Generation progress marker +// --------------------------------------------------------------------- + +// SplitMarker splits a generation progress marker into the account and +// full components. For bintrie the marker is a full 32-byte key +// (stem || offset), not the merkle two-tier +// account-then-storage format, so both returned slices point at the +// same data. The second half of the merkle marker (storage offset) has +// no equivalent for bintrie: the generator iterates stems directly, +// not (account, storage) pairs. +func (c *bintrieFlatCodec) SplitMarker(marker []byte) ([]byte, []byte) { + if len(marker) == 0 { + return nil, marker + } + return marker, marker +} + +// MarkerCompare compares a flat-state key against a progress marker with +// bytes.Compare semantics, mirroring the merkle codec. The bintrie keys +// being compared are stem bytes (31 bytes) or full keys (32 bytes); both +// are lexicographically ordered so bytes.Compare is the correct +// ordering. +func (c *bintrieFlatCodec) MarkerCompare(key []byte, marker []byte) int { + return bytes.Compare(key, marker) +} + +// StorageMarkerKey returns the 32-byte storageHash directly. For bintrie, +// the storageHash IS the full (stem || offset) key because +// bintrieFlatCodec.StorageKey returns (zeroHash, fullKey). Comparing +// this directly against the 32-byte generator marker yields the correct +// ordering — unlike the merkle 64-byte combined key which was fail-open +// for bintrie. +func (c *bintrieFlatCodec) StorageMarkerKey(_ common.Hash, storageHash common.Hash) []byte { + return storageHash[:] +} + +// Flush drains the in-memory accountData and storageData maps into the +// batch using the bintrie per-stem layout. The maps are expected to hold +// per-offset entries — each key is a 32-byte (stem || offset) tuple +// produced by AccountKey/StorageKey, and each value is a 32-byte leaf +// (or nil to clear that offset). +// +// Writes are aggregated per stem and a single read-modify-write is +// issued per stem, so the codec touches each stem at most once during +// a flush and the per-call pre-aggregation requirement is satisfied +// even when many writes target the same stem. +// +// storageData is walked alongside accountData; bintrie entries should +// normally arrive on accountData but we accept either layout for +// robustness. +// +// Cache update: after the per-stem RMW, the clean cache is updated +// with each written offset's new value (per-offset entries, matching +// the shape returned by ReadAccount). Offsets +// that were not touched by this flush retain their existing cache +// entries, which remain valid because the RMW did not modify them. +// +// Returns (offset count from accountData, offset count from storageData) +// for metric reporting parity with the merkle path. +func (c *bintrieFlatCodec) Flush(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int, error) { + // Aggregate per-offset writes into per-stem batches. We use + // [31]byte as the map key because byte slices aren't hashable in + // Go and the stem is fixed size; the alternative (common.Hash with + // a zero pad) wastes a byte per entry without buying anything. + type aggregator struct { + // fullKeys preserves the original 32-byte lookup keys so the + // cache update loop below can store per-offset entries without + // reconstructing the key from (stem, offset) pairs. + fullKeys []common.Hash + writes []stemOffsetValue + } + aggregated := make(map[[bintrie.StemSize]byte]*aggregator) + + addWrite := func(fullKey common.Hash, value []byte) { + var stem [bintrie.StemSize]byte + copy(stem[:], fullKey[:bintrie.StemSize]) + offset := fullKey[bintrie.StemSize] + ag, exists := aggregated[stem] + if !exists { + ag = &aggregator{} + aggregated[stem] = ag + } + ag.fullKeys = append(ag.fullKeys, fullKey) + ag.writes = append(ag.writes, stemOffsetValue{Offset: offset, Value: value}) + } + + var ( + accountWrites int + storageWrites int + ) + for fullKey, value := range accountData { + // genMarker filtering: skip stems that the generator hasn't + // reached yet. We compare against the FULL key (stem || offset) + // because the bintrie marker is itself a 32-byte key. + if genMarker != nil && bytes.Compare(fullKey[:], genMarker) > 0 { + continue + } + accountWrites++ + addWrite(fullKey, value) + } + for _, slots := range storageData { + for fullKey, value := range slots { + if genMarker != nil && bytes.Compare(fullKey[:], genMarker) > 0 { + continue + } + storageWrites++ + addWrite(fullKey, value) + } + } + // Issue one RMW per stem, then update the clean cache per-offset + // using the fullKeys we captured in the aggregator. An empty value + // stored in the cache means "confirmed absent" (the reader will + // fall through to the trie reader); a 32-byte value means the + // offset is populated. + for _, ag := range aggregated { + if _, err := c.applyWrites(batch, ag.fullKeys[0][:bintrie.StemSize], ag.writes); err != nil { + return accountWrites, storageWrites, fmt.Errorf("bintrie Flush: %w", err) + } + if clean == nil { + continue + } + for i, fullKey := range ag.fullKeys { + cacheKey := c.AccountCacheKey(fullKey) + val := ag.writes[i].Value + if val == nil { + val = []byte{} + } + clean.Set(cacheKey, val) + } + } + return accountWrites, storageWrites, nil +} + diff --git a/triedb/pathdb/flat_codec_bintrie_test.go b/triedb/pathdb/flat_codec_bintrie_test.go new file mode 100644 index 0000000000..bf299f9b45 --- /dev/null +++ b/triedb/pathdb/flat_codec_bintrie_test.go @@ -0,0 +1,468 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/trie/bintrie" +) + +// newTestBintrieCodec constructs a bintrieFlatCodec backed by an +// in-memory key-value store. Returns both the codec and the underlying +// store so tests can drive it directly. +func newTestBintrieCodec(t *testing.T) (*bintrieFlatCodec, ethdb.Database) { + t.Helper() + db := rawdb.NewMemoryDatabase() + codec := newBintrieFlatCodec(db) + return codec, db +} + +// flushBatch commits a batch built against a memory database. Called +// after each codec write because the in-memory RMW of applyWrites reads +// from the store, not the batch. +func flushBatch(t *testing.T, batch interface{ Write() error }) { + t.Helper() + if err := batch.Write(); err != nil { + t.Fatalf("batch write: %v", err) + } +} + +// TestBintrieCodecAccountRoundTrip verifies that an account written via +// WriteAccount (a two-slot BasicData||CodeHash blob) is persisted under +// the account's stem and can be read back by calling ReadAccount with +// the appropriate per-offset key (A1 remediation: ReadAccount now +// returns a per-offset 32-byte value, matching the buffer-path shape). +func TestBintrieCodecAccountRoundTrip(t *testing.T) { + codec, db := newTestBintrieCodec(t) + addr := common.HexToAddress("0x1111111111111111111111111111111111111111") + + basicData := bytes.Repeat([]byte{0xAB}, stemBlobValueSize) + codeHash := bytes.Repeat([]byte{0xCD}, stemBlobValueSize) + blob := append(append([]byte{}, basicData...), codeHash...) + + batch := db.NewBatch() + codec.WriteAccount(batch, codec.AccountKey(addr), blob) + flushBatch(t, batch) + + // Read each offset individually. `codec.AccountKey(addr)` returns + // the BasicData key (offset 0); the CodeHash key has the same stem + // with offset 1. + basicKey := codec.AccountKey(addr) + codeKey := common.BytesToHash(bintrie.GetBinaryTreeKeyCodeHash(addr)) + + gotBasic := codec.ReadAccount(db, basicKey) + if !bytes.Equal(gotBasic, basicData) { + t.Fatalf("BasicData read: got %x, want %x", gotBasic, basicData) + } + gotCode := codec.ReadAccount(db, codeKey) + if !bytes.Equal(gotCode, codeHash) { + t.Fatalf("CodeHash read: got %x, want %x", gotCode, codeHash) + } +} + +// TestBintrieCodecStorageRoundTrip verifies that a storage slot written +// via WriteStorage is persisted at the correct stem+offset and can be +// read back via ReadStorage (which does offset extraction internally). +func TestBintrieCodecStorageRoundTrip(t *testing.T) { + codec, db := newTestBintrieCodec(t) + addr := common.HexToAddress("0x2222222222222222222222222222222222222222") + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000042") + value := bytes.Repeat([]byte{0x77}, stemBlobValueSize) + + acctKey, storageKey := codec.StorageKey(addr, slot) + batch := db.NewBatch() + codec.WriteStorage(batch, acctKey, storageKey, value) + flushBatch(t, batch) + + got := codec.ReadStorage(db, acctKey, storageKey) + if !bytes.Equal(got, value) { + t.Fatalf("ReadStorage: got %x, want %x", got, value) + } +} + +// TestBintrieCodecMultipleWritesSameStem verifies that two successive +// writes to DIFFERENT offsets at the same stem both persist — this is +// the common case when an account is updated (BasicData + CodeHash at +// stem X) and then a header storage slot at the same stem is written. +// +// Note: because the codec reads RMW from the store (not the batch), the +// caller must flush the batch between writes to the same stem for this +// to work correctly. This test exercises that pattern to ensure the +// per-call contract holds. +func TestBintrieCodecMultipleWritesSameStem(t *testing.T) { + codec, db := newTestBintrieCodec(t) + addr := common.HexToAddress("0x3333333333333333333333333333333333333333") + + // Write the account (offsets 0 and 1 at the BasicData stem). + basicData := bytes.Repeat([]byte{0xAA}, stemBlobValueSize) + codeHash := bytes.Repeat([]byte{0xBB}, stemBlobValueSize) + blob := append(append([]byte{}, basicData...), codeHash...) + batch := db.NewBatch() + codec.WriteAccount(batch, codec.AccountKey(addr), blob) + flushBatch(t, batch) + + // Now write a header storage slot. Slot 0 (per EIP-7864) lives at + // offset 64 within the SAME stem as BasicData, so this is a + // read-modify-write on the existing stem blob. + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000") + storageValue := bytes.Repeat([]byte{0xCC}, stemBlobValueSize) + acctKey, storageKey := codec.StorageKey(addr, slot) + batch = db.NewBatch() + codec.WriteStorage(batch, acctKey, storageKey, storageValue) + flushBatch(t, batch) + + // All three offsets should now be readable via per-offset reads. + basicKey := codec.AccountKey(addr) + codeKey := common.BytesToHash(bintrie.GetBinaryTreeKeyCodeHash(addr)) + + if gotBasic := codec.ReadAccount(db, basicKey); !bytes.Equal(gotBasic, basicData) { + t.Fatalf("BasicData lost after storage write: got %x, want %x", gotBasic, basicData) + } + if gotCode := codec.ReadAccount(db, codeKey); !bytes.Equal(gotCode, codeHash) { + t.Fatalf("CodeHash lost after storage write: got %x, want %x", gotCode, codeHash) + } + gotStorage := codec.ReadStorage(db, acctKey, storageKey) + if !bytes.Equal(gotStorage, storageValue) { + t.Fatalf("Storage: got %x, want %x", gotStorage, storageValue) + } +} + +// TestBintrieCodecDeleteAccount verifies that DeleteAccount clears only +// offsets 0 (BasicData) and 1 (CodeHash) at the account's stem, leaving +// any other offsets (e.g. header storage slots) at the same stem +// untouched. This mirrors BinaryTrie.DeleteAccount's intended semantics. +func TestBintrieCodecDeleteAccount(t *testing.T) { + codec, db := newTestBintrieCodec(t) + addr := common.HexToAddress("0x4444444444444444444444444444444444444444") + + // Populate account (offsets 0+1) and one header storage slot (offset 64). + basicData := bytes.Repeat([]byte{0xAA}, stemBlobValueSize) + codeHash := bytes.Repeat([]byte{0xBB}, stemBlobValueSize) + batch := db.NewBatch() + codec.WriteAccount(batch, codec.AccountKey(addr), append(basicData, codeHash...)) + flushBatch(t, batch) + + storageValue := bytes.Repeat([]byte{0xCC}, stemBlobValueSize) + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000") + acctKey, storageKey := codec.StorageKey(addr, slot) + batch = db.NewBatch() + codec.WriteStorage(batch, acctKey, storageKey, storageValue) + flushBatch(t, batch) + + // Delete the account. Offsets 0 and 1 should be cleared; the + // header storage slot at offset 64 should survive. + batch = db.NewBatch() + codec.DeleteAccount(batch, codec.AccountKey(addr)) + flushBatch(t, batch) + + basicKey := codec.AccountKey(addr) + codeKey := common.BytesToHash(bintrie.GetBinaryTreeKeyCodeHash(addr)) + + // Verify the underlying stem blob still exists (the storage slot + // at offset 64 should have prevented a full delete). + stemBlob := rawdb.ReadBinTrieStem(db, stemFromKey(basicKey)) + if len(stemBlob) == 0 { + t.Fatal("stem blob was fully deleted; header storage should still be present") + } + // BasicData and CodeHash now read back as nil (offset cleared). + if got := codec.ReadAccount(db, basicKey); got != nil { + t.Fatalf("BasicData not cleared: %x", got) + } + if got := codec.ReadAccount(db, codeKey); got != nil { + t.Fatalf("CodeHash not cleared: %x", got) + } + if got := codec.ReadStorage(db, acctKey, storageKey); !bytes.Equal(got, storageValue) { + t.Fatalf("header storage lost after DeleteAccount: got %x, want %x", got, storageValue) + } +} + +// TestBintrieCodecDeleteLastOffsetRemovesKey verifies that when the +// final populated offset at a stem is cleared, the on-disk key is +// removed entirely (zero-length blobs are never persisted). +func TestBintrieCodecDeleteLastOffsetRemovesKey(t *testing.T) { + codec, db := newTestBintrieCodec(t) + addr := common.HexToAddress("0x5555555555555555555555555555555555555555") + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000080") + value := bytes.Repeat([]byte{0xDD}, stemBlobValueSize) + + acctKey, storageKey := codec.StorageKey(addr, slot) + + // Write, verify, delete, verify absent. + batch := db.NewBatch() + codec.WriteStorage(batch, acctKey, storageKey, value) + flushBatch(t, batch) + + if got := codec.ReadStorage(db, acctKey, storageKey); !bytes.Equal(got, value) { + t.Fatalf("pre-delete read: got %x, want %x", got, value) + } + + batch = db.NewBatch() + codec.DeleteStorage(batch, acctKey, storageKey) + flushBatch(t, batch) + + // The raw key should be gone from the store. + raw := rawdb.ReadBinTrieStem(db, stemFromKey(storageKey)) + if raw != nil { + t.Fatalf("stem blob should be deleted, got %x", raw) + } + // And ReadStorage returns nil. + if got := codec.ReadStorage(db, acctKey, storageKey); got != nil { + t.Fatalf("post-delete read: got %x, want nil", got) + } +} + +// TestBintrieCodecCacheKeysDisjoint verifies that the bintrie cache key +// prefix keeps it disjoint from merkle account keys AND that two +// different offsets at the same stem produce DIFFERENT cache keys +// (the A1 remediation moved from per-stem caching to per-offset +// caching — without the full-key embedding, BasicData and CodeHash +// would collide in the cache and return wrong values). +func TestBintrieCodecCacheKeysDisjoint(t *testing.T) { + codec := &bintrieFlatCodec{} + merkle := &merkleFlatCodec{} + + // A 32-byte hash that, when passed to both codecs, would collide + // if the bintrie codec didn't prefix-disambiguate its cache keys. + hash := common.HexToHash("0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899") + + binKey := codec.AccountCacheKey(hash) + merkleKey := merkle.AccountCacheKey(hash) + + if bytes.Equal(binKey, merkleKey) { + t.Fatalf("bintrie and merkle cache keys collided: both are %x", binKey) + } + if binKey[0] != bintrieCacheKeyPrefix { + t.Fatalf("bintrie cache key missing prefix byte: %x", binKey) + } + // Per-offset disambiguation: two keys with the same stem but + // different offsets must produce distinct cache keys. + var basicKey common.Hash + copy(basicKey[:], hash[:]) + basicKey[31] = bintrie.BasicDataLeafKey + var codeKey common.Hash + copy(codeKey[:], hash[:]) + codeKey[31] = bintrie.CodeHashLeafKey + + basicCacheKey := codec.AccountCacheKey(basicKey) + codeCacheKey := codec.AccountCacheKey(codeKey) + if bytes.Equal(basicCacheKey, codeCacheKey) { + t.Fatalf("per-offset cache keys collided at same stem: %x", basicCacheKey) + } +} + +// TestBintrieCodecSplitMarker verifies the single-tier marker handling. +// For merkle the marker is a two-tier (account, account+storage) pair; +// for bintrie it's a single 32-byte stem key, so SplitMarker returns +// the same slice twice. +func TestBintrieCodecSplitMarker(t *testing.T) { + codec := &bintrieFlatCodec{} + + // Nil marker. + acc, full := codec.SplitMarker(nil) + if acc != nil || full != nil { + t.Fatalf("nil marker: acc=%v full=%v, want nil/nil", acc, full) + } + + // A 32-byte marker. Both halves point to the same bytes. + marker := bytes.Repeat([]byte{0xAA}, 32) + acc, full = codec.SplitMarker(marker) + if !bytes.Equal(acc, marker) || !bytes.Equal(full, marker) { + t.Fatalf("SplitMarker: acc=%x full=%x, want both %x", acc, full, marker) + } +} + +// TestBintrieCodecFlushAggregates verifies the per-stem aggregation that +// the codec's Flush method performs. Two distinct offsets at the SAME stem +// should produce a single on-disk stem blob containing both offsets after +// one Flush call — proving the codec collapses what would have been N +// read-modify-writes into one. +// +// Three offsets are written across two stems (2 + 1) so we exercise both +// the multi-offset and single-offset paths in a single test. +func TestBintrieCodecFlushAggregates(t *testing.T) { + codec, db := newTestBintrieCodec(t) + + // Build a per-offset accountData map mimicking what encodeBinary + // produces from a binaryHasher.DrainStemWrites: the keys are full + // 32-byte (stem || offset) tuples and the values are 32-byte leaves. + addr := common.HexToAddress("0xCafeBabeDeadBeef00112233445566778899aabb") + stem := bintrie.GetBinaryTreeKey(addr, make([]byte, 32))[:bintrie.StemSize] + + basicData := bytes.Repeat([]byte{0xAA}, stemBlobValueSize) + codeHash := bytes.Repeat([]byte{0xBB}, stemBlobValueSize) + storageVal := bytes.Repeat([]byte{0xCC}, stemBlobValueSize) + otherStem := bytes.Repeat([]byte{0x42}, bintrie.StemSize) + otherVal := bytes.Repeat([]byte{0xDD}, stemBlobValueSize) + + mkKey := func(stem []byte, offset byte) common.Hash { + var k common.Hash + copy(k[:bintrie.StemSize], stem) + k[bintrie.StemSize] = offset + return k + } + accountData := map[common.Hash][]byte{ + mkKey(stem, bintrie.BasicDataLeafKey): basicData, + mkKey(stem, bintrie.CodeHashLeafKey): codeHash, + mkKey(stem, 64): storageVal, // header storage slot + mkKey(otherStem, bintrie.BasicDataLeafKey): otherVal, + } + + batch := db.NewBatch() + accW, stoW, _ := codec.Flush(batch, nil, accountData, nil, nil) + flushBatch(t, batch) + + if accW != 4 { + t.Errorf("account write count: got %d, want 4", accW) + } + if stoW != 0 { + t.Errorf("storage write count: got %d, want 0 (no storage map)", stoW) + } + + // All three offsets at `stem` should be readable from a single on-disk + // blob; aggregation worked iff the second/third writes did not clobber + // the first. + blob := rawdb.ReadBinTrieStem(db, stem) + if len(blob) == 0 { + t.Fatal("stem blob missing after Flush") + } + for offset, want := range map[byte][]byte{ + bintrie.BasicDataLeafKey: basicData, + bintrie.CodeHashLeafKey: codeHash, + 64: storageVal, + } { + got, err := extractStemOffset(blob, offset) + if err != nil { + t.Fatalf("extract offset %d: %v", offset, err) + } + if !bytes.Equal(got, want) { + t.Errorf("offset %d: got %x, want %x", offset, got, want) + } + } + + // The other stem should also have its single offset. + otherBlob := rawdb.ReadBinTrieStem(db, otherStem) + if got, _ := extractStemOffset(otherBlob, bintrie.BasicDataLeafKey); !bytes.Equal(got, otherVal) { + t.Errorf("other stem BasicData: got %x, want %x", got, otherVal) + } +} + +// TestBintrieCodecCrossFlushRMW verifies that writes to the SAME stem +// from DIFFERENT flush passes (simulating blocks N and N+1) correctly +// merge on disk. Flush1 writes offsets 0+1 at stemX; Flush2 writes +// offset 64 at the same stem. After both flushes, all three offsets +// must be readable — the second flush must not clobber the first. +// +// This is the regression test for cross-flush RMW correctness and is +// the bread-and-butter behavior of the per-stem codec layout. Before +// the A1 remediation, the buffer → disk shape mismatch also masked +// this (different writes would be invisible through the reader), so +// the regression test had no teeth. +func TestBintrieCodecCrossFlushRMW(t *testing.T) { + codec, db := newTestBintrieCodec(t) + + stem := bytes.Repeat([]byte{0x99}, bintrie.StemSize) + mkKey := func(offset byte) common.Hash { + var k common.Hash + copy(k[:bintrie.StemSize], stem) + k[bintrie.StemSize] = offset + return k + } + basicVal := bytes.Repeat([]byte{0xAA}, stemBlobValueSize) + codeVal := bytes.Repeat([]byte{0xBB}, stemBlobValueSize) + slotVal := bytes.Repeat([]byte{0xCC}, stemBlobValueSize) + + // Flush 1: write BasicData (offset 0) and CodeHash (offset 1). + batch := db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{ + mkKey(bintrie.BasicDataLeafKey): basicVal, + mkKey(bintrie.CodeHashLeafKey): codeVal, + }, nil, nil) + flushBatch(t, batch) + + // Flush 2: write a header storage slot at offset 64 — same stem. + batch = db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{ + mkKey(64): slotVal, + }, nil, nil) + flushBatch(t, batch) + + // After both flushes, all three offsets must be readable. Before + // the RMW, Flush 2 would overwrite the stem blob and erase + // BasicData + CodeHash. + if got := codec.ReadAccount(db, mkKey(bintrie.BasicDataLeafKey)); !bytes.Equal(got, basicVal) { + t.Errorf("BasicData lost after second flush: got %x, want %x", got, basicVal) + } + if got := codec.ReadAccount(db, mkKey(bintrie.CodeHashLeafKey)); !bytes.Equal(got, codeVal) { + t.Errorf("CodeHash lost after second flush: got %x, want %x", got, codeVal) + } + if got := codec.ReadAccount(db, mkKey(64)); !bytes.Equal(got, slotVal) { + t.Errorf("header slot at offset 64 missing: got %x, want %x", got, slotVal) + } +} + +// TestBintrieCodecFlushDelete verifies that nil-valued entries in the +// accountData map clear the corresponding offset, and that clearing every +// populated offset at a stem removes the on-disk key entirely (matching +// the per-call DeleteStorage semantics tested elsewhere). +func TestBintrieCodecFlushDelete(t *testing.T) { + codec, db := newTestBintrieCodec(t) + + // Seed: write two offsets at one stem. + stem := bytes.Repeat([]byte{0x77}, bintrie.StemSize) + v0 := bytes.Repeat([]byte{0x01}, stemBlobValueSize) + v1 := bytes.Repeat([]byte{0x02}, stemBlobValueSize) + + mkKey := func(offset byte) common.Hash { + var k common.Hash + copy(k[:bintrie.StemSize], stem) + k[bintrie.StemSize] = offset + return k + } + batch := db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{ + mkKey(0): v0, + mkKey(1): v1, + }, nil, nil) + flushBatch(t, batch) + + // Now flush a nil for offset 0 — only offset 1 should remain. + batch = db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{mkKey(0): nil}, nil, nil) + flushBatch(t, batch) + + blob := rawdb.ReadBinTrieStem(db, stem) + if got, _ := extractStemOffset(blob, 0); got != nil { + t.Errorf("offset 0 should be cleared, got %x", got) + } + if got, _ := extractStemOffset(blob, 1); !bytes.Equal(got, v1) { + t.Errorf("offset 1 should survive, got %x want %x", got, v1) + } + + // Clear the last remaining offset; the on-disk key should disappear. + batch = db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{mkKey(1): nil}, nil, nil) + flushBatch(t, batch) + + if raw := rawdb.ReadBinTrieStem(db, stem); raw != nil { + t.Errorf("stem should be deleted, got %x", raw) + } +} diff --git a/triedb/pathdb/flush.go b/triedb/pathdb/flush.go index 4f816cf6a6..f6f02816de 100644 --- a/triedb/pathdb/flush.go +++ b/triedb/pathdb/flush.go @@ -17,8 +17,6 @@ package pathdb import ( - "bytes" - "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/rawdb" @@ -71,64 +69,16 @@ func writeNodes(batch ethdb.Batch, nodes map[common.Hash]map[string]*trienode.No // This function assumes the background generator is already terminated and states // before the supplied marker has been correctly generated. // +// The codec parameter abstracts the trie-specific persistence: merkleFlatCodec +// performs a per-entry rawdb write for each accountData/storageData entry, +// while bintrieFlatCodec aggregates per-offset writes into per-stem +// read-modify-writes. Either way, the genMarker filtering, cache update, and +// metric reporting all happen inside the codec — writeStates is just a thin +// dispatcher. +// // TODO(rjl493456442) do we really need this generation marker? The state updates // after the marker can also be written and will be fixed by generator later if // it's outdated. -func writeStates(batch ethdb.Batch, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int) { - var ( - accounts int - slots int - ) - for addrHash, blob := range accountData { - // Skip any account not yet covered by the snapshot. The account - // at the generation marker position (addrHash == genMarker[:common.HashLength]) - // should still be updated, as it would be skipped in the next - // generation cycle. - if genMarker != nil && bytes.Compare(addrHash[:], genMarker) > 0 { - continue - } - accounts += 1 - if len(blob) == 0 { - rawdb.DeleteAccountSnapshot(batch, addrHash) - if clean != nil { - clean.Set(addrHash[:], nil) - } - } else { - rawdb.WriteAccountSnapshot(batch, addrHash, blob) - if clean != nil { - clean.Set(addrHash[:], blob) - } - } - } - for addrHash, storages := range storageData { - // Skip any account not covered yet by the snapshot - if genMarker != nil && bytes.Compare(addrHash[:], genMarker) > 0 { - continue - } - midAccount := genMarker != nil && bytes.Equal(addrHash[:], genMarker[:common.HashLength]) - - for storageHash, blob := range storages { - // Skip any storage slot not yet covered by the snapshot. The storage slot - // at the generation marker position (addrHash == genMarker[:common.HashLength] - // and storageHash == genMarker[common.HashLength:]) should still be updated, - // as it would be skipped in the next generation cycle. - if midAccount && bytes.Compare(storageHash[:], genMarker[common.HashLength:]) > 0 { - continue - } - slots += 1 - key := storageKeySlice(addrHash, storageHash) - if len(blob) == 0 { - rawdb.DeleteStorageSnapshot(batch, addrHash, storageHash) - if clean != nil { - clean.Set(key, nil) - } - } else { - rawdb.WriteStorageSnapshot(batch, addrHash, storageHash, blob) - if clean != nil { - clean.Set(key, blob) - } - } - } - } - return accounts, slots +func writeStates(batch ethdb.Batch, codec flatStateCodec, genMarker []byte, accountData map[common.Hash][]byte, storageData map[common.Hash]map[common.Hash][]byte, clean *fastcache.Cache) (int, int, error) { + return codec.Flush(batch, genMarker, accountData, storageData, clean) } diff --git a/triedb/pathdb/generate.go b/triedb/pathdb/generate.go index d3d26fff26..6c01650854 100644 --- a/triedb/pathdb/generate.go +++ b/triedb/pathdb/generate.go @@ -93,6 +93,7 @@ type generator struct { running bool // Flag indicating whether the background generation is running db ethdb.KeyValueStore // Key-value store containing the snapshot data + codec flatStateCodec // Flat-state codec for key derivation, persistence, iterators stats *generatorStats // Generation statistics used throughout the entire life cycle abort chan chan struct{} // Notification channel to abort generating the snapshot in this layer done chan struct{} // Notification channel when generation is done @@ -109,7 +110,11 @@ type generator struct { // progress indicates the starting position for resuming snapshot generation. // It must be provided even if generation is not allowed; otherwise, uncovered // states may be exposed for serving. -func newGenerator(db ethdb.KeyValueStore, noBuild bool, progress []byte, stats *generatorStats) *generator { +// +// codec is the flat-state codec used for marker handling, prefix selection, +// persistence, and iterator construction. It must match the codec configured +// on the owning Database. +func newGenerator(db ethdb.KeyValueStore, codec flatStateCodec, noBuild bool, progress []byte, stats *generatorStats) *generator { if stats == nil { stats = &generatorStats{start: time.Now()} } @@ -117,6 +122,7 @@ func newGenerator(db ethdb.KeyValueStore, noBuild bool, progress []byte, stats * noBuild: noBuild, progress: progress, db: db, + codec: codec, stats: stats, abort: make(chan chan struct{}), done: make(chan struct{}), @@ -124,6 +130,13 @@ func newGenerator(db ethdb.KeyValueStore, noBuild bool, progress []byte, stats * } // run starts the state snapshot generation in the background. +// +// The dispatch on codec type chooses between the merkle two-tier +// account/storage iteration (`generate`) and the bintrie single-tier +// stem iteration (`generateBintrie`). Both share the same lifecycle +// (g.running, g.abort, g.done) and the same progress journal format, +// so the only difference visible to callers of run/stop is which +// background routine is launched. func (g *generator) run(root common.Hash) { if g.noBuild { log.Warn("Snapshot generation is not permitted") @@ -134,7 +147,11 @@ func (g *generator) run(root common.Hash) { log.Warn("Paused the leftover generation cycle") } g.running = true - go g.generate(newGeneratorContext(root, g.progress, g.db)) + if _, isBintrie := g.codec.(*bintrieFlatCodec); isBintrie { + go g.generateBintrie(newBintrieGeneratorContext(root, g.progress, g.db)) + return + } + go g.generate(newGeneratorContext(root, g.progress, g.db, g.codec)) } // stop terminates the background generation if it's actively running. @@ -168,15 +185,6 @@ func (g *generator) progressMarker() []byte { return g.progress } -// splitMarker is an internal helper which splits the generation progress marker -// into two parts. -func splitMarker(marker []byte) ([]byte, []byte) { - var accMarker []byte - if len(marker) > 0 { - accMarker = marker[:common.HashLength] - } - return accMarker, marker -} // generateSnapshot regenerates a brand-new snapshot based on an existing state // database and head block asynchronously. The snapshot is returned immediately @@ -188,7 +196,7 @@ func generateSnapshot(triedb *Database, root common.Hash, noBuild bool) *diskLay genMarker = []byte{} // Initialized but empty! ) dl := newDiskLayer(root, 0, triedb, nil, nil, newBuffer(triedb.config.WriteBufferSize, nil, nil, 0), nil) - dl.setGenerator(newGenerator(triedb.diskdb, noBuild, genMarker, stats)) + dl.setGenerator(newGenerator(triedb.diskdb, triedb.flatCodec, noBuild, genMarker, stats)) if !noBuild { dl.generator.run(root) @@ -198,13 +206,20 @@ func generateSnapshot(triedb *Database, root common.Hash, noBuild bool) *diskLay } // journalProgress persists the generator stats into the database to resume later. -func journalProgress(db ethdb.KeyValueWriter, marker []byte, stats *generatorStats) { +// +// It is a method on generator so it can stamp the journal entry with the +// active scheme (merkle vs. bintrie). loadGenerator uses that flag to +// discard journals from a different scheme rather than blindly resuming +// with an incompatible marker shape. +func (g *generator) journalProgress(db ethdb.KeyValueWriter, marker []byte, stats *generatorStats) { // Write out the generator marker. Note it's a standalone disk layer generator // which is not mixed with journal. It's ok if the generator is persisted while // journal is not. + _, isBintrie := g.codec.(*bintrieFlatCodec) entry := journalGenerator{ - Done: marker == nil, - Marker: marker, + Done: marker == nil, + Marker: marker, + IsBintrie: isBintrie, } if stats != nil { entry.Accounts = stats.accounts @@ -595,7 +610,7 @@ func (g *generator) checkAndFlush(ctx *generatorContext, current []byte) error { // Persist the progress marker regardless of whether the batch is empty or not. // It may happen that all the flat states in the database are correct, so the // generator indeed makes progress even if there is nothing to commit. - journalProgress(ctx.batch, current, g.stats) + g.journalProgress(ctx.batch, current, g.stats) // Flush out the database writes atomically if err := ctx.batch.Write(); err != nil { @@ -633,12 +648,12 @@ func (g *generator) generateStorages(ctx *generatorContext, account common.Hash, }(time.Now()) if delete { - rawdb.DeleteStorageSnapshot(ctx.batch, account, common.BytesToHash(key)) + g.codec.DeleteStorage(ctx.batch, account, common.BytesToHash(key)) wipedStorageMeter.Mark(1) return nil } if write { - rawdb.WriteStorageSnapshot(ctx.batch, account, common.BytesToHash(key), val) + g.codec.WriteStorage(ctx.batch, account, common.BytesToHash(key), val) generatedStorageMeter.Mark(1) } else { recoveredStorageMeter.Mark(1) @@ -682,7 +697,7 @@ func (g *generator) generateAccounts(ctx *generatorContext, accMarker []byte) er start := time.Now() if delete { - rawdb.DeleteAccountSnapshot(ctx.batch, account) + g.codec.DeleteAccount(ctx.batch, account) wipedAccountMeter.Mark(1) accountWriteCounter.Inc(time.Since(start).Nanoseconds()) @@ -708,7 +723,7 @@ func (g *generator) generateAccounts(ctx *generatorContext, accMarker []byte) er } else { data := types.SlimAccountRLP(acc) dataLen = len(data) - rawdb.WriteAccountSnapshot(ctx.batch, account, data) + g.codec.WriteAccount(ctx.batch, account, data) generatedAccountMeter.Mark(1) } g.stats.storage += common.StorageSize(1 + common.HashLength + dataLen) @@ -774,7 +789,7 @@ func (g *generator) generate(ctx *generatorContext) { if len(g.progress) == 0 { batch := g.db.NewBatch() rawdb.WriteSnapshotRoot(batch, ctx.root) - journalProgress(batch, g.progress, g.stats) + g.journalProgress(batch, g.progress, g.stats) if err := batch.Write(); err != nil { log.Crit("Failed to write initialized state marker", "err", err) } @@ -788,7 +803,7 @@ func (g *generator) generate(ctx *generatorContext) { // processed twice by the generator(they are already processed in the // last run) but it's fine. var ( - accMarker, _ = splitMarker(g.progress) + accMarker, _ = g.codec.SplitMarker(g.progress) abort chan struct{} ) if err := g.generateAccounts(ctx, accMarker); err != nil { @@ -807,7 +822,7 @@ func (g *generator) generate(ctx *generatorContext) { // Snapshot fully generated, set the marker to nil. // Note even there is nothing to commit, persist the // generator anyway to mark the snapshot is complete. - journalProgress(ctx.batch, nil, g.stats) + g.journalProgress(ctx.batch, nil, g.stats) if err := ctx.batch.Write(); err != nil { log.Error("Failed to flush batch", "err", err) abort = <-g.abort diff --git a/triedb/pathdb/generate_bintrie.go b/triedb/pathdb/generate_bintrie.go new file mode 100644 index 0000000000..51dbdab1d7 --- /dev/null +++ b/triedb/pathdb/generate_bintrie.go @@ -0,0 +1,364 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/trie/bintrie" + "github.com/ethereum/go-ethereum/triedb/database" +) + +// bintrieDiskStore is the bintrie equivalent of diskStore (the merkle +// reader used by the snapshot generator). The two differ in how +// NodeReader validates the requested state root: the merkle store +// hashes the on-disk account-trie root with keccak256, while the +// bintrie root must be deserialized as a binary node and rehashed with +// sha256 (the bintrie's native hash function). Sharing the merkle store +// would always fail validation for a bintrie root. +// +// Once validated, both stores read trie nodes by path via +// rawdb.ReadAccountTrieNode — the path-based key space is shared +// between the two schemes (the bintrie sits in the same namespace as +// the account trie because EIP-7864 unifies storage under accounts). +type bintrieDiskStore struct { + db ethdb.KeyValueStore +} + +// NodeReader validates that the bintrie root currently persisted at the +// account-trie nil path matches the requested state root. The returned +// reader is a plain path-based diskReader (the same one used by the +// merkle generator) — only the validation logic differs. +func (s *bintrieDiskStore) NodeReader(stateRoot common.Hash) (database.NodeReader, error) { + // EmptyBinaryHash and the legacy EmptyRootHash are both treated as + // "trie has no persisted root" — neither has a corresponding on-disk + // node, and the bintrie itself short-circuits these cases inside + // NewBinaryTrie. We accept them here without touching the disk. + if stateRoot == (common.Hash{}) || stateRoot == types.EmptyBinaryHash || stateRoot == types.EmptyRootHash { + return &diskReader{s.db}, nil + } + blob := rawdb.ReadAccountTrieNode(s.db, nil) + if len(blob) == 0 { + return nil, fmt.Errorf("bintrie state %x is not available (empty root node)", stateRoot) + } + // DeserializeNode rehashes via sha256 internally; the resulting node's + // Hash() is the canonical bintrie root hash for the on-disk blob. + root, err := bintrie.DeserializeNode(blob, 0) + if err != nil { + return nil, fmt.Errorf("bintrie state %x: deserialize root: %w", stateRoot, err) + } + if got := root.Hash(); got != stateRoot { + return nil, fmt.Errorf("bintrie state %x is not available (have %x)", stateRoot, got) + } + return &diskReader{s.db}, nil +} + +// bintrieGeneratorContext holds the state needed by a single bintrie +// snapshot generation cycle. Unlike generatorContext (which manages two +// holdable iterators over the on-disk merkle account/storage prefixes), +// the bintrie path iterates the trie itself and never re-reads the +// existing flat state. As a result the bintrie context is small: just +// a write batch, the target root, and a single 32-byte progress marker +// (the bintrie key (stem || offset) at which the previous run was +// interrupted). +// +// The context is recreated on every generator restart, mirroring the +// merkle generatorContext lifecycle. +type bintrieGeneratorContext struct { + root common.Hash // State root of the generation target + marker []byte // Resume marker — a full 32-byte (stem || offset) key + db ethdb.KeyValueStore // Key-value store containing trie nodes and stem blobs + batch ethdb.Batch // Database batch for atomic writes + logged time.Time // Timestamp of the last progress log message +} + +// newBintrieGeneratorContext initializes a fresh context bound to the +// given target root, starting from the supplied resume marker. A nil or +// zero-length marker means "start from the beginning of the trie". +func newBintrieGeneratorContext(root common.Hash, marker []byte, db ethdb.KeyValueStore) *bintrieGeneratorContext { + return &bintrieGeneratorContext{ + root: root, + marker: marker, + db: db, + batch: db.NewBatch(), + logged: time.Now(), + } +} + +// close releases any resources held by the context. The bintrie path +// holds no long-lived iterators outside of generateBinTrieStems (which +// owns its iterator and releases it on return), so this is currently a +// no-op. It exists symmetrically with generatorContext.close so future +// resource additions have an obvious place to land. +func (ctx *bintrieGeneratorContext) close() {} + +// generateBinTrieStems regenerates the bintrie flat-state by iterating +// the entire bintrie and emitting one stem blob per stem. The iterator +// yields leaves in stem-then-offset order, so we accumulate offsets in a +// per-stem builder and flush whenever the stem changes (and once more +// at the end of iteration). +// +// Resume support is structural: ctx.marker — a 32-byte (stem || offset) +// key — is fed straight to BinaryTrie.NodeIterator which positions on the +// first leaf with key >= marker via binaryNodeIterator.seek (added in +// Commit 1). Resuming inside a stem is safe because flushStem performs a +// read-modify-write: the builder's new offsets (from the resumed walk) +// are merged with the existing on-disk blob (from the prior pass). If +// the marker is at offset 3 of stemA, the resume processes offsets 3..N +// and the merge preserves offsets 0..2 from disk. One extra disk read +// per flushStem (the RMW) is negligible compared to the walk cost. +// +// Range proofs are deliberately not used here. The bintrie's Prove path +// is not implemented yet, and an iteration-only generation cycle is +// acceptable because regeneration is a one-time cost paid at startup. +// +// Code chunks (offsets 128..255) are written to the same stem blobs as +// account header and storage offsets — it keeps the stem encoding +// symmetric with the trie and means a future re-iteration regenerates +// the entire stem layout in one pass. +func (g *generator) generateBinTrieStems(ctx *bintrieGeneratorContext) error { + // Open the bintrie via the same disk-backed reader that the merkle + // generator uses. The diskStore reads trie nodes via + // rawdb.ReadAccountTrieNode/ReadStorageTrieNode against the + // already-namespaced verkle table (db.diskdb wraps it under + // VerklePrefix), so the same accessor works for both schemes. + tr, err := bintrie.NewBinaryTrie(ctx.root, &bintrieDiskStore{db: ctx.db}) + if err != nil { + log.Info("Bintrie missing, snapshotting paused", "state", ctx.root, "err", err) + return errMissingTrie + } + it, err := tr.NodeIterator(ctx.marker) + if err != nil { + return err + } + + var ( + // currentStem is a freshly-allocated copy of the most recently + // observed leaf's stem. We never alias the iterator's slice + // because it can be invalidated on Next. + currentStem []byte + builder = newStemBuilder() + ) + + // flushStem performs a read-modify-write on the stem being accumulated: + // it reads the existing on-disk stem blob (if any), merges in the + // builder's new offsets (new values win over existing), and writes the + // merged result back. This makes mid-stem resume safe: if a prior pass + // wrote offsets 0..2 and the current pass (after resuming at offset 3) + // only has offsets 3..4 in the builder, the merge preserves 0..2 from + // disk and adds 3..4 — no data loss. + // + // Without this RMW, a mid-stem resume would overwrite the existing disk + // blob with a partial one, silently dropping the earlier offsets. This + // was bug C1 identified in the PR review. + flushStem := func() error { + if currentStem == nil || builder.empty() { + return nil + } + existing := rawdb.ReadBinTrieStem(ctx.db, currentStem) + writes := builder.toOffsetValues() + merged, err := mergeStemBlob(existing, writes) + if err != nil { + return fmt.Errorf("merge stem %x failed: %w", currentStem, err) + } + if merged == nil { + rawdb.DeleteBinTrieStem(ctx.batch, currentStem) + } else { + rawdb.WriteBinTrieStem(ctx.batch, currentStem, merged) + } + builder.reset() + // Bookkeeping: count one stem per emitted blob. + g.stats.accounts++ + return nil + } + + for it.Next(true) { + if !it.Leaf() { + continue + } + key := it.LeafKey() + val := it.LeafBlob() + + // A well-formed bintrie leaf is always (32-byte key, 32-byte value). + // Defensive check so a malformed trie surfaces as an error rather + // than corrupting the flat state. + if len(key) != bintrie.StemSize+1 { + return fmt.Errorf("bintrie leaf key has len %d, want %d", len(key), bintrie.StemSize+1) + } + if len(val) != stemBlobValueSize { + return fmt.Errorf("bintrie leaf value has len %d, want %d", len(val), stemBlobValueSize) + } + + // Stem boundary detection: if we've moved to a new stem, persist + // the previous one before starting a new builder. + if currentStem != nil && !bytes.Equal(key[:bintrie.StemSize], currentStem) { + if err := flushStem(); err != nil { + return err + } + currentStem = nil + } + if currentStem == nil { + currentStem = make([]byte, bintrie.StemSize) + copy(currentStem, key[:bintrie.StemSize]) + } + // builder.set takes an owning copy internally so it's safe to + // hand it the iterator's transient value slice. + builder.set(key[bintrie.StemSize], val) + + g.stats.slots++ + g.stats.storage += common.StorageSize(1 + bintrie.StemSize + len(val)) + + // Use the FULL leaf key (stem || offset) as the progress marker + // so an interrupted run can resume mid-stem. checkAndFlushBin + // takes an owning copy because the iterator's key may be + // invalidated on the next call. + marker := make([]byte, len(key)) + copy(marker, key) + if err := g.checkAndFlushBin(ctx, marker); err != nil { + return err + } + } + if err := it.Error(); err != nil { + return err + } + // Flush the trailing stem (the loop only flushes on transitions). + if err := flushStem(); err != nil { + return err + } + return nil +} + +// checkAndFlushBin is the bintrie analogue of checkAndFlush. It saves +// progress as a single 32-byte (stem || offset) key and writes the +// batch when it exceeds IdealBatchSize, or when an abort signal is +// received. +// +// Unlike the merkle variant, there are no snapshot iterators to reopen +// here — the bintrie path iterates the trie itself, and the trie +// iterator manages its own resource lifetime. +func (g *generator) checkAndFlushBin(ctx *bintrieGeneratorContext, current []byte) error { + var abort chan struct{} + select { + case abort = <-g.abort: + default: + } + if ctx.batch.ValueSize() > ethdb.IdealBatchSize || abort != nil { + if bytes.Compare(current, g.progress) < 0 { + log.Error("Bintrie generator went backwards", + "current", fmt.Sprintf("%x", current), + "genMarker", fmt.Sprintf("%x", g.progress)) + } + // Persist progress regardless of whether the batch is empty — + // it may be that all observed stems were already on disk and + // nothing actually changed. + g.journalProgress(ctx.batch, current, g.stats) + + if err := ctx.batch.Write(); err != nil { + return err + } + ctx.batch.Reset() + + g.lock.Lock() + g.progress = current + g.lock.Unlock() + + if abort != nil { + g.stats.log("Aborting bintrie snapshot generation", ctx.root, g.progress) + return newAbortErr(abort) + } + } + if time.Since(ctx.logged) > 8*time.Second { + g.stats.log("Generating bintrie snapshot", ctx.root, g.progress) + ctx.logged = time.Now() + } + return nil +} + +// generateBintrie is the bintrie analogue of the merkle `generate` +// background loop. The shapes mirror each other so the lifecycle and +// shutdown protocol look identical to callers (`run` / `stop`): +// +// 1. Persist the initial progress marker if this is a fresh run +// (so a crash after the first batch can find the genesis marker +// during recovery). +// 2. Drive generateBinTrieStems to completion (or until an abort). +// 3. On clean completion, write the "done" sentinel marker, log a +// summary, and close g.done. +// 4. On abort (internal error or external signal), close the abort +// channel and return. +func (g *generator) generateBintrie(ctx *bintrieGeneratorContext) { + g.stats.log("Resuming bintrie snapshot generation", ctx.root, g.progress) + defer ctx.close() + + if len(g.progress) == 0 { + batch := ctx.db.NewBatch() + rawdb.WriteSnapshotRoot(batch, ctx.root) + g.journalProgress(batch, g.progress, g.stats) + if err := batch.Write(); err != nil { + log.Crit("Failed to write initialized bintrie state marker", "err", err) + } + } + + var abort chan struct{} + if err := g.generateBinTrieStems(ctx); err != nil { + var aerr *abortErr + if errors.As(err, &aerr) { + abort = aerr.abort + } + // Internal error: wait for an external abort signal so the + // caller's stop() invocation can synchronize. + if abort == nil { + abort = <-g.abort + } + close(abort) + return + } + + // Successful completion: write the nil "done" marker so subsequent + // loads know the snapshot is complete. + g.journalProgress(ctx.batch, nil, g.stats) + if err := ctx.batch.Write(); err != nil { + log.Error("Failed to flush bintrie batch", "err", err) + abort = <-g.abort + close(abort) + return + } + ctx.batch.Reset() + + log.Info("Generated bintrie snapshot", + "stems", g.stats.accounts, + "leaves", g.stats.slots, + "storage", g.stats.storage, + "elapsed", common.PrettyDuration(time.Since(g.stats.start))) + + g.lock.Lock() + g.progress = nil + g.lock.Unlock() + close(g.done) + + // Block until the eventual stop() so the caller can wait for us. + abort = <-g.abort + close(abort) +} diff --git a/triedb/pathdb/generate_bintrie_test.go b/triedb/pathdb/generate_bintrie_test.go new file mode 100644 index 0000000000..17a0f02520 --- /dev/null +++ b/triedb/pathdb/generate_bintrie_test.go @@ -0,0 +1,372 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/trie/bintrie" + "github.com/holiman/uint256" +) + +// buildTestBintrie constructs a small in-memory bintrie containing two +// accounts and one storage slot, persists its serialized nodes into the +// supplied key-value store under the standard pathdb account-trie key +// space (which is what the bintrie reads back via diskStore), and returns +// the resulting state root. +// +// This helper sidesteps triedb.Database to avoid an import cycle: pathdb +// is a child of triedb, so the test cannot construct a triedb.Database +// here. Instead it manually persists the nodes returned by +// bintrie.Commit, mirroring what writeNodes would do in production. +func buildTestBintrie(t *testing.T, db ethdb.Database) (common.Hash, []addrAcct) { + t.Helper() + + // Use a memory-backed NodeDatabase for the empty starting trie. The + // trie's nodeResolver returns nil for unknown hashes, which matches + // the empty-trie semantics expected by NewBinaryTrie. + tr, err := bintrie.NewBinaryTrie(types.EmptyBinaryHash, &diskStore{db: db}) + if err != nil { + t.Fatalf("new bintrie: %v", err) + } + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000007") + slotValue := bytes.Repeat([]byte{0x77}, 32) + + if err := tr.UpdateAccount(addr1, &types.StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(100), + CodeHash: types.EmptyCodeHash[:], + }, 0); err != nil { + t.Fatalf("update account 1: %v", err) + } + if err := tr.UpdateAccount(addr2, &types.StateAccount{ + Nonce: 2, + Balance: uint256.NewInt(200), + CodeHash: types.EmptyCodeHash[:], + }, 0); err != nil { + t.Fatalf("update account 2: %v", err) + } + if err := tr.UpdateStorage(addr1, slot[:], slotValue); err != nil { + t.Fatalf("update storage: %v", err) + } + root, nodes := tr.Commit(false) + + // Persist all collected nodes via the standard account-trie path + // scheme accessor — the bintrie sits in the same key space as the + // account trie because there are no per-account storage tries in + // EIP-7864. + batch := db.NewBatch() + for path, node := range nodes.Nodes { + if node.IsDeleted() { + rawdb.DeleteAccountTrieNode(batch, []byte(path)) + continue + } + rawdb.WriteAccountTrieNode(batch, []byte(path), node.Blob) + } + if err := batch.Write(); err != nil { + t.Fatalf("flush trie nodes: %v", err) + } + + return root, []addrAcct{ + {addr: addr1, hasStorage: true, slot: slot, slotVal: slotValue}, + {addr: addr2, hasStorage: false}, + } +} + +// addrAcct describes a test account so the assertions phase can re-derive +// the bintrie keys it should find on disk. +type addrAcct struct { + addr common.Address + hasStorage bool + slot common.Hash + slotVal []byte +} + +// runTestBintrieGenerator wires up a generator with the bintrie codec and +// drives generateBinTrieStems to completion. It returns the codec and the +// underlying db so the assertions can read back stem blobs. +func runTestBintrieGenerator(t *testing.T, db ethdb.Database, root common.Hash, marker []byte) { + t.Helper() + + codec := newBintrieFlatCodec(db) + gen := &generator{ + db: db, + codec: codec, + stats: &generatorStats{start: time.Now()}, + abort: make(chan chan struct{}, 1), + done: make(chan struct{}), + } + ctx := newBintrieGeneratorContext(root, marker, db) + defer ctx.close() + + if err := gen.generateBinTrieStems(ctx); err != nil { + t.Fatalf("generateBinTrieStems: %v", err) + } + if err := ctx.batch.Write(); err != nil { + t.Fatalf("final batch write: %v", err) + } +} + +// TestBintrieGeneratorRebuildsStems verifies the happy-path: +// - Build a small bintrie with two accounts and one storage slot. +// - Run the generator on its root. +// - Read back the stem blobs and check every offset round-trips. +// +// This is the primary "the generator works" test. +func TestBintrieGeneratorRebuildsStems(t *testing.T) { + db := rawdb.NewMemoryDatabase() + root, accounts := buildTestBintrie(t, db) + + // Sanity-check that the bintrie isn't trivially empty. + if root == (common.Hash{}) || root == types.EmptyBinaryHash { + t.Fatal("test bintrie produced an empty root") + } + + runTestBintrieGenerator(t, db, root, nil) + + // Each test account must have its BasicData (offset 0) and CodeHash + // (offset 1) entries on disk after generation. + for _, a := range accounts { + stem := bintrie.GetBinaryTreeKeyBasicData(a.addr)[:bintrie.StemSize] + blob := rawdb.ReadBinTrieStem(db, stem) + if len(blob) == 0 { + t.Errorf("addr %x: stem blob missing after generation", a.addr) + continue + } + basic, err := extractStemOffset(blob, bintrie.BasicDataLeafKey) + if err != nil || len(basic) != 32 { + t.Errorf("addr %x: BasicData missing/invalid (err=%v len=%d)", a.addr, err, len(basic)) + } + codeHash, err := extractStemOffset(blob, bintrie.CodeHashLeafKey) + if err != nil || !bytes.Equal(codeHash, types.EmptyCodeHash[:]) { + t.Errorf("addr %x: CodeHash mismatch (err=%v got=%x)", a.addr, err, codeHash) + } + } + + // The storage slot must be present at its derived stem (which may + // equal the account's BasicData stem for header slots, or differ for + // out-of-header slots — slot 7 is in-header so we expect the same + // stem as BasicData). + a := accounts[0] + storageKey := bintrie.GetBinaryTreeKeyStorageSlot(a.addr, a.slot[:]) + storageBlob := rawdb.ReadBinTrieStem(db, storageKey[:bintrie.StemSize]) + if len(storageBlob) == 0 { + t.Fatal("storage stem blob missing") + } + got, err := extractStemOffset(storageBlob, storageKey[bintrie.StemSize]) + if err != nil { + t.Fatalf("extract storage offset: %v", err) + } + if !bytes.Equal(got, a.slotVal) { + t.Errorf("storage value mismatch: got %x want %x", got, a.slotVal) + } +} + +// TestBintrieGeneratorResumeStemBoundary verifies that a generator +// started from a stem-boundary marker (stem || offset 0) correctly +// generates only the stems at or after the marker. +func TestBintrieGeneratorResumeStemBoundary(t *testing.T) { + db := rawdb.NewMemoryDatabase() + root, accounts := buildTestBintrie(t, db) + + stem1 := bintrie.GetBinaryTreeKeyBasicData(accounts[0].addr)[:bintrie.StemSize] + stem2 := bintrie.GetBinaryTreeKeyBasicData(accounts[1].addr)[:bintrie.StemSize] + larger := stem1 + smaller := stem2 + if bytes.Compare(stem1, stem2) < 0 { + larger, smaller = stem2, stem1 + } + + marker := make([]byte, 32) + copy(marker, larger) + + runTestBintrieGenerator(t, db, root, marker) + + if got := rawdb.ReadBinTrieStem(db, smaller); len(got) != 0 { + t.Errorf("smaller stem should have been skipped by resume marker, got %x", got) + } + if got := rawdb.ReadBinTrieStem(db, larger); len(got) == 0 { + t.Errorf("larger stem should have been generated after resume marker") + } +} + +// TestBintrieGeneratorResumeMidStem is the regression test for review +// finding C1 (mid-stem resume drops earlier offsets). Before A3's fix, +// flushStem OVERWROTE the on-disk stem blob with only the offsets +// accumulated after the resume point. Offsets from a prior pass that +// were already on disk were silently lost. +// +// The test simulates a two-pass generation: +// +// 1. Pre-seed the disk with a stem blob containing offsets 0 and 1 +// (simulating what a prior pass wrote before being interrupted). +// 2. Run the generator with marker = stem||1 (resume INSIDE the stem, +// past offset 0). +// 3. After the generator completes, verify that the on-disk blob +// contains ALL offsets (0, 1, and everything else the trie has) +// — not just the offsets from the resumed walk. +// +// Before A3: step 3 would show only the post-marker offsets. +func TestBintrieGeneratorResumeMidStem(t *testing.T) { + db := rawdb.NewMemoryDatabase() + root, accounts := buildTestBintrie(t, db) + + // Pick addr1 (the one with storage). It has BasicData (offset 0), + // CodeHash (offset 1), and storage slot 7 at offset 64+7=71. + a := accounts[0] + stem := bintrie.GetBinaryTreeKeyBasicData(a.addr)[:bintrie.StemSize] + + // Step 1: Pre-seed the disk with a partial stem blob containing + // only offsets 0 and 1 — as if a prior generator pass wrote them + // before being interrupted. + preSeed := newStemBuilder() + preSeed.set(bintrie.BasicDataLeafKey, bytes.Repeat([]byte{0xAA}, 32)) + preSeed.set(bintrie.CodeHashLeafKey, bytes.Repeat([]byte{0xBB}, 32)) + rawdb.WriteBinTrieStem(db, stem, preSeed.encode()) + + // Step 2: Resume from offset 1 — the generator should pick up at + // offset 1 of this stem and walk forward. The builder will + // accumulate only offset 1 + storage offset from the trie walk. + // The RMW in flushStem must merge them with the pre-seeded disk + // blob to preserve offset 0. + marker := make([]byte, 32) + copy(marker[:bintrie.StemSize], stem) + marker[bintrie.StemSize] = bintrie.CodeHashLeafKey // resume at offset 1 + + runTestBintrieGenerator(t, db, root, marker) + + // Step 3: After the full run, verify the disk blob has ALL offsets. + blob := rawdb.ReadBinTrieStem(db, stem) + if len(blob) == 0 { + t.Fatal("stem blob missing after mid-stem resume") + } + + // Offset 0 (BasicData): must survive the mid-stem resume because + // the RMW merged the builder's new content with the existing disk + // blob. Before A3, this offset was silently dropped. + basic, err := extractStemOffset(blob, bintrie.BasicDataLeafKey) + if err != nil { + t.Fatalf("extract BasicData: %v", err) + } + if len(basic) != 32 { + t.Fatalf("BasicData lost after mid-stem resume (A3 regression): got len=%d, want 32", len(basic)) + } + + // Offset 1 (CodeHash): the generator walked this offset (it's at + // the marker), so the trie's authoritative value should overwrite + // the pre-seeded one. + code, err := extractStemOffset(blob, bintrie.CodeHashLeafKey) + if err != nil { + t.Fatalf("extract CodeHash: %v", err) + } + if len(code) != 32 { + t.Fatalf("CodeHash missing after resume: got len=%d", len(code)) + } + + // Storage slot must also be present (the generator walked it as + // part of the full stem traversal). + storageKey := bintrie.GetBinaryTreeKeyStorageSlot(a.addr, a.slot[:]) + storageOffset := storageKey[bintrie.StemSize] + storageStem := storageKey[:bintrie.StemSize] + if bytes.Equal(storageStem, stem) { + // Storage is at the same stem (header slot) — verify it's in the blob. + storageVal, err := extractStemOffset(blob, storageOffset) + if err != nil { + t.Fatalf("extract storage: %v", err) + } + if !bytes.Equal(storageVal, a.slotVal) { + t.Errorf("storage value: got %x, want %x", storageVal, a.slotVal) + } + } +} + +// TestBintrieGeneratorWithContractCode verifies that the generator +// correctly writes code-chunk offsets (128..255) into stem blobs for +// contracts with non-trivial code. This is the A16/T10 test. +func TestBintrieGeneratorWithContractCode(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Build a bintrie with one contract that has ~100 bytes of code. + // Per EIP-7864, code is chunked into 31-byte pieces starting at + // offset 128 of the account's stem. + tr, err := bintrie.NewBinaryTrie(types.EmptyBinaryHash, &bintrieDiskStore{db: db}) + if err != nil { + t.Fatalf("new bintrie: %v", err) + } + addr := common.HexToAddress("0xContractContractContractContractContrac") + code := make([]byte, 100) + for i := range code { + code[i] = byte(i) + } + if err := tr.UpdateAccount(addr, &types.StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(1000), + CodeHash: types.EmptyCodeHash[:], + }, len(code)); err != nil { + t.Fatalf("UpdateAccount: %v", err) + } + codeHash := common.BytesToHash(types.EmptyCodeHash[:]) + if err := tr.UpdateContractCode(addr, codeHash, code); err != nil { + t.Fatalf("UpdateContractCode: %v", err) + } + root, nodes := tr.Commit(false) + + // Persist trie nodes + batch := db.NewBatch() + for path, node := range nodes.Nodes { + if !node.IsDeleted() { + rawdb.WriteAccountTrieNode(batch, []byte(path), node.Blob) + } + } + if err := batch.Write(); err != nil { + t.Fatalf("flush trie nodes: %v", err) + } + + // Run the generator + runTestBintrieGenerator(t, db, root, nil) + + // Verify account header offsets are present. + stem := bintrie.GetBinaryTreeKeyBasicData(addr)[:bintrie.StemSize] + blob := rawdb.ReadBinTrieStem(db, stem) + if len(blob) == 0 { + t.Fatal("stem blob missing for contract account") + } + basic, _ := extractStemOffset(blob, bintrie.BasicDataLeafKey) + if len(basic) != 32 { + t.Errorf("BasicData: got len %d, want 32", len(basic)) + } + codeHashLeaf, _ := extractStemOffset(blob, bintrie.CodeHashLeafKey) + if len(codeHashLeaf) != 32 { + t.Errorf("CodeHash: got len %d, want 32", len(codeHashLeaf)) + } + + // Verify at least one code chunk offset (128) is present. + // 100 bytes of code = ceil(100/31) = 4 chunks, at offsets 128..131. + codeChunk0, _ := extractStemOffset(blob, 128) + if len(codeChunk0) != 32 { + t.Errorf("Code chunk at offset 128: got len %d, want 32 (code chunk missing from stem blob)", len(codeChunk0)) + } +} diff --git a/triedb/pathdb/iterator_test.go b/triedb/pathdb/iterator_test.go index adb534f47d..d434598924 100644 --- a/triedb/pathdb/iterator_test.go +++ b/triedb/pathdb/iterator_test.go @@ -137,7 +137,7 @@ func TestAccountIteratorBasics(t *testing.T) { db := rawdb.NewMemoryDatabase() batch := db.NewBatch() - states.write(batch, nil, nil) + states.write(batch, &merkleFlatCodec{}, nil, nil) batch.Write() it = newDiskAccountIterator(db, common.Hash{}) verifyIterator(t, 100, it, verifyNothing) // Nil is allowed for single layer iterator @@ -176,7 +176,7 @@ func TestStorageIteratorBasics(t *testing.T) { db := rawdb.NewMemoryDatabase() batch := db.NewBatch() - states.write(batch, nil, nil) + states.write(batch, &merkleFlatCodec{}, nil, nil) batch.Write() for account := range accounts { it := newDiskStorageIterator(db, account, common.Hash{}) diff --git a/triedb/pathdb/journal.go b/triedb/pathdb/journal.go index efcc3f2549..d586553d5b 100644 --- a/triedb/pathdb/journal.go +++ b/triedb/pathdb/journal.go @@ -50,7 +50,11 @@ var ( // - Version 1: storage.Incomplete field is removed // - Version 2: add post-modification state values // - Version 3: a flag has been added to indicate whether the storage slot key is the raw key or a hash -const journalVersion uint64 = 3 +// - Version 4: bintrie flat-state per-stem layout. The journalGenerator +// struct gains an IsBintrie flag (rlp:"optional", defaults to +// false) so the loader can discard journals from a mismatched +// scheme and trigger a full flat-state regeneration. +const journalVersion uint64 = 4 // loadJournal tries to parse the layer journal from the disk. func (db *Database) loadJournal(diskRoot common.Hash) (layer, error) { @@ -119,10 +123,27 @@ type journalGenerator struct { Accounts uint64 Slots uint64 Storage uint64 + + // IsBintrie distinguishes a bintrie generator's progress marker from a + // merkle one. The two markers have incompatible semantics (single-tier + // 32-byte stem||offset vs. two-tier accountHash+storageHash) and the + // loader discards the journal whenever this flag does not match the + // database's mode, forcing a full regeneration. + // + // Marshalled with rlp:"optional" so older v3 journals (which never + // wrote this field) decode cleanly to false — the merkle default. + IsBintrie bool `rlp:"optional"` } // loadGenerator loads the state generation progress marker from the database. -func loadGenerator(db ethdb.KeyValueReader, hash nodeHasher) (*journalGenerator, common.Hash, error) { +// +// isBintrie indicates the database's active scheme. A persisted generator +// from the *other* scheme is discarded outright (and a fresh marker is +// returned) because the marker shapes are mutually unintelligible: a +// merkle marker is two-tier accountHash+storageHash, while a bintrie +// marker is a single 32-byte stem||offset key. Resuming with the wrong +// shape would either skip large stretches of the trie or revisit them. +func loadGenerator(db ethdb.KeyValueReader, hash nodeHasher, isBintrie bool) (*journalGenerator, common.Hash, error) { trieRoot, err := hash(rawdb.ReadAccountTrieNode(db, nil)) if err != nil { return nil, common.Hash{}, err @@ -139,6 +160,15 @@ func loadGenerator(db ethdb.KeyValueReader, hash nodeHasher) (*journalGenerator, log.Info("State snapshot generator is not compatible") return nil, trieRoot, nil } + // Scheme mismatch — drop the journal and force a full regeneration. + // IsBintrie defaults to false on legacy v3 entries (the field is + // rlp:"optional"), which is exactly the right answer for a merkle + // database opened against an old journal. + if generator.IsBintrie != isBintrie { + log.Info("State snapshot generator is for a different scheme, discarding", + "journalIsBintrie", generator.IsBintrie, "dbIsBintrie", isBintrie) + return nil, trieRoot, nil + } // The state snapshot is inconsistent with the trie data and must // be rebuilt. // diff --git a/triedb/pathdb/reader.go b/triedb/pathdb/reader.go index e3cfbcba8a..9bb9b3932a 100644 --- a/triedb/pathdb/reader.go +++ b/triedb/pathdb/reader.go @@ -51,6 +51,26 @@ func (loc nodeLoc) string() string { return fmt.Sprintf("loc: %s, depth: %d", loc.loc, loc.depth) } +// RawStateReader is an extension of database.StateReader that exposes raw +// byte access to flat-state entries without applying any scheme-specific +// decoding (slim-RLP for merkle, no-op for bintrie). The bintrie state +// reader in core/state uses it to fetch the BasicData and CodeHash leaves +// for an account separately and reconstruct a slim account locally. +// +// The merkle pathdb reader implements this interface trivially because +// it already has AccountRLP. Callers should type-assert before using it +// rather than relying on the database.StateReader interface unconditionally. +type RawStateReader interface { + database.StateReader + + // AccountRLP returns the raw flat-state entry stored under the given + // lookup key. Semantics depend on the active codec: + // - merkle: slim-RLP-encoded account bytes + // - bintrie: 32-byte leaf value at the (stem || offset) tuple + // Returns nil if the entry is not present. + AccountRLP(hash common.Hash) ([]byte, error) +} + // reader implements the database.NodeReader interface, providing the functionalities to // retrieve trie nodes by wrapping the internal state layer. type reader struct { @@ -260,7 +280,7 @@ func (r *HistoricalStateReader) AccountRLP(address common.Address) ([]byte, erro // and try to define a low granularity lock if the current approach doesn't // work later. dl := r.db.tree.bottom() - hash := crypto.Keccak256Hash(address.Bytes()) + hash := r.db.flatCodec.AccountKey(address) latest, err := dl.account(hash, 0) if err != nil { return nil, err @@ -310,8 +330,7 @@ func (r *HistoricalStateReader) Storage(address common.Address, key common.Hash) // and try to define a low granularity lock if the current approach doesn't // work later. dl := r.db.tree.bottom() - addrHash := crypto.Keccak256Hash(address.Bytes()) - keyHash := crypto.Keccak256Hash(key.Bytes()) + addrHash, keyHash := r.db.flatCodec.StorageKey(address, key) latest, err := dl.storage(addrHash, keyHash, 0) if err != nil { return nil, err diff --git a/triedb/pathdb/states.go b/triedb/pathdb/states.go index c54d8b1136..a851253670 100644 --- a/triedb/pathdb/states.go +++ b/triedb/pathdb/states.go @@ -25,7 +25,6 @@ import ( "github.com/VictoriaMetrics/fastcache" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/metrics" @@ -424,8 +423,8 @@ func (s *stateSet) decode(r *rlp.Stream) error { } // write flushes state mutations into the provided database batch as a whole. -func (s *stateSet) write(batch ethdb.Batch, genMarker []byte, clean *fastcache.Cache) (int, int) { - return writeStates(batch, genMarker, s.accountData, s.storageData, clean) +func (s *stateSet) write(batch ethdb.Batch, codec flatStateCodec, genMarker []byte, clean *fastcache.Cache) (int, int, error) { + return writeStates(batch, codec, genMarker, s.accountData, s.storageData, clean) } // reset clears all cached state data, including any optional sorted lists that @@ -438,11 +437,13 @@ func (s *stateSet) reset() { s.storageListSorted = make(map[common.Hash][]common.Hash) } -// dbsize returns the approximate size for db write. -func (s *stateSet) dbsize() int { - m := len(s.accountData) * len(rawdb.SnapshotAccountPrefix) +// dbsize returns the approximate size for db write. The codec supplies +// the per-entry on-disk overhead so this calculation tracks the actual +// schema in use (merkle vs. bintrie). +func (s *stateSet) dbsize(codec flatStateCodec) int { + m := len(s.accountData) * codec.AccountPrefixSize() for _, slots := range s.storageData { - m += len(slots) * len(rawdb.SnapshotStoragePrefix) + m += len(slots) * codec.StoragePrefixSize() } return m + int(s.size) } diff --git a/triedb/pathdb/stem_blob.go b/triedb/pathdb/stem_blob.go new file mode 100644 index 0000000000..5ec731b95f --- /dev/null +++ b/triedb/pathdb/stem_blob.go @@ -0,0 +1,353 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "errors" + "fmt" + "math/bits" + + "github.com/ethereum/go-ethereum/common" +) + +// Bintrie stem blob layout +// ------------------------ +// +// The flat-state representation of a bintrie stem packs the populated +// (offset, 32-byte value) pairs at that stem into a single on-disk blob. +// A stem holds up to 256 offsets (per EIP-7864, the full "stem group"), +// but in practice only a handful are populated for any given account +// (BasicData at offset 0, CodeHash at offset 1, a few storage slots, or +// code chunks). A dense encoding would waste 8 KB per stem; this layout +// scales linearly with the number of populated offsets. +// +// Layout: +// +// [ 0 .. 31 ] 32-byte bitmap; bit i set iff offset i has a value +// [32 .. 63 ] first populated offset's 32-byte value +// [64 .. 95 ] second populated offset's 32-byte value +// ... +// [32 + 32*(N-1) .. 32 + 32*N - 1] N-th populated offset's value +// +// where N = popcount(bitmap). Values appear in increasing offset order, +// which is the iteration order of the bitmap bits from least- to +// most-significant byte (byte 0 first, then byte 1, etc.), and within +// each byte from MSB (offset b*8) to LSB (offset b*8+7). +// +// An "absent" offset is one whose bitmap bit is clear; an offset whose +// value is 32 zero bytes is "present with zero value" — that is the +// tombstone convention used by BinaryTrie.DeleteStorage, which writes +// 32 zero bytes to mark a slot as cleared without removing it from the +// underlying StemNode's Values slice. +// +// An empty stem (all bits clear) is represented by a zero-length blob, +// and callers must delete the on-disk key rather than write a zero-length +// value. +const ( + stemBlobBitmapSize = 32 // bytes + stemBlobBitmapBits = stemBlobBitmapSize * 8 // 256 + stemBlobValueSize = common.HashLength // 32 +) + +// stemOffsetMax is the highest valid offset within a bintrie stem. +const stemOffsetMax = stemBlobBitmapBits - 1 // 255 + +var ( + errStemBlobTooShort = errors.New("stem blob shorter than bitmap") + errStemBlobMalformed = errors.New("stem blob length does not match bitmap popcount") + errStemBlobValueOutOfRange = errors.New("stem blob value slice out of range") +) + +// encodeStemBlob encodes a bitmap and a dense values slice (one entry per +// set bit, in ascending offset order) into the wire format described at +// the top of this file. +// +// The caller must ensure len(values) == popcount(bitmap) and that every +// entry in values has len == 32. If every bitmap bit is clear the function +// returns nil so the caller knows to delete the on-disk key. +func encodeStemBlob(bitmap [stemBlobBitmapSize]byte, values [][]byte) ([]byte, error) { + count := bitmapPopcount(bitmap) + if count != len(values) { + return nil, fmt.Errorf("stem blob popcount=%d values=%d: %w", count, len(values), errStemBlobMalformed) + } + if count > stemBlobBitmapBits { + return nil, fmt.Errorf("stem blob value count %d exceeds max %d: %w", count, stemBlobBitmapBits, errStemBlobMalformed) + } + if count == 0 { + return nil, nil + } + out := make([]byte, stemBlobBitmapSize+count*stemBlobValueSize) + copy(out, bitmap[:]) + for i, v := range values { + if len(v) != stemBlobValueSize { + return nil, fmt.Errorf("stem blob value %d has len %d: %w", i, len(v), errStemBlobMalformed) + } + copy(out[stemBlobBitmapSize+i*stemBlobValueSize:], v) + } + return out, nil +} + +// decodeStemBlob parses a raw stem blob into its bitmap and an ordered +// slice of populated 32-byte values. The returned values alias the input +// slice; callers must not retain or mutate them without copying first. +// +// A nil or zero-length blob decodes to a zero bitmap and no values +// (equivalent to "no offsets present"). +func decodeStemBlob(blob []byte) ([stemBlobBitmapSize]byte, [][]byte, error) { + var bitmap [stemBlobBitmapSize]byte + if len(blob) == 0 { + return bitmap, nil, nil + } + if len(blob) < stemBlobBitmapSize { + return bitmap, nil, errStemBlobTooShort + } + copy(bitmap[:], blob[:stemBlobBitmapSize]) + count := bitmapPopcount(bitmap) + expected := stemBlobBitmapSize + count*stemBlobValueSize + if len(blob) != expected { + return bitmap, nil, fmt.Errorf("stem blob len=%d popcount=%d expected=%d: %w", len(blob), count, expected, errStemBlobMalformed) + } + if count == 0 { + return bitmap, nil, nil + } + values := make([][]byte, count) + for i := range values { + start := stemBlobBitmapSize + i*stemBlobValueSize + values[i] = blob[start : start+stemBlobValueSize] + } + return bitmap, values, nil +} + +// extractStemOffset returns the 32-byte value at the given offset within +// a stem blob, or nil if the offset is not present. It does not allocate; +// the returned slice aliases the input blob and must not be mutated. +// +// Returns an error only if the blob itself is malformed. An absent offset +// in a well-formed blob is (nil, nil) — not an error. +func extractStemOffset(blob []byte, offset byte) ([]byte, error) { + if len(blob) == 0 { + return nil, nil + } + if len(blob) < stemBlobBitmapSize { + return nil, errStemBlobTooShort + } + var bitmap [stemBlobBitmapSize]byte + copy(bitmap[:], blob[:stemBlobBitmapSize]) + + // Is the offset present at all? + if !bitmapGet(bitmap, offset) { + return nil, nil + } + // Count how many set bits precede this offset to find the value slot. + idx := bitmapRank(bitmap, offset) + start := stemBlobBitmapSize + idx*stemBlobValueSize + end := start + stemBlobValueSize + if end > len(blob) { + return nil, errStemBlobValueOutOfRange + } + return blob[start:end], nil +} + +// stemBuilder accumulates (offset, value) pairs and produces a stem blob. +// It supports loading an existing blob, setting individual offsets, and +// emitting the final encoded form. +// +// Setting a value of nil or an empty slice clears the corresponding bit +// from the bitmap (the offset becomes "absent"). Setting a non-nil +// 32-byte slice — including 32 zero bytes — marks the offset present +// with that value. This preserves the distinction between absent and +// tombstoned-with-zero used elsewhere in the bintrie code. +// +// A stemBuilder is not safe for concurrent use. +type stemBuilder struct { + bitmap [stemBlobBitmapSize]byte + // values stores the current value at each offset, or nil if absent. + // Using a fixed 256-entry array avoids allocation churn as offsets + // are set and cleared. + values [stemBlobBitmapBits][]byte +} + +// newStemBuilder returns an empty stemBuilder. +func newStemBuilder() *stemBuilder { + return &stemBuilder{} +} + +// loadFromBlob merges the entries of the given stem blob into the builder. +// Existing entries at the same offsets are overwritten. An empty blob is +// a no-op. +func (b *stemBuilder) loadFromBlob(blob []byte) error { + if len(blob) == 0 { + return nil + } + bitmap, values, err := decodeStemBlob(blob) + if err != nil { + return err + } + // Walk the bitmap and copy each populated offset into the builder, + // stepping the values index in sync. + var vi int + for offset := range stemBlobBitmapBits { + if !bitmapGet(bitmap, byte(offset)) { + continue + } + // decodeStemBlob returns slices aliasing the input blob; we take + // an owning copy so the builder survives the caller mutating or + // releasing the source blob. + v := make([]byte, stemBlobValueSize) + copy(v, values[vi]) + b.values[offset] = v + b.bitmap[offset/8] |= 1 << (7 - uint(offset%8)) + vi++ + } + return nil +} + +// set writes value at the given offset. A nil or empty-length value +// clears the offset (bitmap bit cleared). A non-nil 32-byte value sets +// the offset present with that value. Setting with any other length +// panics — callers are expected to always pass 32-byte values. +func (b *stemBuilder) set(offset byte, value []byte) { + if len(value) == 0 { + b.values[offset] = nil + b.bitmap[offset/8] &^= 1 << (7 - uint(offset%8)) + return + } + if len(value) != stemBlobValueSize { + panic(fmt.Sprintf("stemBuilder: value at offset %d has len %d, want %d", offset, len(value), stemBlobValueSize)) + } + // Own the bytes so later caller mutations don't aliasing-surprise us. + owned := make([]byte, stemBlobValueSize) + copy(owned, value) + b.values[offset] = owned + b.bitmap[offset/8] |= 1 << (7 - uint(offset%8)) +} + +// empty reports whether no offsets are currently populated in the builder. +func (b *stemBuilder) empty() bool { + return bitmapPopcount(b.bitmap) == 0 +} + +// encode produces the stem blob encoding for the builder's current state. +// Returns nil for an empty builder so the caller can decide to delete the +// on-disk key rather than write a zero-length value. +func (b *stemBuilder) encode() []byte { + count := bitmapPopcount(b.bitmap) + if count == 0 { + return nil + } + out := make([]byte, stemBlobBitmapSize+count*stemBlobValueSize) + copy(out, b.bitmap[:]) + + // Walk the bitmap in ascending order, copying each populated value. + pos := stemBlobBitmapSize + for offset := range stemBlobBitmapBits { + if b.values[offset] == nil { + continue + } + copy(out[pos:], b.values[offset]) + pos += stemBlobValueSize + } + return out +} + +// reset clears all entries in the builder. +func (b *stemBuilder) reset() { + b.bitmap = [stemBlobBitmapSize]byte{} + b.values = [stemBlobBitmapBits][]byte{} +} + +// toOffsetValues converts the builder's populated entries into a slice +// of (offset, value) pairs suitable for passing to mergeStemBlob. Only +// offsets with non-nil values are emitted; cleared (nil-value) offsets +// are skipped since their absence in the merge input leaves the +// existing blob's value intact — which is the correct behavior for the +// generator's RMW pattern where the builder holds only the new writes. +func (b *stemBuilder) toOffsetValues() []stemOffsetValue { + count := bitmapPopcount(b.bitmap) + if count == 0 { + return nil + } + out := make([]stemOffsetValue, 0, count) + for offset := range stemBlobBitmapBits { + if b.values[offset] != nil { + out = append(out, stemOffsetValue{ + Offset: byte(offset), + Value: b.values[offset], + }) + } + } + return out +} + +// stemOffsetValue is a single (offset, value) pair passed to mergeStemBlob. +// A nil Value clears the offset. +type stemOffsetValue struct { + Offset byte + Value []byte +} + +// mergeStemBlob performs a read-modify-write on a stem blob: it decodes +// the existing blob (if any), applies the given writes in order, and +// returns a freshly encoded blob. Returns (nil, nil) when the result is +// empty — the caller should delete the on-disk key in that case. +func mergeStemBlob(existing []byte, writes []stemOffsetValue) ([]byte, error) { + b := newStemBuilder() + if err := b.loadFromBlob(existing); err != nil { + return nil, err + } + for _, w := range writes { + b.set(w.Offset, w.Value) + } + return b.encode(), nil +} + +// bitmapPopcount returns the number of set bits in the 32-byte bitmap. +func bitmapPopcount(bitmap [stemBlobBitmapSize]byte) int { + var n int + for _, b := range bitmap { + n += bits.OnesCount8(b) + } + return n +} + +// bitmapGet returns whether bit `offset` is set in the bitmap. The +// convention mirrors the bintrie: bit index `offset` lives in byte +// `offset/8`, with the MSB of that byte corresponding to the lowest +// in-byte offset (`offset%8 == 0`). +func bitmapGet(bitmap [stemBlobBitmapSize]byte, offset byte) bool { + return bitmap[offset/8]&(1<<(7-uint(offset%8))) != 0 +} + +// bitmapRank returns the number of set bits that come strictly before +// `offset` (in ascending offset order). The offset itself does not count. +func bitmapRank(bitmap [stemBlobBitmapSize]byte, offset byte) int { + // Full whole bytes before the target. + byteIdx := int(offset) / 8 + var rank int + for i := range byteIdx { + rank += bits.OnesCount8(bitmap[i]) + } + // Bits within the target byte that are above the target's bit. + bitIdx := offset % 8 + if bitIdx > 0 { + // The MSB is offset%8==0. We want bits 0..bitIdx-1 in that layout, + // which are the top bitIdx bits of the byte. + mask := byte(0xFF << (8 - bitIdx)) + rank += bits.OnesCount8(bitmap[byteIdx] & mask) + } + return rank +} diff --git a/triedb/pathdb/stem_blob_edge_test.go b/triedb/pathdb/stem_blob_edge_test.go new file mode 100644 index 0000000000..14733fc4f2 --- /dev/null +++ b/triedb/pathdb/stem_blob_edge_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/trie/bintrie" +) + +// TestStemBlobOffset127_128Boundary tests the bitmap byte boundary +// between offset 127 (last header storage slot) and offset 128 +// (first code chunk). Off-by-one in bitmapRank at this boundary +// would cause extractStemOffset to return the wrong value. +func TestStemBlobOffset127_128Boundary(t *testing.T) { + b := newStemBuilder() + val127 := bytes.Repeat([]byte{0x7F}, stemBlobValueSize) + val128 := bytes.Repeat([]byte{0x80}, stemBlobValueSize) + b.set(127, val127) + b.set(128, val128) + + blob := b.encode() + if blob == nil { + t.Fatal("encode returned nil for 2-offset builder") + } + + got127, err := extractStemOffset(blob, 127) + if err != nil { + t.Fatalf("extract offset 127: %v", err) + } + if !bytes.Equal(got127, val127) { + t.Errorf("offset 127: got %x, want %x", got127, val127) + } + + got128, err := extractStemOffset(blob, 128) + if err != nil { + t.Fatalf("extract offset 128: %v", err) + } + if !bytes.Equal(got128, val128) { + t.Errorf("offset 128: got %x, want %x", got128, val128) + } + + // Verify bitmapRank correctness at the byte boundary. + var bitmap [stemBlobBitmapSize]byte + copy(bitmap[:], blob[:stemBlobBitmapSize]) + if r := bitmapRank(bitmap, 127); r != 0 { + t.Errorf("bitmapRank(127) = %d, want 0", r) + } + if r := bitmapRank(bitmap, 128); r != 1 { + t.Errorf("bitmapRank(128) = %d, want 1", r) + } +} + +// TestStemBlobFull256DeleteMiddle tests a fully-populated stem (all 256 +// offsets) where one offset in the middle is deleted. +func TestStemBlobFull256DeleteMiddle(t *testing.T) { + b := newStemBuilder() + for i := range 256 { + val := bytes.Repeat([]byte{byte(i)}, stemBlobValueSize) + b.set(byte(i), val) + } + if bitmapPopcount(b.bitmap) != 256 { + t.Fatalf("full builder has popcount %d, want 256", bitmapPopcount(b.bitmap)) + } + + b.set(128, nil) // delete the middle + if bitmapPopcount(b.bitmap) != 255 { + t.Fatalf("after delete: popcount %d, want 255", bitmapPopcount(b.bitmap)) + } + + blob := b.encode() + expectedSize := stemBlobBitmapSize + 255*stemBlobValueSize + if len(blob) != expectedSize { + t.Fatalf("blob size %d, want %d", len(blob), expectedSize) + } + + got128, _ := extractStemOffset(blob, 128) + if got128 != nil { + t.Errorf("offset 128 should be absent, got %x", got128) + } + + got127, _ := extractStemOffset(blob, 127) + if !bytes.Equal(got127, bytes.Repeat([]byte{127}, stemBlobValueSize)) { + t.Errorf("offset 127 corrupted after deleting 128") + } + got129, _ := extractStemOffset(blob, 129) + if !bytes.Equal(got129, bytes.Repeat([]byte{129}, stemBlobValueSize)) { + t.Errorf("offset 129 corrupted after deleting 128") + } + got0, _ := extractStemOffset(blob, 0) + if !bytes.Equal(got0, bytes.Repeat([]byte{0}, stemBlobValueSize)) { + t.Errorf("offset 0 corrupted") + } + got255, _ := extractStemOffset(blob, 255) + if !bytes.Equal(got255, bytes.Repeat([]byte{255}, stemBlobValueSize)) { + t.Errorf("offset 255 corrupted") + } +} + +// TestFlushIdempotency verifies that flushing the same data twice +// produces an identical on-disk blob. +func TestFlushIdempotency(t *testing.T) { + codec, db := newTestBintrieCodec(t) + stem := bytes.Repeat([]byte{0x55}, bintrie.StemSize) + mkKey := func(offset byte) common.Hash { + var k common.Hash + copy(k[:bintrie.StemSize], stem) + k[bintrie.StemSize] = offset + return k + } + val := bytes.Repeat([]byte{0xAA}, stemBlobValueSize) + + batch := db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{mkKey(5): val}, nil, nil) + flushBatch(t, batch) + blob1 := rawdb.ReadBinTrieStem(db, stem) + + batch = db.NewBatch() + codec.Flush(batch, nil, map[common.Hash][]byte{mkKey(5): val}, nil, nil) + flushBatch(t, batch) + blob2 := rawdb.ReadBinTrieStem(db, stem) + + if !bytes.Equal(blob1, blob2) { + t.Errorf("Flush is not idempotent: blob1 len=%d blob2 len=%d", len(blob1), len(blob2)) + } +} diff --git a/triedb/pathdb/stem_blob_test.go b/triedb/pathdb/stem_blob_test.go new file mode 100644 index 0000000000..da57cf144f --- /dev/null +++ b/triedb/pathdb/stem_blob_test.go @@ -0,0 +1,361 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "bytes" + "testing" +) + +// mkval constructs a 32-byte value where the first byte is tag and the +// rest are zero. Used to make test assertions easy to read. +func mkval(tag byte) []byte { + v := make([]byte, stemBlobValueSize) + v[0] = tag + return v +} + +// TestStemBlobEmpty verifies that a builder with no entries encodes to +// nil (so callers delete the key) and decodes back to a zero bitmap and +// no values. +func TestStemBlobEmpty(t *testing.T) { + b := newStemBuilder() + if !b.empty() { + t.Fatal("fresh builder should be empty") + } + blob := b.encode() + if blob != nil { + t.Fatalf("empty builder should encode to nil, got %x", blob) + } + + // Decode nil and empty slice both yield an empty result. + for _, input := range [][]byte{nil, {}} { + bitmap, values, err := decodeStemBlob(input) + if err != nil { + t.Fatalf("decode empty: %v", err) + } + if values != nil { + t.Fatalf("decode empty values: got %v, want nil", values) + } + for i, b := range bitmap { + if b != 0 { + t.Fatalf("decode empty bitmap byte %d: got 0x%02x, want 0", i, b) + } + } + } +} + +// TestStemBlobBasicDataAndCodeHash verifies the "account header" encoding +// pattern: offsets 0 and 1 populated. This is the common case for every +// account update. +func TestStemBlobBasicDataAndCodeHash(t *testing.T) { + b := newStemBuilder() + basicData := mkval(0xAA) + codeHash := mkval(0xBB) + b.set(0, basicData) + b.set(1, codeHash) + + if b.empty() { + t.Fatal("builder should not be empty after two sets") + } + + blob := b.encode() + if blob == nil { + t.Fatal("encode should not return nil for populated builder") + } + if got, want := len(blob), stemBlobBitmapSize+2*stemBlobValueSize; got != want { + t.Fatalf("blob length: got %d, want %d", got, want) + } + + // Roundtrip through decodeStemBlob. + bitmap, values, err := decodeStemBlob(blob) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got := bitmapPopcount(bitmap); got != 2 { + t.Fatalf("popcount: got %d, want 2", got) + } + if !bitmapGet(bitmap, 0) || !bitmapGet(bitmap, 1) { + t.Fatalf("bitmap missing offset 0 or 1: %x", bitmap) + } + if !bytes.Equal(values[0], basicData) { + t.Fatalf("value[0]: got %x, want %x", values[0], basicData) + } + if !bytes.Equal(values[1], codeHash) { + t.Fatalf("value[1]: got %x, want %x", values[1], codeHash) + } + + // Point reads via extractStemOffset. + got, err := extractStemOffset(blob, 0) + if err != nil { + t.Fatalf("extract offset 0: %v", err) + } + if !bytes.Equal(got, basicData) { + t.Fatalf("extract 0: got %x, want %x", got, basicData) + } + got, err = extractStemOffset(blob, 1) + if err != nil { + t.Fatalf("extract offset 1: %v", err) + } + if !bytes.Equal(got, codeHash) { + t.Fatalf("extract 1: got %x, want %x", got, codeHash) + } + // An unset offset returns (nil, nil). + got, err = extractStemOffset(blob, 42) + if err != nil { + t.Fatalf("extract unset offset: %v", err) + } + if got != nil { + t.Fatalf("extract unset: got %x, want nil", got) + } +} + +// TestStemBlobAllOffsets verifies that a fully-populated stem (all 256 +// offsets) encodes and decodes correctly. This is the worst-case size. +func TestStemBlobAllOffsets(t *testing.T) { + b := newStemBuilder() + for i := range stemBlobBitmapBits { + b.set(byte(i), mkval(byte(i))) + } + blob := b.encode() + expectedLen := stemBlobBitmapSize + stemBlobBitmapBits*stemBlobValueSize + if len(blob) != expectedLen { + t.Fatalf("blob length: got %d, want %d", len(blob), expectedLen) + } + + bitmap, _, err := decodeStemBlob(blob) + if err != nil { + t.Fatalf("decode: %v", err) + } + if bitmapPopcount(bitmap) != stemBlobBitmapBits { + t.Fatalf("popcount: got %d, want %d", bitmapPopcount(bitmap), stemBlobBitmapBits) + } + for i := range stemBlobBitmapBits { + got, err := extractStemOffset(blob, byte(i)) + if err != nil { + t.Fatalf("extract %d: %v", i, err) + } + if got[0] != byte(i) { + t.Fatalf("extract %d: tag 0x%02x, want 0x%02x", i, got[0], byte(i)) + } + } +} + +// TestStemBlobSparseHighOffsets verifies that non-contiguous offsets +// (typical for storage slots scattered across the stem) round-trip +// correctly. +func TestStemBlobSparseHighOffsets(t *testing.T) { + b := newStemBuilder() + offsets := []byte{3, 17, 64, 127, 128, 200, 255} + for _, o := range offsets { + b.set(o, mkval(o)) + } + blob := b.encode() + if len(blob) != stemBlobBitmapSize+len(offsets)*stemBlobValueSize { + t.Fatalf("unexpected blob length: %d", len(blob)) + } + + // Extract each and verify, including some absent offsets in between. + for _, o := range offsets { + got, err := extractStemOffset(blob, o) + if err != nil { + t.Fatalf("extract %d: %v", o, err) + } + if got[0] != o { + t.Fatalf("extract %d: tag 0x%02x, want 0x%02x", o, got[0], o) + } + } + // Spot-check absent offsets between populated ones. + for _, o := range []byte{0, 1, 2, 4, 18, 63, 126, 129, 199, 254} { + got, err := extractStemOffset(blob, o) + if err != nil { + t.Fatalf("extract absent %d: %v", o, err) + } + if got != nil { + t.Fatalf("extract absent %d: got %x, want nil", o, got) + } + } +} + +// TestStemBlobSetClearRoundtrip verifies that setting and then clearing +// an offset leaves the builder in the same state as never setting it. +func TestStemBlobSetClearRoundtrip(t *testing.T) { + b := newStemBuilder() + b.set(5, mkval(0xCD)) + if b.empty() { + t.Fatal("should not be empty after set") + } + b.set(5, nil) + if !b.empty() { + t.Fatal("should be empty after clearing the only entry") + } + if blob := b.encode(); blob != nil { + t.Fatalf("encode after clear: got %x, want nil", blob) + } +} + +// TestStemBlobLoadFromBlob verifies that an existing blob can be loaded +// into a fresh builder for read-modify-write semantics. +func TestStemBlobLoadFromBlob(t *testing.T) { + // Build an initial blob with two entries. + b1 := newStemBuilder() + b1.set(0, mkval(0x11)) + b1.set(64, mkval(0x22)) + initial := b1.encode() + + // Load into a fresh builder, modify, encode. + b2 := newStemBuilder() + if err := b2.loadFromBlob(initial); err != nil { + t.Fatalf("loadFromBlob: %v", err) + } + b2.set(0, mkval(0x33)) // overwrite offset 0 + b2.set(64, nil) // clear offset 64 + b2.set(128, mkval(0x44)) // add offset 128 + updated := b2.encode() + + // Offset 0 should have the new value. + got, err := extractStemOffset(updated, 0) + if err != nil || got == nil || got[0] != 0x33 { + t.Fatalf("offset 0 after update: got %x err=%v, want tag 0x33", got, err) + } + // Offset 64 should be absent. + got, err = extractStemOffset(updated, 64) + if err != nil { + t.Fatalf("offset 64 after clear: %v", err) + } + if got != nil { + t.Fatalf("offset 64 after clear: got %x, want nil", got) + } + // Offset 128 should have the new value. + got, err = extractStemOffset(updated, 128) + if err != nil || got == nil || got[0] != 0x44 { + t.Fatalf("offset 128 after update: got %x err=%v, want tag 0x44", got, err) + } +} + +// TestStemBlobMergeHelper verifies mergeStemBlob: read existing, apply +// writes, produce new blob in one call. +func TestStemBlobMergeHelper(t *testing.T) { + // Start with a blob containing offset 0. + b := newStemBuilder() + b.set(0, mkval(0x01)) + initial := b.encode() + + // Merge: overwrite 0, add 1, clear a non-existent offset (no-op). + result, err := mergeStemBlob(initial, []stemOffsetValue{ + {Offset: 0, Value: mkval(0x02)}, + {Offset: 1, Value: mkval(0x03)}, + {Offset: 100, Value: nil}, + }) + if err != nil { + t.Fatalf("merge: %v", err) + } + got, _ := extractStemOffset(result, 0) + if got == nil || got[0] != 0x02 { + t.Fatalf("merged offset 0: got %x, want tag 0x02", got) + } + got, _ = extractStemOffset(result, 1) + if got == nil || got[0] != 0x03 { + t.Fatalf("merged offset 1: got %x, want tag 0x03", got) + } +} + +// TestStemBlobMergeToEmpty verifies that clearing every populated entry +// via merge returns a nil blob (so the caller deletes the key). +func TestStemBlobMergeToEmpty(t *testing.T) { + b := newStemBuilder() + b.set(0, mkval(0x01)) + b.set(5, mkval(0x02)) + initial := b.encode() + + result, err := mergeStemBlob(initial, []stemOffsetValue{ + {Offset: 0, Value: nil}, + {Offset: 5, Value: nil}, + }) + if err != nil { + t.Fatalf("merge to empty: %v", err) + } + if result != nil { + t.Fatalf("merge to empty: got %x, want nil", result) + } +} + +// TestStemBlobTombstoneZeroBytes verifies that a 32-byte zero value is +// preserved as "present with zero value" — not confused with "absent". +// DeleteStorage uses this convention. +func TestStemBlobTombstoneZeroBytes(t *testing.T) { + b := newStemBuilder() + zeros := make([]byte, stemBlobValueSize) + b.set(64, zeros) + if b.empty() { + t.Fatal("zero-value entry should count as populated") + } + blob := b.encode() + got, err := extractStemOffset(blob, 64) + if err != nil { + t.Fatalf("extract tombstone: %v", err) + } + if !bytes.Equal(got, zeros) { + t.Fatalf("extract tombstone: got %x, want 32 zero bytes", got) + } +} + +// TestStemBlobMalformedInput verifies that decodeStemBlob detects +// malformed blobs with wrong lengths. +func TestStemBlobMalformedInput(t *testing.T) { + // Shorter than bitmap. + if _, _, err := decodeStemBlob(make([]byte, 10)); err == nil { + t.Fatal("expected error for too-short blob") + } + // Bitmap claims 2 entries but blob only has room for 1. + var bitmap [stemBlobBitmapSize]byte + bitmap[0] = 0xC0 // bits 0 and 1 set → 2 entries + short := make([]byte, stemBlobBitmapSize+stemBlobValueSize) + copy(short, bitmap[:]) + if _, _, err := decodeStemBlob(short); err == nil { + t.Fatal("expected error for blob shorter than bitmap implies") + } +} + +// TestBitmapRank sanity-checks the bit-to-index helper used by +// extractStemOffset for single-offset reads. +func TestBitmapRank(t *testing.T) { + var bitmap [stemBlobBitmapSize]byte + // Set bits at offsets 0, 1, 5, 64, 200. + for _, o := range []byte{0, 1, 5, 64, 200} { + bitmap[o/8] |= 1 << (7 - uint(o%8)) + } + cases := []struct { + offset byte + want int + }{ + {0, 0}, // first set bit is at index 0 + {1, 1}, // second set bit + {5, 2}, // third + {64, 3}, // fourth + {200, 4}, // fifth + // For an unset offset, rank returns the number of set bits < it. + {2, 2}, // bits 0 and 1 are before 2 + {100, 4}, // bits 0,1,5,64 are before 100 + {255, 5}, // all five bits are before 255 + } + for _, c := range cases { + if got := bitmapRank(bitmap, c.offset); got != c.want { + t.Errorf("bitmapRank(%d) = %d, want %d", c.offset, got, c.want) + } + } +}