diff --git a/cmd/geth/chaincmd.go b/cmd/geth/chaincmd.go
index e535d7d892..55316c14ab 100644
--- a/cmd/geth/chaincmd.go
+++ b/cmd/geth/chaincmd.go
@@ -296,7 +296,7 @@ func initGenesis(ctx *cli.Context) error {
triedb := utils.MakeTrieDatabase(ctx, stack, chaindb, ctx.Bool(utils.CachePreimagesFlag.Name), false, genesis.IsVerkle())
defer triedb.Close()
- _, hash, compatErr, err := core.SetupGenesisBlockWithOverride(chaindb, triedb, genesis, &overrides)
+ _, hash, compatErr, err := core.SetupGenesisBlockWithOverride(chaindb, triedb, genesis, &overrides, nil)
if err != nil {
utils.Fatalf("Failed to write genesis block: %v", err)
}
diff --git a/core/blockchain.go b/core/blockchain.go
index 858eceb630..144b59ef89 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -366,7 +366,7 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine,
// yet. The corresponding chain config will be returned, either from the
// provided genesis or from the locally stored configuration if the genesis
// has already been initialized.
- chainConfig, genesisHash, compatErr, err := SetupGenesisBlockWithOverride(db, triedb, genesis, cfg.Overrides)
+ chainConfig, genesisHash, compatErr, err := SetupGenesisBlockWithOverride(db, triedb, genesis, cfg.Overrides, cfg.VmConfig.Tracer)
if err != nil {
return nil, err
}
@@ -1649,15 +1649,31 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.
log.Debug("Committed block data", "size", common.StorageSize(batch.ValueSize()), "elapsed", common.PrettyDuration(time.Since(start)))
var (
- err error
- root common.Hash
- isEIP158 = bc.chainConfig.IsEIP158(block.Number())
- isCancun = bc.chainConfig.IsCancun(block.Number(), block.Time())
+ err error
+ root common.Hash
+ isEIP158 = bc.chainConfig.IsEIP158(block.Number())
+ isCancun = bc.chainConfig.IsCancun(block.Number(), block.Time())
+ hasStateHook = bc.logger != nil && bc.logger.OnStateUpdate != nil
+ hasStateSizer = bc.stateSizer != nil
+ needStateUpdate = hasStateHook || hasStateSizer
)
- if bc.stateSizer == nil {
- root, err = statedb.Commit(block.NumberU64(), isEIP158, isCancun)
+ if needStateUpdate {
+ r, update, err := statedb.CommitWithUpdate(block.NumberU64(), isEIP158, isCancun)
+ if err == nil && update != nil {
+ if hasStateHook {
+ trUpdate, err := update.ToTracingUpdate()
+ if err != nil {
+ return err
+ }
+ bc.logger.OnStateUpdate(trUpdate)
+ }
+ if hasStateSizer {
+ bc.stateSizer.Notify(update)
+ }
+ }
+ root = r
} else {
- root, err = statedb.CommitAndTrack(block.NumberU64(), isEIP158, isCancun, bc.stateSizer)
+ root, err = statedb.Commit(block.NumberU64(), isEIP158, isCancun)
}
if err != nil {
return err
diff --git a/core/chain_makers.go b/core/chain_makers.go
index a1e07becba..7ce86b14e9 100644
--- a/core/chain_makers.go
+++ b/core/chain_makers.go
@@ -481,7 +481,7 @@ func GenerateChainWithGenesis(genesis *Genesis, engine consensus.Engine, n int,
}
triedb := triedb.NewDatabase(db, triedbConfig)
defer triedb.Close()
- _, err := genesis.Commit(db, triedb)
+ _, err := genesis.Commit(db, triedb, nil)
if err != nil {
panic(err)
}
diff --git a/core/genesis.go b/core/genesis.go
index 7d640c8cae..983ad4c3cb 100644
--- a/core/genesis.go
+++ b/core/genesis.go
@@ -164,7 +164,7 @@ func hashAlloc(ga *types.GenesisAlloc, isVerkle bool) (common.Hash, error) {
// flushAlloc is very similar with hash, but the main difference is all the
// generated states will be persisted into the given database.
-func flushAlloc(ga *types.GenesisAlloc, triedb *triedb.Database) (common.Hash, error) {
+func flushAlloc(ga *types.GenesisAlloc, triedb *triedb.Database, tracer *tracing.Hooks) (common.Hash, error) {
emptyRoot := types.EmptyRootHash
if triedb.IsVerkle() {
emptyRoot = types.EmptyVerkleHash
@@ -185,10 +185,26 @@ func flushAlloc(ga *types.GenesisAlloc, triedb *triedb.Database) (common.Hash, e
statedb.SetState(addr, key, value)
}
}
- root, err := statedb.Commit(0, false, false)
- if err != nil {
- return common.Hash{}, err
+
+ var root common.Hash
+ if tracer != nil && tracer.OnStateUpdate != nil {
+ r, update, err := statedb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ return common.Hash{}, err
+ }
+ trUpdate, err := update.ToTracingUpdate()
+ if err != nil {
+ return common.Hash{}, err
+ }
+ tracer.OnStateUpdate(trUpdate)
+ root = r
+ } else {
+ root, err = statedb.Commit(0, false, false)
+ if err != nil {
+ return common.Hash{}, err
+ }
}
+
// Commit newly generated states into disk if it's not empty.
if root != emptyRoot {
if err := triedb.Commit(root, true); err != nil {
@@ -296,10 +312,10 @@ func (o *ChainOverrides) apply(cfg *params.ChainConfig) error {
// specify a fork block below the local head block). In case of a conflict, the
// error is a *params.ConfigCompatError and the new, unwritten config is returned.
func SetupGenesisBlock(db ethdb.Database, triedb *triedb.Database, genesis *Genesis) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
- return SetupGenesisBlockWithOverride(db, triedb, genesis, nil)
+ return SetupGenesisBlockWithOverride(db, triedb, genesis, nil, nil)
}
-func SetupGenesisBlockWithOverride(db ethdb.Database, triedb *triedb.Database, genesis *Genesis, overrides *ChainOverrides) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
+func SetupGenesisBlockWithOverride(db ethdb.Database, triedb *triedb.Database, genesis *Genesis, overrides *ChainOverrides, tracer *tracing.Hooks) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
// Copy the genesis, so we can operate on a copy.
genesis = genesis.copy()
// Sanitize the supplied genesis, ensuring it has the associated chain
@@ -320,7 +336,7 @@ func SetupGenesisBlockWithOverride(db ethdb.Database, triedb *triedb.Database, g
return nil, common.Hash{}, nil, err
}
- block, err := genesis.Commit(db, triedb)
+ block, err := genesis.Commit(db, triedb, tracer)
if err != nil {
return nil, common.Hash{}, nil, err
}
@@ -348,7 +364,7 @@ func SetupGenesisBlockWithOverride(db ethdb.Database, triedb *triedb.Database, g
if hash := genesis.ToBlock().Hash(); hash != ghash {
return nil, common.Hash{}, nil, &GenesisMismatchError{ghash, hash}
}
- block, err := genesis.Commit(db, triedb)
+ block, err := genesis.Commit(db, triedb, tracer)
if err != nil {
return nil, common.Hash{}, nil, err
}
@@ -537,7 +553,7 @@ func (g *Genesis) toBlockWithRoot(root common.Hash) *types.Block {
// Commit writes the block and state of a genesis specification to the database.
// The block is committed as the canonical head block.
-func (g *Genesis) Commit(db ethdb.Database, triedb *triedb.Database) (*types.Block, error) {
+func (g *Genesis) Commit(db ethdb.Database, triedb *triedb.Database, tracer *tracing.Hooks) (*types.Block, error) {
if g.Number != 0 {
return nil, errors.New("can't commit genesis block with number > 0")
}
@@ -552,7 +568,7 @@ func (g *Genesis) Commit(db ethdb.Database, triedb *triedb.Database) (*types.Blo
return nil, errors.New("can't start clique chain without signers")
}
// flush the data to disk and compute the state root
- root, err := flushAlloc(&g.Alloc, triedb)
+ root, err := flushAlloc(&g.Alloc, triedb, tracer)
if err != nil {
return nil, err
}
@@ -578,7 +594,7 @@ func (g *Genesis) Commit(db ethdb.Database, triedb *triedb.Database) (*types.Blo
// MustCommit writes the genesis block and state to db, panicking on error.
// The block is committed as the canonical head block.
func (g *Genesis) MustCommit(db ethdb.Database, triedb *triedb.Database) *types.Block {
- block, err := g.Commit(db, triedb)
+ block, err := g.Commit(db, triedb, nil)
if err != nil {
panic(err)
}
diff --git a/core/genesis_test.go b/core/genesis_test.go
index 1ed475695d..821c71feb9 100644
--- a/core/genesis_test.go
+++ b/core/genesis_test.go
@@ -88,7 +88,7 @@ func testSetupGenesis(t *testing.T, scheme string) {
name: "custom block in DB, genesis == nil",
fn: func(db ethdb.Database) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
tdb := triedb.NewDatabase(db, newDbConfig(scheme))
- customg.Commit(db, tdb)
+ customg.Commit(db, tdb, nil)
return SetupGenesisBlock(db, tdb, nil)
},
wantHash: customghash,
@@ -98,7 +98,7 @@ func testSetupGenesis(t *testing.T, scheme string) {
name: "custom block in DB, genesis == sepolia",
fn: func(db ethdb.Database) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
tdb := triedb.NewDatabase(db, newDbConfig(scheme))
- customg.Commit(db, tdb)
+ customg.Commit(db, tdb, nil)
return SetupGenesisBlock(db, tdb, DefaultSepoliaGenesisBlock())
},
wantErr: &GenesisMismatchError{Stored: customghash, New: params.SepoliaGenesisHash},
@@ -107,7 +107,7 @@ func testSetupGenesis(t *testing.T, scheme string) {
name: "custom block in DB, genesis == hoodi",
fn: func(db ethdb.Database) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
tdb := triedb.NewDatabase(db, newDbConfig(scheme))
- customg.Commit(db, tdb)
+ customg.Commit(db, tdb, nil)
return SetupGenesisBlock(db, tdb, DefaultHoodiGenesisBlock())
},
wantErr: &GenesisMismatchError{Stored: customghash, New: params.HoodiGenesisHash},
@@ -116,7 +116,7 @@ func testSetupGenesis(t *testing.T, scheme string) {
name: "compatible config in DB",
fn: func(db ethdb.Database) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
tdb := triedb.NewDatabase(db, newDbConfig(scheme))
- oldcustomg.Commit(db, tdb)
+ oldcustomg.Commit(db, tdb, nil)
return SetupGenesisBlock(db, tdb, &customg)
},
wantHash: customghash,
@@ -128,7 +128,7 @@ func testSetupGenesis(t *testing.T, scheme string) {
// Commit the 'old' genesis block with Homestead transition at #2.
// Advance to block #4, past the homestead transition block of customg.
tdb := triedb.NewDatabase(db, newDbConfig(scheme))
- oldcustomg.Commit(db, tdb)
+ oldcustomg.Commit(db, tdb, nil)
bc, _ := NewBlockChain(db, &oldcustomg, ethash.NewFullFaker(), DefaultConfig().WithStateScheme(scheme))
defer bc.Stop()
diff --git a/core/headerchain_test.go b/core/headerchain_test.go
index b51fb8f226..dba04e2cf2 100644
--- a/core/headerchain_test.go
+++ b/core/headerchain_test.go
@@ -69,7 +69,7 @@ func TestHeaderInsertion(t *testing.T) {
db = rawdb.NewMemoryDatabase()
gspec = &Genesis{BaseFee: big.NewInt(params.InitialBaseFee), Config: params.AllEthashProtocolChanges}
)
- gspec.Commit(db, triedb.NewDatabase(db, nil))
+ gspec.Commit(db, triedb.NewDatabase(db, nil), nil)
hc, err := NewHeaderChain(db, gspec.Config, ethash.NewFaker(), func() bool { return false })
if err != nil {
t.Fatal(err)
diff --git a/core/state/statedb.go b/core/state/statedb.go
index 7c6b8bbdfc..bb51ff25ac 100644
--- a/core/state/statedb.go
+++ b/core/state/statedb.go
@@ -1390,14 +1390,14 @@ func (s *StateDB) Commit(block uint64, deleteEmptyObjects bool, noStorageWiping
return ret.root, nil
}
-// CommitAndTrack writes the state mutations and notifies the size tracker of the state changes.
-func (s *StateDB) CommitAndTrack(block uint64, deleteEmptyObjects bool, noStorageWiping bool, sizer *SizeTracker) (common.Hash, error) {
+// CommitWithUpdate writes the state mutations and returns the state update for
+// external processing (e.g., live tracing hooks or size tracker).
+func (s *StateDB) CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (common.Hash, *stateUpdate, error) {
ret, err := s.commitAndFlush(block, deleteEmptyObjects, noStorageWiping, true)
if err != nil {
- return common.Hash{}, err
+ return common.Hash{}, nil, err
}
- sizer.Notify(ret)
- return ret.root, nil
+ return ret.root, ret, nil
}
// Prepare handles the preparatory steps for executing a state transition with.
diff --git a/core/state/stateupdate.go b/core/state/stateupdate.go
index c043166cf2..fe11f5efdb 100644
--- a/core/state/stateupdate.go
+++ b/core/state/stateupdate.go
@@ -17,9 +17,14 @@
package state
import (
+ "fmt"
"maps"
"github.com/ethereum/go-ethereum/common"
+ "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/trienode"
"github.com/ethereum/go-ethereum/triedb"
)
@@ -210,3 +215,131 @@ func (sc *stateUpdate) markCodeExistence(reader ContractCodeReader) {
code.exists = res
}
}
+
+// ToTracingUpdate converts the internal stateUpdate to an exported tracing.StateUpdate.
+func (sc *stateUpdate) ToTracingUpdate() (*tracing.StateUpdate, error) {
+ update := &tracing.StateUpdate{
+ OriginRoot: sc.originRoot,
+ Root: sc.root,
+ BlockNumber: sc.blockNumber,
+ AccountChanges: make(map[common.Address]*tracing.AccountChange, len(sc.accountsOrigin)),
+ StorageChanges: make(map[common.Address]map[common.Hash]*tracing.StorageChange),
+ CodeChanges: make(map[common.Address]*tracing.CodeChange, len(sc.codes)),
+ TrieChanges: make(map[common.Hash]map[string]*tracing.TrieNodeChange),
+ }
+
+ for addr, prev := range sc.accountsOrigin {
+ addrHash := crypto.Keccak256Hash(addr.Bytes())
+ new, exists := sc.accounts[addrHash]
+ if !exists {
+ return nil, fmt.Errorf("account %x not found", addr)
+ }
+
+ change := &tracing.AccountChange{}
+
+ if len(prev) > 0 {
+ if acct, err := types.FullAccount(prev); err == nil {
+ change.Prev = &types.StateAccount{
+ Nonce: acct.Nonce,
+ Balance: acct.Balance,
+ Root: acct.Root,
+ CodeHash: acct.CodeHash,
+ }
+ }
+ }
+
+ if len(new) > 0 {
+ if acct, err := types.FullAccount(new); err == nil {
+ change.New = &types.StateAccount{
+ Nonce: acct.Nonce,
+ Balance: acct.Balance,
+ Root: acct.Root,
+ CodeHash: acct.CodeHash,
+ }
+ }
+ }
+
+ update.AccountChanges[addr] = change
+ }
+
+ for addr, slots := range sc.storagesOrigin {
+ addrHash := crypto.Keccak256Hash(addr.Bytes())
+ subset, exists := sc.storages[addrHash]
+ if !exists {
+ return nil, fmt.Errorf("storage %x not found", addr)
+ }
+
+ storageMap := make(map[common.Hash]*tracing.StorageChange, len(slots))
+ for key, encPrev := range slots {
+ // Get new value - handle both raw and hashed key formats
+ var (
+ exists bool
+ encNew []byte
+ decPrev []byte
+ decNew []byte
+ err error
+ )
+ if sc.rawStorageKey {
+ encNew, exists = subset[crypto.Keccak256Hash(key.Bytes())]
+ } else {
+ encNew, 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)
+ }
+ }
+
+ storageMap[key] = &tracing.StorageChange{
+ Prev: common.BytesToHash(decPrev),
+ New: common.BytesToHash(decNew),
+ }
+ }
+ update.StorageChanges[addr] = storageMap
+ }
+
+ for addr, code := range sc.codes {
+ update.CodeChanges[addr] = &tracing.CodeChange{
+ Hash: code.hash,
+ Code: code.blob,
+ Exists: code.exists,
+ }
+ }
+
+ if sc.nodes != nil {
+ for owner, subset := range sc.nodes.Sets {
+ nodeMap := make(map[string]*tracing.TrieNodeChange, len(subset.Origins))
+ for path, oldNode := range subset.Origins {
+ newNode, exists := subset.Nodes[path]
+ if !exists {
+ return nil, fmt.Errorf("node %x-%v not found", owner, path)
+ }
+ nodeMap[path] = &tracing.TrieNodeChange{
+ Prev: &trienode.Node{
+ Hash: crypto.Keccak256Hash(oldNode),
+ Blob: oldNode,
+ },
+ New: &trienode.Node{
+ Hash: newNode.Hash,
+ Blob: newNode.Blob,
+ },
+ }
+ }
+ update.TrieChanges[owner] = nodeMap
+ }
+ }
+
+ return update, nil
+}
diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go
index 8e50dc3d8f..4feb9b1164 100644
--- a/core/tracing/hooks.go
+++ b/core/tracing/hooks.go
@@ -30,6 +30,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/trie/trienode"
"github.com/holiman/uint256"
)
@@ -75,6 +76,51 @@ type BlockEvent struct {
Safe *types.Header
}
+// StateUpdate represents the state mutations resulting from block execution.
+// It provides access to account changes, storage changes, and contract code
+// deployments with both previous and new values.
+type StateUpdate struct {
+ OriginRoot common.Hash // State root before the update
+ Root common.Hash // State root after the update
+ BlockNumber uint64
+
+ // AccountChanges contains all account state changes keyed by address.
+ AccountChanges map[common.Address]*AccountChange
+
+ // StorageChanges contains all storage slot changes keyed by address and storage slot key.
+ StorageChanges map[common.Address]map[common.Hash]*StorageChange
+
+ // CodeChanges contains all contract code changes keyed by address.
+ CodeChanges map[common.Address]*CodeChange
+
+ // TrieChanges contains trie node mutations keyed by address hash and trie node path.
+ TrieChanges map[common.Hash]map[string]*TrieNodeChange
+}
+
+// AccountChange represents a change to an account's state.
+type AccountChange struct {
+ Prev *types.StateAccount // nil if account was created
+ New *types.StateAccount // nil if account was deleted
+}
+
+// StorageChange represents a change to a storage slot.
+type StorageChange struct {
+ Prev common.Hash // previous value (zero if slot was created)
+ New common.Hash // new value (zero if slot was deleted)
+}
+
+// CodeChange represents a contract code deployment or change.
+type CodeChange struct {
+ Hash common.Hash
+ Code []byte
+ Exists bool // true if the code already deployed before
+}
+
+type TrieNodeChange struct {
+ Prev *trienode.Node
+ New *trienode.Node
+}
+
type (
/*
- VM events -
@@ -161,6 +207,11 @@ type (
// beacon block root.
OnSystemCallEndHook = func()
+ // StateUpdateHook is called after state is committed for a block.
+ // It provides access to the complete state mutations including account changes,
+ // storage changes, trie node mutations, and contract code deployments.
+ StateUpdateHook = func(update *StateUpdate)
+
/*
- State events -
*/
@@ -209,6 +260,7 @@ type Hooks struct {
OnSystemCallStart OnSystemCallStartHook
OnSystemCallStartV2 OnSystemCallStartHookV2
OnSystemCallEnd OnSystemCallEndHook
+ OnStateUpdate StateUpdateHook
// State events
OnBalanceChange BalanceChangeHook
OnNonceChange NonceChangeHook
diff --git a/eth/filters/filter_system_test.go b/eth/filters/filter_system_test.go
index e5a1a2b25f..6f97d5b664 100644
--- a/eth/filters/filter_system_test.go
+++ b/eth/filters/filter_system_test.go
@@ -546,7 +546,7 @@ func TestExceedLogQueryLimit(t *testing.T) {
}
)
- _, err := gspec.Commit(db, triedb.NewDatabase(db, nil))
+ _, err := gspec.Commit(db, triedb.NewDatabase(db, nil), nil)
if err != nil {
t.Fatal(err)
}
diff --git a/eth/filters/filter_test.go b/eth/filters/filter_test.go
index edec3e027f..f44ada20b1 100644
--- a/eth/filters/filter_test.go
+++ b/eth/filters/filter_test.go
@@ -205,7 +205,7 @@ func testFilters(t *testing.T, history uint64, noHistory bool) {
// Hack: GenerateChainWithGenesis creates a new db.
// Commit the genesis manually and use GenerateChain.
- _, err = gspec.Commit(db, triedb.NewDatabase(db, nil))
+ _, err = gspec.Commit(db, triedb.NewDatabase(db, nil), nil)
if err != nil {
t.Fatal(err)
}
@@ -426,7 +426,7 @@ func TestRangeLogs(t *testing.T) {
BaseFee: big.NewInt(params.InitialBaseFee),
}
)
- _, err := gspec.Commit(db, triedb.NewDatabase(db, nil))
+ _, err := gspec.Commit(db, triedb.NewDatabase(db, nil), nil)
if err != nil {
t.Fatal(err)
}
diff --git a/eth/tracers/live/statesize.go b/eth/tracers/live/statesize.go
new file mode 100644
index 0000000000..42464900ad
--- /dev/null
+++ b/eth/tracers/live/statesize.go
@@ -0,0 +1,715 @@
+// Copyright 2025 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 live
+
+import (
+ "bufio"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "sync"
+
+ "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/eth/tracers"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/rlp"
+)
+
+func init() {
+ tracers.LiveDirectory.Register("statesize", newStateSizeTracer)
+}
+
+// Database key size constants matching core/state/state_sizer.go
+var (
+ accountKeySize = int64(len(rawdb.SnapshotAccountPrefix) + common.HashLength)
+ storageKeySize = int64(len(rawdb.SnapshotStoragePrefix) + common.HashLength*2)
+ accountTrienodePrefixSize = int64(len(rawdb.TrieNodeAccountPrefix))
+ storageTrienodePrefixSize = int64(len(rawdb.TrieNodeStoragePrefix) + common.HashLength)
+ codeKeySize = int64(len(rawdb.CodePrefix) + common.HashLength)
+)
+
+// CSV column headers (cumulative only)
+var csvHeaders = []string{
+ "block_number",
+ "root",
+ "parent_root",
+ "accounts",
+ "account_bytes",
+ "storages",
+ "storage_bytes",
+ "account_trienodes",
+ "account_trienode_bytes",
+ "storage_trienodes",
+ "storage_trienode_bytes",
+ "codes",
+ "code_bytes",
+}
+
+// depthCSVHeaders generates headers for the depth CSV files.
+// Format: block_number, root, parent_root, total_nodes, account_depth_0..64, storage_depth_0..64
+func depthCSVHeaders() []string {
+ headers := make([]string, 0, 4+65+65)
+ headers = append(headers, "block_number", "root", "parent_root", "total_nodes")
+ for i := 0; i <= 64; i++ {
+ headers = append(headers, fmt.Sprintf("account_depth_%d", i))
+ }
+ for i := 0; i <= 64; i++ {
+ headers = append(headers, fmt.Sprintf("storage_depth_%d", i))
+ }
+ return headers
+}
+
+// stateSizeStats represents cumulative state size statistics.
+type stateSizeStats struct {
+ Accounts int64
+ AccountBytes int64
+ Storages int64
+ StorageBytes int64
+ AccountTrienodes int64
+ AccountTrienodeBytes int64
+ StorageTrienodes int64
+ StorageTrienodeBytes int64
+ Codes int64
+ CodeBytes int64
+}
+
+// stateSizeRecord represents a single CSV record with cumulative stats.
+type stateSizeRecord struct {
+ BlockNumber uint64
+ Root common.Hash
+ ParentRoot common.Hash
+ Stats stateSizeStats
+}
+
+type stateSizeTracer struct {
+ mu sync.Mutex
+ file *os.File
+ writer *csv.Writer
+ filePath string
+
+ // Depth tracking - separate files for created and deleted nodes
+ depthCreatedFile *os.File
+ depthCreatedWriter *csv.Writer
+ depthCreatedFilePath string
+
+ depthDeletedFile *os.File
+ depthDeletedWriter *csv.Writer
+ depthDeletedFilePath string
+
+ // Map from state root to cumulative stats (for handling forks)
+ stats map[common.Hash]stateSizeStats
+}
+
+type stateSizeTracerConfig struct {
+ Path string `json:"path"` // Path to the directory where the tracer logs will be stored
+}
+
+func newStateSizeTracer(cfg json.RawMessage) (*tracing.Hooks, error) {
+ var config stateSizeTracerConfig
+ if err := json.Unmarshal(cfg, &config); err != nil {
+ return nil, fmt.Errorf("failed to parse config: %v", err)
+ }
+ if config.Path == "" {
+ return nil, errors.New("statesize tracer output path is required")
+ }
+
+ filePath := filepath.Join(config.Path, "statesize.csv")
+ depthCreatedFilePath := filepath.Join(config.Path, "statesize_depth_created.csv")
+ depthDeletedFilePath := filepath.Join(config.Path, "statesize_depth_deleted.csv")
+
+ // Ensure the directory exists
+ if err := os.MkdirAll(config.Path, 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create statesize directory: %v", err)
+ }
+
+ t := &stateSizeTracer{
+ filePath: filePath,
+ depthCreatedFilePath: depthCreatedFilePath,
+ depthDeletedFilePath: depthDeletedFilePath,
+ stats: make(map[common.Hash]stateSizeStats),
+ }
+
+ // Load existing data if file exists
+ if err := t.loadExisting(); err != nil {
+ return nil, fmt.Errorf("failed to load existing statesize data: %v", err)
+ }
+
+ // Open statesize file for appending
+ file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open statesize file: %v", err)
+ }
+ t.file = file
+ t.writer = csv.NewWriter(file)
+
+ // Write header if file is new (empty)
+ info, err := file.Stat()
+ if err != nil {
+ file.Close()
+ return nil, fmt.Errorf("failed to stat statesize file: %v", err)
+ }
+ if info.Size() == 0 {
+ if err := t.writer.Write(csvHeaders); err != nil {
+ file.Close()
+ return nil, fmt.Errorf("failed to write CSV headers: %v", err)
+ }
+ t.writer.Flush()
+ }
+
+ // Open depth created file for appending
+ depthCreatedFile, err := os.OpenFile(depthCreatedFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ file.Close()
+ return nil, fmt.Errorf("failed to open depth created file: %v", err)
+ }
+ t.depthCreatedFile = depthCreatedFile
+ t.depthCreatedWriter = csv.NewWriter(depthCreatedFile)
+
+ // Write header if depth created file is new (empty)
+ depthCreatedInfo, err := depthCreatedFile.Stat()
+ if err != nil {
+ file.Close()
+ depthCreatedFile.Close()
+ return nil, fmt.Errorf("failed to stat depth created file: %v", err)
+ }
+ if depthCreatedInfo.Size() == 0 {
+ if err := t.depthCreatedWriter.Write(depthCSVHeaders()); err != nil {
+ file.Close()
+ depthCreatedFile.Close()
+ return nil, fmt.Errorf("failed to write depth created CSV headers: %v", err)
+ }
+ t.depthCreatedWriter.Flush()
+ }
+
+ // Open depth deleted file for appending
+ depthDeletedFile, err := os.OpenFile(depthDeletedFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ file.Close()
+ depthCreatedFile.Close()
+ return nil, fmt.Errorf("failed to open depth deleted file: %v", err)
+ }
+ t.depthDeletedFile = depthDeletedFile
+ t.depthDeletedWriter = csv.NewWriter(depthDeletedFile)
+
+ // Write header if depth deleted file is new (empty)
+ depthDeletedInfo, err := depthDeletedFile.Stat()
+ if err != nil {
+ file.Close()
+ depthCreatedFile.Close()
+ depthDeletedFile.Close()
+ return nil, fmt.Errorf("failed to stat depth deleted file: %v", err)
+ }
+ if depthDeletedInfo.Size() == 0 {
+ if err := t.depthDeletedWriter.Write(depthCSVHeaders()); err != nil {
+ file.Close()
+ depthCreatedFile.Close()
+ depthDeletedFile.Close()
+ return nil, fmt.Errorf("failed to write depth deleted CSV headers: %v", err)
+ }
+ t.depthDeletedWriter.Flush()
+ }
+
+ return &tracing.Hooks{
+ OnStateUpdate: t.onStateUpdate,
+ OnClose: t.onClose,
+ }, nil
+}
+
+// loadExisting reads the existing CSV file and loads the latest records by block number.
+func (s *stateSizeTracer) loadExisting() error {
+ file, err := os.Open(s.filePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil // File doesn't exist, nothing to load
+ }
+ return err
+ }
+ defer file.Close()
+
+ reader := csv.NewReader(bufio.NewReader(file))
+
+ // Read and skip header
+ if _, err := reader.Read(); err != nil {
+ if err == io.EOF {
+ return nil // Empty file
+ }
+ return fmt.Errorf("failed to read CSV header: %v", err)
+ }
+
+ // Read all records to find the latest block number
+ var (
+ latestBlockNum uint64
+ latestRecords = make(map[common.Hash]stateSizeStats)
+ )
+
+ for {
+ record, err := reader.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("failed to read CSV record: %v", err)
+ }
+ if len(record) < len(csvHeaders) {
+ continue // Skip malformed records
+ }
+
+ blockNum, err := strconv.ParseUint(record[0], 10, 64)
+ if err != nil {
+ continue
+ }
+
+ // If we found a new latest block, clear the map
+ if blockNum > latestBlockNum {
+ latestBlockNum = blockNum
+ latestRecords = make(map[common.Hash]stateSizeStats)
+ }
+
+ // Only store records from the latest block
+ if blockNum == latestBlockNum {
+ root := common.HexToHash(record[1])
+ stats, err := parseStats(record)
+ if err != nil {
+ log.Warn("Failed to parse statesize record", "error", err)
+ continue
+ }
+ latestRecords[root] = stats
+ }
+ }
+
+ s.stats = latestRecords
+ if len(latestRecords) > 0 {
+ log.Info("Loaded statesize tracer state", "block", latestBlockNum, "roots", len(latestRecords))
+ }
+ return nil
+}
+
+// parseStats extracts cumulative statistics from a CSV record.
+func parseStats(record []string) (stateSizeStats, error) {
+ if len(record) < len(csvHeaders) {
+ return stateSizeStats{}, errors.New("record too short")
+ }
+
+ // Cumulative columns start at index 3
+ stats := stateSizeStats{}
+ var err error
+
+ stats.Accounts, err = strconv.ParseInt(record[3], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.AccountBytes, err = strconv.ParseInt(record[4], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.Storages, err = strconv.ParseInt(record[5], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.StorageBytes, err = strconv.ParseInt(record[6], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.AccountTrienodes, err = strconv.ParseInt(record[7], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.AccountTrienodeBytes, err = strconv.ParseInt(record[8], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.StorageTrienodes, err = strconv.ParseInt(record[9], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.StorageTrienodeBytes, err = strconv.ParseInt(record[10], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.Codes, err = strconv.ParseInt(record[11], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+ stats.CodeBytes, err = strconv.ParseInt(record[12], 10, 64)
+ if err != nil {
+ return stats, err
+ }
+
+ return stats, nil
+}
+
+func (s *stateSizeTracer) onStateUpdate(update *tracing.StateUpdate) {
+ if update == nil {
+ return
+ }
+
+ // Calculate deltas
+ var (
+ accountsDelta int64
+ accountBytesDelta int64
+ storagesDelta int64
+ storageBytesDelta int64
+ accountTrienodesDelta int64
+ accountTrienodeBytes int64
+ storageTrienodesDelta int64
+ storageTrienodeBytes int64
+ codesDelta int64
+ codeBytesDelta int64
+ )
+
+ // Calculate account size changes
+ for _, change := range update.AccountChanges {
+ prevLen := slimAccountSize(change.Prev)
+ newLen := slimAccountSize(change.New)
+
+ switch {
+ case prevLen > 0 && newLen == 0:
+ accountsDelta--
+ accountBytesDelta -= accountKeySize + int64(prevLen)
+ case prevLen == 0 && newLen > 0:
+ accountsDelta++
+ accountBytesDelta += accountKeySize + int64(newLen)
+ default:
+ accountBytesDelta += int64(newLen - prevLen)
+ }
+ }
+
+ encode := func(val common.Hash) []byte {
+ if val == (common.Hash{}) {
+ return nil
+ }
+ blob, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(val[:]))
+ return blob
+ }
+
+ // Calculate storage size changes
+ for _, slots := range update.StorageChanges {
+ for _, change := range slots {
+ prevLen := len(encode(change.Prev))
+ newLen := len(encode(change.New))
+
+ switch {
+ case prevLen > 0 && newLen == 0:
+ storagesDelta--
+ storageBytesDelta -= storageKeySize + int64(prevLen)
+ case prevLen == 0 && newLen > 0:
+ storagesDelta++
+ storageBytesDelta += storageKeySize + int64(newLen)
+ default:
+ storageBytesDelta += int64(newLen - prevLen)
+ }
+ }
+ }
+
+ // Calculate trie node size changes and depth counts
+ var (
+ accountDepthCreated [65]int64
+ storageDepthCreated [65]int64
+ accountDepthDeleted [65]int64
+ storageDepthDeleted [65]int64
+ )
+
+ for owner, nodes := range update.TrieChanges {
+ var (
+ keyPrefix int64
+ isAccount = owner == (common.Hash{})
+ )
+ if isAccount {
+ keyPrefix = accountTrienodePrefixSize
+ } else {
+ keyPrefix = storageTrienodePrefixSize
+ }
+
+ // Calculate depth counts for created/modified and deleted nodes
+ createdCounts, deletedCounts := calculateDepthCountsByType(nodes)
+
+ for path, change := range nodes {
+ var prevLen, newLen int
+ if change.Prev != nil {
+ prevLen = len(change.Prev.Blob)
+ }
+ if change.New != nil {
+ newLen = len(change.New.Blob)
+ }
+ keySize := keyPrefix + int64(len(path))
+
+ switch {
+ case prevLen > 0 && newLen == 0:
+ if isAccount {
+ accountTrienodesDelta--
+ accountTrienodeBytes -= keySize + int64(prevLen)
+ } else {
+ storageTrienodesDelta--
+ storageTrienodeBytes -= keySize + int64(prevLen)
+ }
+ case prevLen == 0 && newLen > 0:
+ if isAccount {
+ accountTrienodesDelta++
+ accountTrienodeBytes += keySize + int64(newLen)
+ } else {
+ storageTrienodesDelta++
+ storageTrienodeBytes += keySize + int64(newLen)
+ }
+ default:
+ if isAccount {
+ accountTrienodeBytes += int64(newLen - prevLen)
+ } else {
+ storageTrienodeBytes += int64(newLen - prevLen)
+ }
+ }
+ }
+
+ // Accumulate depth counts
+ if isAccount {
+ for i := range 65 {
+ accountDepthCreated[i] += createdCounts[i]
+ accountDepthDeleted[i] += deletedCounts[i]
+ }
+ } else {
+ for i := range 65 {
+ storageDepthCreated[i] += createdCounts[i]
+ storageDepthDeleted[i] += deletedCounts[i]
+ }
+ }
+ }
+
+ // Calculate contract code size changes
+ codeExists := make(map[common.Hash]struct{})
+ for _, code := range update.CodeChanges {
+ if _, ok := codeExists[code.Hash]; ok || code.Exists {
+ continue
+ }
+ codesDelta++
+ codeBytesDelta += codeKeySize + int64(len(code.Code))
+ codeExists[code.Hash] = struct{}{}
+ }
+
+ // Calculate cumulative statistics
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Look up parent stats
+ parentStats := s.stats[update.OriginRoot] // zero value if not found
+
+ // Apply deltas to get new cumulative stats
+ newStats := stateSizeStats{
+ Accounts: parentStats.Accounts + accountsDelta,
+ AccountBytes: parentStats.AccountBytes + accountBytesDelta,
+ Storages: parentStats.Storages + storagesDelta,
+ StorageBytes: parentStats.StorageBytes + storageBytesDelta,
+ AccountTrienodes: parentStats.AccountTrienodes + accountTrienodesDelta,
+ AccountTrienodeBytes: parentStats.AccountTrienodeBytes + accountTrienodeBytes,
+ StorageTrienodes: parentStats.StorageTrienodes + storageTrienodesDelta,
+ StorageTrienodeBytes: parentStats.StorageTrienodeBytes + storageTrienodeBytes,
+ Codes: parentStats.Codes + codesDelta,
+ CodeBytes: parentStats.CodeBytes + codeBytesDelta,
+ }
+
+ // Store the new stats for this root
+ s.stats[update.Root] = newStats
+
+ // Calculate total nodes for created and deleted
+ var totalCreated, totalDeleted int64
+ for _, c := range accountDepthCreated {
+ totalCreated += c
+ }
+ for _, c := range storageDepthCreated {
+ totalCreated += c
+ }
+ for _, c := range accountDepthDeleted {
+ totalDeleted += c
+ }
+ for _, c := range storageDepthDeleted {
+ totalDeleted += c
+ }
+
+ // Write to statesize CSV
+ s.writeRecord(stateSizeRecord{
+ BlockNumber: update.BlockNumber,
+ Root: update.Root,
+ ParentRoot: update.OriginRoot,
+ Stats: newStats,
+ })
+
+ // Write to depth CSV files
+ s.writeDepthRecord(s.depthCreatedWriter, update.BlockNumber, update.Root, update.OriginRoot, totalCreated, accountDepthCreated, storageDepthCreated)
+ s.writeDepthRecord(s.depthDeletedWriter, update.BlockNumber, update.Root, update.OriginRoot, totalDeleted, accountDepthDeleted, storageDepthDeleted)
+}
+
+func (s *stateSizeTracer) writeRecord(r stateSizeRecord) {
+ row := []string{
+ strconv.FormatUint(r.BlockNumber, 10),
+ r.Root.Hex(),
+ r.ParentRoot.Hex(),
+ strconv.FormatInt(r.Stats.Accounts, 10),
+ strconv.FormatInt(r.Stats.AccountBytes, 10),
+ strconv.FormatInt(r.Stats.Storages, 10),
+ strconv.FormatInt(r.Stats.StorageBytes, 10),
+ strconv.FormatInt(r.Stats.AccountTrienodes, 10),
+ strconv.FormatInt(r.Stats.AccountTrienodeBytes, 10),
+ strconv.FormatInt(r.Stats.StorageTrienodes, 10),
+ strconv.FormatInt(r.Stats.StorageTrienodeBytes, 10),
+ strconv.FormatInt(r.Stats.Codes, 10),
+ strconv.FormatInt(r.Stats.CodeBytes, 10),
+ }
+
+ if err := s.writer.Write(row); err != nil {
+ log.Warn("Failed to write statesize record", "error", err)
+ return
+ }
+ s.writer.Flush()
+ if err := s.writer.Error(); err != nil {
+ log.Warn("Failed to flush statesize record", "error", err)
+ }
+}
+
+func (s *stateSizeTracer) writeDepthRecord(writer *csv.Writer, blockNumber uint64, root, parentRoot common.Hash, totalNodes int64, accountDepths, storageDepths [65]int64) {
+ // Build row: block_number, root, parent_root, total_nodes, account_depth_0..64, storage_depth_0..64
+ row := make([]string, 0, 4+65+65)
+ row = append(row, strconv.FormatUint(blockNumber, 10))
+ row = append(row, root.Hex())
+ row = append(row, parentRoot.Hex())
+ row = append(row, strconv.FormatInt(totalNodes, 10))
+
+ for i := 0; i < 65; i++ {
+ row = append(row, strconv.FormatInt(accountDepths[i], 10))
+ }
+ for i := 0; i < 65; i++ {
+ row = append(row, strconv.FormatInt(storageDepths[i], 10))
+ }
+
+ if err := writer.Write(row); err != nil {
+ log.Warn("Failed to write depth record", "error", err)
+ return
+ }
+ writer.Flush()
+ if err := writer.Error(); err != nil {
+ log.Warn("Failed to flush depth record", "error", err)
+ }
+}
+
+func (s *stateSizeTracer) onClose() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.writer != nil {
+ s.writer.Flush()
+ }
+ if s.file != nil {
+ if err := s.file.Close(); err != nil {
+ log.Warn("Failed to close statesize tracer file", "error", err)
+ }
+ }
+ if s.depthCreatedWriter != nil {
+ s.depthCreatedWriter.Flush()
+ }
+ if s.depthCreatedFile != nil {
+ if err := s.depthCreatedFile.Close(); err != nil {
+ log.Warn("Failed to close depth created file", "error", err)
+ }
+ }
+ if s.depthDeletedWriter != nil {
+ s.depthDeletedWriter.Flush()
+ }
+ if s.depthDeletedFile != nil {
+ if err := s.depthDeletedFile.Close(); err != nil {
+ log.Warn("Failed to close depth deleted file", "error", err)
+ }
+ }
+}
+
+// slimAccountSize calculates the RLP-encoded size of an account in slim format.
+func slimAccountSize(acct *types.StateAccount) int {
+ if acct == nil {
+ return 0
+ }
+ data := types.SlimAccountRLP(*acct)
+ return len(data)
+}
+
+// calculateDepthCountsByType calculates the depth of each node and separates counts
+// into created/modified nodes and deleted nodes.
+// - Created/Modified: nodes that exist after the update (New has data)
+// - Deleted: nodes that existed before but don't exist after (Prev has data, New is empty)
+func calculateDepthCountsByType(pathMap map[string]*tracing.TrieNodeChange) (created, deleted [65]int64) {
+ n := len(pathMap)
+ if n == 0 {
+ return
+ }
+
+ // First, calculate depth for all nodes using the tree structure
+ paths := make([]string, 0, n)
+ for path := range pathMap {
+ paths = append(paths, path)
+ }
+ slices.Sort(paths)
+
+ // Map from path to its depth
+ depthMap := make(map[string]int, n)
+
+ // Stack stores paths of ancestors
+ stack := make([]string, 0, 65)
+
+ for _, path := range paths {
+ // Pop until stack top is a strict prefix of path
+ for len(stack) > 0 {
+ top := stack[len(stack)-1]
+ if len(top) < len(path) && path[:len(top)] == top {
+ break
+ }
+ stack = stack[:len(stack)-1]
+ }
+
+ depth := len(stack)
+ depthMap[path] = depth
+
+ stack = append(stack, path)
+ }
+
+ // Now classify each node based on Prev/New status
+ for path, change := range pathMap {
+ depth := depthMap[path]
+
+ var prevLen, newLen int
+ if change.Prev != nil {
+ prevLen = len(change.Prev.Blob)
+ }
+ if change.New != nil {
+ newLen = len(change.New.Blob)
+ }
+
+ // Created/Modified: New has data (node exists after update)
+ if newLen > 0 {
+ created[depth]++
+ }
+ // Deleted: Prev has data but New is empty (node removed)
+ if prevLen > 0 && newLen == 0 {
+ deleted[depth]++
+ }
+ }
+
+ return
+}
diff --git a/eth/tracers/live/statesize_test.go b/eth/tracers/live/statesize_test.go
new file mode 100644
index 0000000000..120887441a
--- /dev/null
+++ b/eth/tracers/live/statesize_test.go
@@ -0,0 +1,244 @@
+// Copyright 2025 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 live
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/core/tracing"
+ "github.com/ethereum/go-ethereum/trie/trienode"
+)
+
+func TestCalculateDepthCountsByType(t *testing.T) {
+ tests := []struct {
+ name string
+ paths []string
+ expectedCreated [65]int64
+ }{
+ {
+ name: "empty map",
+ paths: []string{},
+ expectedCreated: [65]int64{},
+ },
+ {
+ name: "root only",
+ paths: []string{""},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1
+ return c
+ }(),
+ },
+ {
+ name: "linear chain - all branch nodes",
+ paths: []string{"", "0", "0a", "0a3"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 1 // "0"
+ c[2] = 1 // "0a"
+ c[3] = 1 // "0a3"
+ return c
+ }(),
+ },
+ {
+ name: "extension node - path jump",
+ paths: []string{"", "0a3"}, // extension from root to "0a3"
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 1 // "0a3" (child of root)
+ return c
+ }(),
+ },
+ {
+ name: "branching at root",
+ paths: []string{"", "0", "1", "2"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 3 // "0", "1", "2"
+ return c
+ }(),
+ },
+ {
+ name: "two branches from root",
+ paths: []string{"", "0", "0a", "1", "1b"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 2 // "0", "1"
+ c[2] = 2 // "0a", "1b"
+ return c
+ }(),
+ },
+ {
+ name: "mixed extension and branch",
+ paths: []string{"", "0", "0a", "0a3", "0b"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 1 // "0"
+ c[2] = 2 // "0a", "0b"
+ c[3] = 1 // "0a3"
+ return c
+ }(),
+ },
+ {
+ name: "deep path with extensions",
+ paths: []string{"", "abc", "abcdef"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 1 // "abc"
+ c[2] = 1 // "abcdef"
+ return c
+ }(),
+ },
+ {
+ name: "siblings at various depths",
+ paths: []string{"", "0", "00", "01", "1", "10", "11"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 2 // "0", "1"
+ c[2] = 4 // "00", "01", "10", "11"
+ return c
+ }(),
+ },
+ {
+ name: "complex tree",
+ paths: []string{"", "a", "ab", "abc", "abd", "b", "bc"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 2 // "a", "b"
+ c[2] = 2 // "ab", "bc"
+ c[3] = 2 // "abc", "abd"
+ return c
+ }(),
+ },
+ {
+ name: "max depth path",
+ paths: []string{"", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
+ expectedCreated: func() [65]int64 {
+ var c [65]int64
+ c[0] = 1 // ""
+ c[1] = 1 // 64-nibble path (child of root via extension)
+ return c
+ }(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Build pathMap with New blob data (simulates created nodes)
+ pathMap := make(map[string]*tracing.TrieNodeChange, len(tt.paths))
+ for _, p := range tt.paths {
+ pathMap[p] = &tracing.TrieNodeChange{
+ New: &trienode.Node{Blob: []byte{0x01}}, // Non-empty blob marks as created
+ }
+ }
+
+ created, deleted := calculateDepthCountsByType(pathMap)
+
+ if created != tt.expectedCreated {
+ t.Errorf("calculateDepthCountsByType() created mismatch")
+ for i := 0; i < 65; i++ {
+ if created[i] != tt.expectedCreated[i] {
+ t.Errorf(" depth %d: got %d, want %d", i, created[i], tt.expectedCreated[i])
+ }
+ }
+ }
+
+ // All nodes have New data, so deleted should be all zeros
+ var expectedDeleted [65]int64
+ if deleted != expectedDeleted {
+ t.Errorf("calculateDepthCountsByType() deleted should be all zeros for created nodes")
+ }
+ })
+ }
+}
+
+func TestCalculateDepthCountsByType_Deletion(t *testing.T) {
+ // Test that deleted nodes are counted correctly
+ pathMap := map[string]*tracing.TrieNodeChange{
+ "": {Prev: &trienode.Node{Blob: []byte{0x01}}}, // deleted at depth 0
+ "0": {Prev: &trienode.Node{Blob: []byte{0x01}}, New: &trienode.Node{Blob: []byte{}}}, // deleted at depth 1
+ "0a": {Prev: &trienode.Node{Blob: []byte{0x01}}}, // deleted at depth 2
+ "1": {Prev: &trienode.Node{Blob: []byte{0x01}}, New: &trienode.Node{Blob: []byte{0x02}}}, // modified at depth 1 (not deleted)
+ }
+
+ created, deleted := calculateDepthCountsByType(pathMap)
+
+ // Expected deleted: depth 0 = 1, depth 1 = 1, depth 2 = 1
+ expectedDeleted := [65]int64{1, 1, 1}
+ for i := 0; i < 65; i++ {
+ if deleted[i] != expectedDeleted[i] {
+ t.Errorf("deleted depth %d: got %d, want %d", i, deleted[i], expectedDeleted[i])
+ }
+ }
+
+ // Expected created: only "1" is modified (has New data), depth 1 = 1
+ expectedCreated := [65]int64{0, 1}
+ for i := 0; i < 65; i++ {
+ if created[i] != expectedCreated[i] {
+ t.Errorf("created depth %d: got %d, want %d", i, created[i], expectedCreated[i])
+ }
+ }
+}
+
+func TestCalculateDepthCountsByType_Mixed(t *testing.T) {
+ // Test mixed scenario: some nodes created, some modified, some deleted
+ pathMap := map[string]*tracing.TrieNodeChange{
+ "": {
+ Prev: &trienode.Node{Blob: []byte{0x01}},
+ New: &trienode.Node{Blob: []byte{0x02}},
+ }, // modified at depth 0
+ "0": {
+ New: &trienode.Node{Blob: []byte{0x01}},
+ }, // created at depth 1
+ "1": {
+ Prev: &trienode.Node{Blob: []byte{0x01}},
+ }, // deleted at depth 1
+ "0a": {
+ Prev: &trienode.Node{Blob: []byte{0x01}},
+ New: &trienode.Node{Blob: []byte{0x02}},
+ }, // modified at depth 2
+ "1b": {
+ Prev: &trienode.Node{Blob: []byte{0x01}},
+ }, // deleted at depth 2
+ }
+
+ created, deleted := calculateDepthCountsByType(pathMap)
+
+ // Created/Modified: "" (depth 0), "0" (depth 1), "0a" (depth 2)
+ expectedCreated := [65]int64{1, 1, 1}
+ for i := 0; i < 65; i++ {
+ if created[i] != expectedCreated[i] {
+ t.Errorf("created depth %d: got %d, want %d", i, created[i], expectedCreated[i])
+ }
+ }
+
+ // Deleted: "1" (depth 1), "1b" (depth 2)
+ expectedDeleted := [65]int64{0, 1, 1}
+ for i := 0; i < 65; i++ {
+ if deleted[i] != expectedDeleted[i] {
+ t.Errorf("deleted depth %d: got %d, want %d", i, deleted[i], expectedDeleted[i])
+ }
+ }
+}
diff --git a/tests/block_test_util.go b/tests/block_test_util.go
index 72fd955c8f..4f6ab65c1a 100644
--- a/tests/block_test_util.go
+++ b/tests/block_test_util.go
@@ -138,7 +138,7 @@ func (t *BlockTest) Run(snapshotter bool, scheme string, witness bool, tracer *t
gspec.Config.TerminalTotalDifficulty = big.NewInt(stdmath.MaxInt64)
}
triedb := triedb.NewDatabase(db, tconf)
- gblock, err := gspec.Commit(db, triedb)
+ gblock, err := gspec.Commit(db, triedb, nil)
if err != nil {
return err
}