cmd/geth: add inspect trie tool to analysis trie storage (#28892)
Some checks are pending
/ Linux Build (push) Waiting to run
/ Linux Build (arm) (push) Waiting to run
/ Keeper Build (push) Waiting to run
/ Windows Build (push) Waiting to run
/ Docker Image (push) Waiting to run

This pr adds a tool names `inpsect-trie`, aimed to analyze the mpt and
its node storage more efficiently.

## Example
 ./geth db inspect-trie --datadir server/data-seed/ latest 4000

## Result

- MPT shape
- Account Trie 
- Top N Storage Trie
```
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      0       |     16      |      0       |
|   -   |   2   |      76      |     32      |      74      |
|   -   |   3   |      66      |      1      |      66      |
|   -   |   4   |      2       |      0      |      2       |
| Total |  144  |      50      |     142     |
+-------+-------+--------------+-------------+--------------+
AccountTrie
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      0       |     16      |      0       |
|   -   |   2   |     108      |     84      |     104      |
|   -   |   3   |     195      |      5      |     195      |
|   -   |   4   |      10      |      0      |      10      |
| Total |  313  |     106      |     309     |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0xc874e65ccffb133d9db4ff637e62532ef6ecef3223845d02f522c55786782911
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      0       |     16      |      0       |
|   -   |   2   |      57      |     14      |      56      |
|   -   |   3   |      33      |      0      |      33      |
| Total |  90   |      31      |     89      |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0x1d7dcb6a0ce5227c5379fc5b0e004561d7833b063355f69bfea3178f08fbaab4
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      5       |      8      |      5       |
|   -   |   2   |      16      |      1      |      16      |
|   -   |   3   |      2       |      0      |      2       |
| Total |  23   |      10      |     23      |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0xaa8a4783ebbb3bec45d3e804b3c59bfd486edfa39cbeda1d42bf86c08a0ebc0f
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      9       |      3      |      9       |
|   -   |   2   |      7       |      1      |      7       |
|   -   |   3   |      2       |      0      |      2       |
| Total |  18   |      5       |     18      |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0x9d2804d0562391d7cfcfaf0013f0352e176a94403a58577ebf82168a21514441
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      6       |      4      |      6       |
|   -   |   2   |      8       |      0      |      8       |
| Total |  14   |      5       |     14      |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0x17e3eb95d0e6e92b42c0b3e95c6e75080c9fcd83e706344712e9587375de96e1
+-------+-------+--------------+-------------+--------------+
|   -   | LEVEL | SHORTNODECNT | FULLNODECNT | VALUENODECNT |
+-------+-------+--------------+-------------+--------------+
|   -   |   0   |      0       |      1      |      0       |
|   -   |   1   |      5       |      3      |      5       |
|   -   |   2   |      7       |      0      |      7       |
| Total |  12   |      4       |     12      |
+-------+-------+--------------+-------------+--------------+
ContractTrie-0xc017ca90c8aa37693c38f80436bb15bde46d7b30a503aa808cb7814127468a44
Contract Trie, total trie num: 142, ShortNodeCnt: 620, FullNodeCnt: 204, ValueNodeCnt: 615
```

---------

Co-authored-by: lightclient <lightclient@protonmail.com>
Co-authored-by: MariusVanDerWijden <m.vanderwijden@live.de>
This commit is contained in:
Fynn 2026-02-25 01:56:00 +08:00 committed by GitHub
parent 9ecb6c4ae6
commit 8450e40798
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1598 additions and 77 deletions

View file

@ -19,6 +19,7 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"math"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@ -37,6 +38,7 @@ import (
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/internal/tablewriter"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie"
@ -53,6 +55,23 @@ var (
Name: "remove.chain", Name: "remove.chain",
Usage: "If set, selects the state data for removal", Usage: "If set, selects the state data for removal",
} }
inspectTrieTopFlag = &cli.IntFlag{
Name: "top",
Usage: "Print the top N results per ranking category",
Value: 10,
}
inspectTrieDumpPathFlag = &cli.StringFlag{
Name: "dump-path",
Usage: "Path for the trie statistics dump file",
}
inspectTrieSummarizeFlag = &cli.StringFlag{
Name: "summarize",
Usage: "Summarize an existing trie dump file (skip trie traversal)",
}
inspectTrieContractFlag = &cli.StringFlag{
Name: "contract",
Usage: "Inspect only the storage of the given contract address (skips full account trie walk)",
}
removedbCommand = &cli.Command{ removedbCommand = &cli.Command{
Action: removeDB, Action: removeDB,
@ -74,6 +93,7 @@ Remove blockchain and state databases`,
dbCompactCmd, dbCompactCmd,
dbGetCmd, dbGetCmd,
dbDeleteCmd, dbDeleteCmd,
dbInspectTrieCmd,
dbPutCmd, dbPutCmd,
dbGetSlotsCmd, dbGetSlotsCmd,
dbDumpFreezerIndex, dbDumpFreezerIndex,
@ -92,6 +112,22 @@ Remove blockchain and state databases`,
Usage: "Inspect the storage size for each type of data in the database", Usage: "Inspect the storage size for each type of data in the database",
Description: `This commands iterates the entire database. If the optional 'prefix' and 'start' arguments are provided, then the iteration is limited to the given subset of data.`, Description: `This commands iterates the entire database. If the optional 'prefix' and 'start' arguments are provided, then the iteration is limited to the given subset of data.`,
} }
dbInspectTrieCmd = &cli.Command{
Action: inspectTrie,
Name: "inspect-trie",
ArgsUsage: "<blocknum>",
Flags: slices.Concat([]cli.Flag{
utils.ExcludeStorageFlag,
inspectTrieTopFlag,
utils.OutputFileFlag,
inspectTrieDumpPathFlag,
inspectTrieSummarizeFlag,
inspectTrieContractFlag,
}, utils.NetworkFlags, utils.DatabaseFlags),
Usage: "Print detailed trie information about the structure of account trie and storage tries.",
Description: `This commands iterates the entrie trie-backed state. If the 'blocknum' is not specified,
the latest block number will be used by default.`,
}
dbCheckStateContentCmd = &cli.Command{ dbCheckStateContentCmd = &cli.Command{
Action: checkStateContent, Action: checkStateContent,
Name: "check-state-content", Name: "check-state-content",
@ -385,6 +421,88 @@ func checkStateContent(ctx *cli.Context) error {
return nil return nil
} }
func inspectTrie(ctx *cli.Context) error {
topN := ctx.Int(inspectTrieTopFlag.Name)
if topN <= 0 {
return fmt.Errorf("invalid --%s value %d (must be > 0)", inspectTrieTopFlag.Name, topN)
}
config := &trie.InspectConfig{
NoStorage: ctx.Bool(utils.ExcludeStorageFlag.Name),
TopN: topN,
Path: ctx.String(utils.OutputFileFlag.Name),
}
if summarizePath := ctx.String(inspectTrieSummarizeFlag.Name); summarizePath != "" {
if ctx.NArg() > 0 {
return fmt.Errorf("block number argument is not supported with --%s", inspectTrieSummarizeFlag.Name)
}
config.DumpPath = summarizePath
log.Info("Summarizing trie dump", "path", summarizePath, "top", topN)
return trie.Summarize(summarizePath, config)
}
if ctx.NArg() > 1 {
return fmt.Errorf("excessive number of arguments: %v", ctx.Command.ArgsUsage)
}
stack, _ := makeConfigNode(ctx)
db := utils.MakeChainDatabase(ctx, stack, false)
defer stack.Close()
defer db.Close()
var (
trieRoot common.Hash
hash common.Hash
number uint64
)
switch {
case ctx.NArg() == 0 || ctx.Args().Get(0) == "latest":
head := rawdb.ReadHeadHeaderHash(db)
n, ok := rawdb.ReadHeaderNumber(db, head)
if !ok {
return fmt.Errorf("could not load head block hash")
}
number = n
case ctx.Args().Get(0) == "snapshot":
trieRoot = rawdb.ReadSnapshotRoot(db)
number = math.MaxUint64
default:
var err error
number, err = strconv.ParseUint(ctx.Args().Get(0), 10, 64)
if err != nil {
return fmt.Errorf("failed to parse blocknum, Args[0]: %v, err: %v", ctx.Args().Get(0), err)
}
}
if number != math.MaxUint64 {
hash = rawdb.ReadCanonicalHash(db, number)
if hash == (common.Hash{}) {
return fmt.Errorf("canonical hash for block %d not found", number)
}
blockHeader := rawdb.ReadHeader(db, hash, number)
trieRoot = blockHeader.Root
}
if trieRoot == (common.Hash{}) {
log.Error("Empty root hash")
}
config.DumpPath = ctx.String(inspectTrieDumpPathFlag.Name)
if config.DumpPath == "" {
config.DumpPath = stack.ResolvePath("trie-dump.bin")
}
triedb := utils.MakeTrieDatabase(ctx, stack, db, false, true, false)
defer triedb.Close()
if contractAddr := ctx.String(inspectTrieContractFlag.Name); contractAddr != "" {
address := common.HexToAddress(contractAddr)
log.Info("Inspecting contract", "address", address, "root", trieRoot, "block", number)
return trie.InspectContract(triedb, db, trieRoot, address)
}
log.Info("Inspecting trie", "root", trieRoot, "block", number, "dump", config.DumpPath, "top", topN)
return trie.Inspect(triedb, trieRoot, config)
}
func showDBStats(db ethdb.KeyValueStater) { func showDBStats(db ethdb.KeyValueStater) {
stats, err := db.Stat() stats, err := db.Stat()
if err != nil { if err != nil {
@ -759,7 +877,7 @@ func showMetaData(ctx *cli.Context) error {
data = append(data, []string{"headHeader.Root", fmt.Sprintf("%v", h.Root)}) data = append(data, []string{"headHeader.Root", fmt.Sprintf("%v", h.Root)})
data = append(data, []string{"headHeader.Number", fmt.Sprintf("%d (%#x)", h.Number, h.Number)}) data = append(data, []string{"headHeader.Number", fmt.Sprintf("%d (%#x)", h.Number, h.Number)})
} }
table := rawdb.NewTableWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Field", "Value"}) table.SetHeader([]string{"Field", "Value"})
table.AppendBulk(data) table.AppendBulk(data)
table.Render() table.Render()

View file

@ -218,6 +218,16 @@ var (
Usage: "Max number of elements (0 = no limit)", Usage: "Max number of elements (0 = no limit)",
Value: 0, Value: 0,
} }
TopFlag = &cli.IntFlag{
Name: "top",
Usage: "Print the top N results",
Value: 5,
}
OutputFileFlag = &cli.StringFlag{
Name: "output",
Usage: "Writes the result in json to the output",
Value: "",
}
SnapshotFlag = &cli.BoolFlag{ SnapshotFlag = &cli.BoolFlag{
Name: "snapshot", Name: "snapshot",

View file

@ -35,6 +35,7 @@ import (
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/ethdb/memorydb" "github.com/ethereum/go-ethereum/ethdb/memorydb"
"github.com/ethereum/go-ethereum/internal/tablewriter"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
@ -663,7 +664,7 @@ func InspectDatabase(db ethdb.Database, keyPrefix, keyStart []byte) error {
total.Add(uint64(ancient.size())) total.Add(uint64(ancient.size()))
} }
table := NewTableWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Database", "Category", "Size", "Items"}) table.SetHeader([]string{"Database", "Category", "Size", "Items"})
table.SetFooter([]string{"", "Total", common.StorageSize(total.Load()).String(), fmt.Sprintf("%d", count.Load())}) table.SetFooter([]string{"", "Total", common.StorageSize(total.Load()).String(), fmt.Sprintf("%d", count.Load())})
table.AppendBulk(stats) table.AppendBulk(stats)

View file

@ -27,6 +27,7 @@ import (
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/metrics"
"github.com/ethereum/go-ethereum/trie"
) )
var accountTrieLeavesAtDepth [16]*metrics.Counter var accountTrieLeavesAtDepth [16]*metrics.Counter
@ -41,59 +42,68 @@ func init() {
// WitnessStats aggregates statistics for account and storage trie accesses. // WitnessStats aggregates statistics for account and storage trie accesses.
type WitnessStats struct { type WitnessStats struct {
accountTrieLeaves [16]int64 accountTrie *trie.LevelStats
storageTrieLeaves [16]int64 storageTrie *trie.LevelStats
} }
// NewWitnessStats creates a new WitnessStats collector. // NewWitnessStats creates a new WitnessStats collector.
func NewWitnessStats() *WitnessStats { func NewWitnessStats() *WitnessStats {
return &WitnessStats{} return &WitnessStats{
accountTrie: trie.NewLevelStats(),
storageTrie: trie.NewLevelStats(),
}
}
func (s *WitnessStats) init() {
if s.accountTrie == nil {
s.accountTrie = trie.NewLevelStats()
}
if s.storageTrie == nil {
s.storageTrie = trie.NewLevelStats()
}
} }
// Add records trie access depths from the given node paths. // Add records trie access depths from the given node paths.
// If `owner` is the zero hash, accesses are attributed to the account trie; // If `owner` is the zero hash, accesses are attributed to the account trie;
// otherwise, they are attributed to the storage trie of that account. // otherwise, they are attributed to the storage trie of that account.
func (s *WitnessStats) Add(nodes map[string][]byte, owner common.Hash) { func (s *WitnessStats) Add(nodes map[string][]byte, owner common.Hash) {
// Extract paths from the nodes map s.init()
// Extract paths from the nodes map.
paths := slices.Collect(maps.Keys(nodes)) paths := slices.Collect(maps.Keys(nodes))
sort.Strings(paths) sort.Strings(paths)
ownerStat := s.accountTrie
if owner != (common.Hash{}) {
ownerStat = s.storageTrie
}
for i, path := range paths { for i, path := range paths {
// If current path is a prefix of the next path, it's not a leaf. // If current path is a prefix of the next path, it's not a leaf.
// The last path is always a leaf. // The last path is always a leaf.
if i == len(paths)-1 || !strings.HasPrefix(paths[i+1], paths[i]) { if i == len(paths)-1 || !strings.HasPrefix(paths[i+1], paths[i]) {
depth := len(path) ownerStat.AddLeaf(len(path))
if owner == (common.Hash{}) {
if depth >= len(s.accountTrieLeaves) {
depth = len(s.accountTrieLeaves) - 1
}
s.accountTrieLeaves[depth] += 1
} else {
if depth >= len(s.storageTrieLeaves) {
depth = len(s.storageTrieLeaves) - 1
}
s.storageTrieLeaves[depth] += 1
}
} }
} }
} }
// ReportMetrics reports the collected statistics to the global metrics registry. // ReportMetrics reports the collected statistics to the global metrics registry.
func (s *WitnessStats) ReportMetrics(blockNumber uint64) { func (s *WitnessStats) ReportMetrics(blockNumber uint64) {
// Encode the metrics as JSON for easier consumption s.init()
accountLeavesJson, _ := json.Marshal(s.accountTrieLeaves)
storageLeavesJson, _ := json.Marshal(s.storageTrieLeaves)
// Log account trie depth statistics accountTrieLeaves := s.accountTrie.LeafDepths()
log.Info("Account trie depth stats", storageTrieLeaves := s.storageTrie.LeafDepths()
"block", blockNumber,
"leavesAtDepth", string(accountLeavesJson))
log.Info("Storage trie depth stats",
"block", blockNumber,
"leavesAtDepth", string(storageLeavesJson))
for i := 0; i < 16; i++ { // Encode the metrics as JSON for easier consumption.
accountTrieLeavesAtDepth[i].Inc(s.accountTrieLeaves[i]) accountLeavesJSON, _ := json.Marshal(accountTrieLeaves)
storageTrieLeavesAtDepth[i].Inc(s.storageTrieLeaves[i]) storageLeavesJSON, _ := json.Marshal(storageTrieLeaves)
// Log account trie depth statistics.
log.Info("Account trie depth stats", "block", blockNumber, "leavesAtDepth", string(accountLeavesJSON))
log.Info("Storage trie depth stats", "block", blockNumber, "leavesAtDepth", string(storageLeavesJSON))
for i := 0; i < len(accountTrieLeavesAtDepth); i++ {
accountTrieLeavesAtDepth[i].Inc(accountTrieLeaves[i])
storageTrieLeavesAtDepth[i].Inc(storageTrieLeaves[i])
} }
} }

View file

@ -17,18 +17,27 @@
package stateless package stateless
import ( import (
"strings"
"testing" "testing"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
) )
func expectedLeaves(counts map[int]int64) [16]int64 {
var leaves [16]int64
for depth, count := range counts {
leaves[depth] = count
}
return leaves
}
func TestWitnessStatsAdd(t *testing.T) { func TestWitnessStatsAdd(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
nodes map[string][]byte nodes map[string][]byte
owner common.Hash owner common.Hash
expectedAccountLeaves map[int64]int64 expectedAccountLeaves map[int]int64
expectedStorageLeaves map[int64]int64 expectedStorageLeaves map[int]int64
}{ }{
{ {
name: "empty nodes", name: "empty nodes",
@ -41,7 +50,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"": []byte("data"), "": []byte("data"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{0: 1}, expectedAccountLeaves: map[int]int64{0: 1},
}, },
{ {
name: "single account trie leaf", name: "single account trie leaf",
@ -49,7 +58,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"abc": []byte("data"), "abc": []byte("data"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{3: 1}, expectedAccountLeaves: map[int]int64{3: 1},
}, },
{ {
name: "account trie with internal nodes", name: "account trie with internal nodes",
@ -59,7 +68,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"abc": []byte("data3"), "abc": []byte("data3"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{3: 1}, // Only "abc" is a leaf expectedAccountLeaves: map[int]int64{3: 1}, // Only "abc" is a leaf
}, },
{ {
name: "multiple account trie branches", name: "multiple account trie branches",
@ -72,7 +81,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"bcd": []byte("data6"), "bcd": []byte("data6"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{3: 2}, // "abc" (3) + "bcd" (3) expectedAccountLeaves: map[int]int64{3: 2}, // "abc" (3) + "bcd" (3)
}, },
{ {
name: "siblings are all leaves", name: "siblings are all leaves",
@ -82,7 +91,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"ac": []byte("data3"), "ac": []byte("data3"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{2: 3}, expectedAccountLeaves: map[int]int64{2: 3},
}, },
{ {
name: "storage trie leaves", name: "storage trie leaves",
@ -93,7 +102,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"124": []byte("data4"), "124": []byte("data4"),
}, },
owner: common.HexToHash("0x1234"), owner: common.HexToHash("0x1234"),
expectedStorageLeaves: map[int64]int64{3: 2}, // "123" (3) + "124" (3) expectedStorageLeaves: map[int]int64{3: 2}, // "123" (3) + "124" (3)
}, },
{ {
name: "complex trie structure", name: "complex trie structure",
@ -109,7 +118,7 @@ func TestWitnessStatsAdd(t *testing.T) {
"3": []byte("data9"), "3": []byte("data9"),
}, },
owner: common.Hash{}, owner: common.Hash{},
expectedAccountLeaves: map[int64]int64{1: 1, 3: 4}, // "123"(3) + "124"(3) + "234"(3) + "235"(3) + "3"(1) expectedAccountLeaves: map[int]int64{1: 1, 3: 4}, // "123"(3) + "124"(3) + "234"(3) + "235"(3) + "3"(1)
}, },
} }
@ -118,32 +127,59 @@ func TestWitnessStatsAdd(t *testing.T) {
stats := NewWitnessStats() stats := NewWitnessStats()
stats.Add(tt.nodes, tt.owner) stats.Add(tt.nodes, tt.owner)
var expectedAccountTrieLeaves [16]int64 if got, want := stats.accountTrie.LeafDepths(), expectedLeaves(tt.expectedAccountLeaves); got != want {
for depth, count := range tt.expectedAccountLeaves { t.Errorf("account trie leaves = %v, want %v", got, want)
expectedAccountTrieLeaves[depth] = count
} }
var expectedStorageTrieLeaves [16]int64 if got, want := stats.storageTrie.LeafDepths(), expectedLeaves(tt.expectedStorageLeaves); got != want {
for depth, count := range tt.expectedStorageLeaves { t.Errorf("storage trie leaves = %v, want %v", got, want)
expectedStorageTrieLeaves[depth] = count
}
// Check account trie depth
if stats.accountTrieLeaves != expectedAccountTrieLeaves {
t.Errorf("Account trie total depth = %v, want %v", stats.accountTrieLeaves, expectedAccountTrieLeaves)
}
// Check storage trie depth
if stats.storageTrieLeaves != expectedStorageTrieLeaves {
t.Errorf("Storage trie total depth = %v, want %v", stats.storageTrieLeaves, expectedStorageTrieLeaves)
} }
}) })
} }
} }
func TestWitnessStatsStorageTrieAggregation(t *testing.T) {
stats := NewWitnessStats()
ownerA := common.HexToHash("0xa")
ownerB := common.HexToHash("0xb")
stats.Add(map[string][]byte{
"a": []byte("data1"),
"ab": []byte("data2"),
"abc": []byte("data3"),
}, ownerA)
stats.Add(map[string][]byte{
"xy": []byte("data4"),
}, ownerA)
stats.Add(map[string][]byte{
"1": []byte("data5"),
"12": []byte("data6"),
"123": []byte("data7"),
"124": []byte("data8"),
}, ownerB)
if got, want := stats.storageTrie.LeafDepths(), expectedLeaves(map[int]int64{2: 1, 3: 3}); got != want {
t.Errorf("storage leaves = %v, want %v", got, want)
}
if got, want := stats.accountTrie.LeafDepths(), expectedLeaves(nil); got != want {
t.Errorf("account leaves = %v, want %v", got, want)
}
}
func TestWitnessStatsPanicsOnDeepLeaf(t *testing.T) {
stats := NewWitnessStats()
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for depth >= 16")
}
}()
stats.Add(map[string][]byte{strings.Repeat("a", 16): []byte("data")}, common.Hash{})
}
func TestWitnessStatsMinMax(t *testing.T) { func TestWitnessStatsMinMax(t *testing.T) {
stats := NewWitnessStats() stats := NewWitnessStats()
// Add some account trie nodes with varying depths // Add some account trie nodes with varying depths.
stats.Add(map[string][]byte{ stats.Add(map[string][]byte{
"a": []byte("data1"), "a": []byte("data1"),
"ab": []byte("data2"), "ab": []byte("data2"),
@ -152,21 +188,21 @@ func TestWitnessStatsMinMax(t *testing.T) {
"abcde": []byte("data5"), "abcde": []byte("data5"),
}, common.Hash{}) }, common.Hash{})
// Only "abcde" is a leaf (depth 5) // Only "abcde" is a leaf (depth 5).
for i, v := range stats.accountTrieLeaves { for i, v := range stats.accountTrie.LeafDepths() {
if v != 0 && i != 5 { if v != 0 && i != 5 {
t.Errorf("leaf found at invalid depth %d", i) t.Errorf("leaf found at invalid depth %d", i)
} }
} }
// Add more leaves with different depths // Add more leaves with different depths.
stats.Add(map[string][]byte{ stats.Add(map[string][]byte{
"x": []byte("data6"), "x": []byte("data6"),
"yz": []byte("data7"), "yz": []byte("data7"),
}, common.Hash{}) }, common.Hash{})
// Now we have leaves at depths 1, 2, and 5 // Now we have leaves at depths 1, 2, and 5.
for i, v := range stats.accountTrieLeaves { for i, v := range stats.accountTrie.LeafDepths() {
if v != 0 && (i != 5 && i != 2 && i != 1) { if v != 0 && (i != 5 && i != 2 && i != 1) {
t.Errorf("leaf found at invalid depth %d", i) t.Errorf("leaf found at invalid depth %d", i)
} }
@ -176,7 +212,7 @@ func TestWitnessStatsMinMax(t *testing.T) {
func TestWitnessStatsAverage(t *testing.T) { func TestWitnessStatsAverage(t *testing.T) {
stats := NewWitnessStats() stats := NewWitnessStats()
// Add nodes that will create leaves at depths 2, 3, and 4 // Add nodes that will create leaves at depths 2, 3, and 4.
stats.Add(map[string][]byte{ stats.Add(map[string][]byte{
"aa": []byte("data1"), "aa": []byte("data1"),
"bb": []byte("data2"), "bb": []byte("data2"),
@ -184,22 +220,22 @@ func TestWitnessStatsAverage(t *testing.T) {
"dddd": []byte("data4"), "dddd": []byte("data4"),
}, common.Hash{}) }, common.Hash{})
// All are leaves: 2 + 2 + 3 + 4 = 11 total, 4 samples // All are leaves: 2 + 2 + 3 + 4 = 11 total, 4 samples.
expectedAvg := int64(11) / int64(4) expectedAvg := int64(11) / int64(4)
var actualAvg, totalSamples int64 var actualAvg, totalSamples int64
for i, c := range stats.accountTrieLeaves { for i, c := range stats.accountTrie.LeafDepths() {
actualAvg += c * int64(i) actualAvg += c * int64(i)
totalSamples += c totalSamples += c
} }
actualAvg = actualAvg / totalSamples actualAvg = actualAvg / totalSamples
if actualAvg != expectedAvg { if actualAvg != expectedAvg {
t.Errorf("Account trie average depth = %d, want %d", actualAvg, expectedAvg) t.Errorf("account trie average depth = %d, want %d", actualAvg, expectedAvg)
} }
} }
func BenchmarkWitnessStatsAdd(b *testing.B) { func BenchmarkWitnessStatsAdd(b *testing.B) {
// Create a realistic trie node structure // Create a realistic trie node structure.
nodes := make(map[string][]byte) nodes := make(map[string][]byte)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
base := string(rune('a' + i%26)) base := string(rune('a' + i%26))

View file

@ -16,7 +16,7 @@
// Naive stub implementation for tablewriter // Naive stub implementation for tablewriter
package rawdb package tablewriter
import ( import (
"errors" "errors"
@ -37,7 +37,7 @@ type Table struct {
rows [][]string rows [][]string
} }
func NewTableWriter(w io.Writer) *Table { func NewWriter(w io.Writer) *Table {
return &Table{out: w} return &Table{out: w}
} }
@ -58,12 +58,12 @@ func (t *Table) SetFooter(footer []string) {
t.footer = footer t.footer = footer
} }
// AppendBulk sets all data rows for the table at once, replacing any existing rows. // AppendBulk appends one or more data rows to the table.
// //
// Each row must have the same number of columns as the headers, or validation // Each row must have the same number of columns as the headers, or validation
// will fail during Render(). // will fail during Render().
func (t *Table) AppendBulk(rows [][]string) { func (t *Table) AppendBulk(rows [][]string) {
t.rows = rows t.rows = append(t.rows, rows...)
} }
// Render outputs the complete table to the configured writer. The table is rendered // Render outputs the complete table to the configured writer. The table is rendered

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Lesser General Public License // You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package rawdb package tablewriter
import ( import (
"bytes" "bytes"
@ -24,7 +24,7 @@ import (
func TestTableWriterTinyGo(t *testing.T) { func TestTableWriterTinyGo(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
table := NewTableWriter(&buf) table := NewWriter(&buf)
headers := []string{"Database", "Size", "Items", "Status"} headers := []string{"Database", "Size", "Items", "Status"}
rows := [][]string{ rows := [][]string{
@ -48,7 +48,7 @@ func TestTableWriterValidationErrors(t *testing.T) {
// Test missing headers // Test missing headers
t.Run("MissingHeaders", func(t *testing.T) { t.Run("MissingHeaders", func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
table := NewTableWriter(&buf) table := NewWriter(&buf)
rows := [][]string{{"x", "y", "z"}} rows := [][]string{{"x", "y", "z"}}
@ -63,7 +63,7 @@ func TestTableWriterValidationErrors(t *testing.T) {
t.Run("NotEnoughRowColumns", func(t *testing.T) { t.Run("NotEnoughRowColumns", func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
table := NewTableWriter(&buf) table := NewWriter(&buf)
headers := []string{"A", "B", "C"} headers := []string{"A", "B", "C"}
badRows := [][]string{ badRows := [][]string{
@ -82,7 +82,7 @@ func TestTableWriterValidationErrors(t *testing.T) {
t.Run("TooManyRowColumns", func(t *testing.T) { t.Run("TooManyRowColumns", func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
table := NewTableWriter(&buf) table := NewWriter(&buf)
headers := []string{"A", "B", "C"} headers := []string{"A", "B", "C"}
badRows := [][]string{ badRows := [][]string{
@ -102,7 +102,7 @@ func TestTableWriterValidationErrors(t *testing.T) {
// Test mismatched footer columns // Test mismatched footer columns
t.Run("MismatchedFooterColumns", func(t *testing.T) { t.Run("MismatchedFooterColumns", func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
table := NewTableWriter(&buf) table := NewWriter(&buf)
headers := []string{"A", "B", "C"} headers := []string{"A", "B", "C"}
rows := [][]string{{"x", "y", "z"}} rows := [][]string{{"x", "y", "z"}}

930
trie/inspect.go Normal file
View file

@ -0,0 +1,930 @@
// 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 <http://www.gnu.org/licenses/>.
package trie
import (
"bufio"
"bytes"
"cmp"
"container/heap"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"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/crypto"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/internal/tablewriter"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/triedb/database"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
const (
inspectDumpRecordSize = 32 + trieStatLevels*(3*4+8)
inspectDefaultTopN = 10
inspectParallelism = int64(16)
)
// inspector is used by the inner inspect function to coordinate across threads.
type inspector struct {
triedb database.NodeDatabase
root common.Hash
config *InspectConfig
accountStat *LevelStats
sem *semaphore.Weighted
// Pass 1: dump file writer.
dumpMu sync.Mutex
dumpBuf *bufio.Writer
dumpFile *os.File
storageRecordsWritten atomic.Uint64
errMu sync.Mutex
err error
}
// InspectConfig is a set of options to control inspection and format the output.
// TopN determines the maximum number of entries retained for each top-list.
// Path controls optional JSON output. DumpPath controls the pass-1 dump location.
type InspectConfig struct {
NoStorage bool
TopN int
Path string
DumpPath string
}
// Inspect walks the trie with the given root and records the number and type of
// nodes at each depth. Storage trie stats are streamed to disk in fixed-size
// records, then summarized in a second pass.
func Inspect(triedb database.NodeDatabase, root common.Hash, config *InspectConfig) error {
trie, err := New(TrieID(root), triedb)
if err != nil {
return fmt.Errorf("fail to open trie %s: %w", root, err)
}
config = normalizeInspectConfig(config)
dumpFile, err := os.OpenFile(config.DumpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("failed to create trie dump %s: %w", config.DumpPath, err)
}
in := inspector{
triedb: triedb,
root: root,
config: config,
accountStat: NewLevelStats(),
sem: semaphore.NewWeighted(inspectParallelism),
dumpBuf: bufio.NewWriterSize(dumpFile, 1<<20),
dumpFile: dumpFile,
}
// Start progress reporter
start := time.Now()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
accountNodes := in.accountStat.TotalNodes()
storageRecords := in.storageRecordsWritten.Load()
log.Info("Inspecting trie",
"accountNodes", accountNodes,
"storageRecords", storageRecords,
"elapsed", common.PrettyDuration(time.Since(start)))
case <-done:
return
}
}
}()
in.recordRootSize(trie, root, in.accountStat)
in.inspect(trie, trie.root, 0, []byte{}, in.accountStat)
// inspect is synchronous: it waits for all spawned goroutines in its
// subtree before returning, so no additional wait is needed here.
// Persist account trie stats as the sentinel record.
in.writeDumpRecord(common.Hash{}, in.accountStat)
if err := in.closeDump(); err != nil {
in.setError(err)
}
// Stop progress reporter
close(done)
if err := in.getError(); err != nil {
return err
}
return Summarize(config.DumpPath, config)
}
// InspectContract inspects the on-disk footprint of a single contract.
// It reports snapshot storage (slot count + size) and storage trie node
// statistics (node type breakdown and per-depth distribution).
func InspectContract(triedb database.NodeDatabase, db ethdb.Database, stateRoot common.Hash, address common.Address) error {
// Resolve account from the state trie.
accountHash := crypto.Keccak256Hash(address.Bytes())
accountTrie, err := New(TrieID(stateRoot), triedb)
if err != nil {
return fmt.Errorf("failed to open account trie: %w", err)
}
accountRLP, err := accountTrie.Get(crypto.Keccak256(address.Bytes()))
if err != nil {
return fmt.Errorf("failed to read account: %w", err)
}
if accountRLP == nil {
return fmt.Errorf("account not found: %s", address)
}
var account types.StateAccount
if err := rlp.DecodeBytes(accountRLP, &account); err != nil {
return fmt.Errorf("failed to decode account: %w", err)
}
if account.Root == (common.Hash{}) || account.Root == types.EmptyRootHash {
return fmt.Errorf("account %s has no storage", address)
}
// Look up account snapshot.
accountData := rawdb.ReadAccountSnapshot(db, accountHash)
// Run trie walk + snap iteration in parallel.
var (
snapSlots atomic.Uint64
snapSize atomic.Uint64
g errgroup.Group
start = time.Now()
)
// Goroutine 1: Snapshot storage iteration.
g.Go(func() error {
prefix := append(rawdb.SnapshotStoragePrefix, accountHash.Bytes()...)
it := db.NewIterator(prefix, nil)
defer it.Release()
for it.Next() {
if !bytes.HasPrefix(it.Key(), prefix) {
break
}
snapSlots.Add(1)
snapSize.Add(uint64(len(it.Key()) + len(it.Value())))
}
return it.Error()
})
// Goroutine 2: Storage trie walk using the existing inspector.
storageStat := NewLevelStats()
g.Go(func() error {
owner := accountHash
storage, err := New(StorageTrieID(stateRoot, owner, account.Root), triedb)
if err != nil {
return fmt.Errorf("failed to open storage trie: %w", err)
}
in := &inspector{
triedb: triedb,
root: stateRoot,
config: &InspectConfig{NoStorage: true},
accountStat: NewLevelStats(), // unused, but needed by inspector
sem: semaphore.NewWeighted(inspectParallelism),
dumpBuf: bufio.NewWriter(io.Discard),
}
in.recordRootSize(storage, account.Root, storageStat)
in.inspect(storage, storage.root, 0, []byte{}, storageStat)
if err := in.getError(); err != nil {
return err
}
return nil
})
// Progress reporter.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Info("Inspecting contract",
"snapSlots", snapSlots.Load(),
"trieNodes", storageStat.TotalNodes(),
"elapsed", common.PrettyDuration(time.Since(start)))
case <-done:
return
}
}
}()
if err := g.Wait(); err != nil {
close(done)
return err
}
close(done)
// Display results.
fmt.Printf("\n=== Contract Inspection: %s ===\n", address)
fmt.Printf("Account hash: %s\n\n", accountHash)
if len(accountData) == 0 {
fmt.Println("Account snapshot: not found")
} else {
fmt.Printf("Account snapshot: %s\n", common.StorageSize(len(accountData)))
}
fmt.Printf("Snapshot storage: %d slots (%s)\n",
snapSlots.Load(), common.StorageSize(snapSize.Load()))
// Compute trie totals from LevelStats.
var trieTotal, trieSize uint64
for i := 0; i < trieStatLevels; i++ {
short, full, value, size := storageStat.level[i].load()
trieTotal += short + full + value
trieSize += size
}
fmt.Printf("Storage trie: %d nodes (%s)\n", trieTotal, common.StorageSize(trieSize))
// Depth distribution table with node type columns.
fmt.Println("\nStorage Trie Depth Distribution:")
b := new(strings.Builder)
table := tablewriter.NewWriter(b)
table.SetHeader([]string{"Depth", "Short", "Full", "Value", "Nodes", "Size"})
for i := 0; i < trieStatLevels; i++ {
short, full, value, size := storageStat.level[i].load()
total := short + full + value
if total == 0 && size == 0 {
continue
}
table.AppendBulk([][]string{{
fmt.Sprint(i),
fmt.Sprint(short),
fmt.Sprint(full),
fmt.Sprint(value),
fmt.Sprint(total),
common.StorageSize(size).String(),
}})
}
table.Render()
fmt.Print(b.String())
return nil
}
func normalizeInspectConfig(config *InspectConfig) *InspectConfig {
if config == nil {
config = &InspectConfig{}
}
if config.TopN <= 0 {
config.TopN = inspectDefaultTopN
}
if config.DumpPath == "" {
config.DumpPath = "trie-dump.bin"
}
return config
}
func (in *inspector) recordRootSize(trie *Trie, root common.Hash, stat *LevelStats) {
if root == (common.Hash{}) || root == types.EmptyRootHash {
return
}
blob := trie.prevalueTracer.Get(nil)
if len(blob) == 0 {
resolved, err := trie.reader.Node(nil, root)
if err != nil {
log.Error("Failed to read trie root for size accounting", "trie", trie.Hash(), "root", root, "err", err)
return
}
blob = resolved
}
stat.addSize(0, uint64(len(blob)))
}
func (in *inspector) closeDump() error {
var ret error
if in.dumpBuf != nil {
if err := in.dumpBuf.Flush(); err != nil {
ret = errors.Join(ret, fmt.Errorf("failed to flush trie dump %s: %w", in.config.DumpPath, err))
}
}
if in.dumpFile != nil {
if err := in.dumpFile.Close(); err != nil {
ret = errors.Join(ret, fmt.Errorf("failed to close trie dump %s: %w", in.config.DumpPath, err))
}
}
return ret
}
func (in *inspector) setError(err error) {
if err == nil {
return
}
in.errMu.Lock()
defer in.errMu.Unlock()
in.err = errors.Join(in.err, err)
}
func (in *inspector) getError() error {
in.errMu.Lock()
defer in.errMu.Unlock()
return in.err
}
func (in *inspector) hasError() bool {
return in.getError() != nil
}
// trySpawn attempts to run fn in a new goroutine, bounded by the semaphore.
// If a slot is available the goroutine is started and tracked via wg; the
// caller must call wg.Wait() before reading any state written by fn.
// Returns false (and does not start a goroutine) when no slot is available.
func (in *inspector) trySpawn(wg *sync.WaitGroup, fn func()) bool {
if !in.sem.TryAcquire(1) {
return false
}
wg.Add(1)
go func() {
defer in.sem.Release(1)
defer wg.Done()
fn()
}()
return true
}
func (in *inspector) writeDumpRecord(owner common.Hash, s *LevelStats) {
if in.hasError() {
return
}
var buf [inspectDumpRecordSize]byte
copy(buf[:32], owner[:])
off := 32
for i := 0; i < trieStatLevels; i++ {
binary.LittleEndian.PutUint32(buf[off:], uint32(s.level[i].short.Load()))
off += 4
binary.LittleEndian.PutUint32(buf[off:], uint32(s.level[i].full.Load()))
off += 4
binary.LittleEndian.PutUint32(buf[off:], uint32(s.level[i].value.Load()))
off += 4
binary.LittleEndian.PutUint64(buf[off:], s.level[i].size.Load())
off += 8
}
in.dumpMu.Lock()
_, err := in.dumpBuf.Write(buf[:])
in.dumpMu.Unlock()
if err != nil {
in.setError(fmt.Errorf("failed writing trie dump record: %w", err))
}
// Increment counter for storage tries only (not for account trie)
if owner != (common.Hash{}) {
in.storageRecordsWritten.Add(1)
}
}
// inspect walks the trie rooted at n and records node statistics into stat.
// It may spawn goroutines for subtrees, but always waits for them before
// returning — the caller sees a fully-populated stat when inspect returns.
func (in *inspector) inspect(trie *Trie, n node, height uint32, path []byte, stat *LevelStats) {
if n == nil {
return
}
// wg tracks goroutines spawned by this call so we can wait for them
// before returning. This guarantees stat is complete when we return,
// which is critical for storage tries that write their dump record
// immediately after inspect returns.
var wg sync.WaitGroup
// Four types of nodes can be encountered:
// - short: extend path with key, inspect single value.
// - full: inspect all 17 children, spin up new threads when possible.
// - hash: need to resolve node from disk, retry inspect on result.
// - value: if account, begin inspecting storage trie.
switch n := (n).(type) {
case *shortNode:
nextPath := slices.Concat(path, n.Key)
in.inspect(trie, n.Val, height+1, nextPath, stat)
case *fullNode:
for idx, child := range n.Children {
if child == nil {
continue
}
childPath := slices.Concat(path, []byte{byte(idx)})
childNode := child
if in.trySpawn(&wg, func() {
in.inspect(trie, childNode, height+1, childPath, stat)
}) {
continue
}
in.inspect(trie, childNode, height+1, childPath, stat)
}
case hashNode:
blob, err := trie.reader.Node(path, common.BytesToHash(n))
if err != nil {
log.Error("Failed to resolve HashNode", "err", err, "trie", trie.Hash(), "height", height+1, "path", path)
return
}
stat.addSize(height, uint64(len(blob)))
resolved := mustDecodeNode(n, blob)
in.inspect(trie, resolved, height, path, stat)
// Return early here so this level isn't recorded twice.
return
case valueNode:
if !hasTerm(path) {
break
}
var account types.StateAccount
if err := rlp.Decode(bytes.NewReader(n), &account); err != nil {
// Not an account value.
break
}
if account.Root == (common.Hash{}) || account.Root == types.EmptyRootHash {
// Account is empty, nothing further to inspect.
break
}
if !in.config.NoStorage {
owner := common.BytesToHash(hexToCompact(path))
storage, err := New(StorageTrieID(in.root, owner, account.Root), in.triedb)
if err != nil {
log.Error("Failed to open account storage trie", "node", n, "error", err, "height", height, "path", common.Bytes2Hex(path))
break
}
storageStat := NewLevelStats()
run := func() {
in.recordRootSize(storage, account.Root, storageStat)
in.inspect(storage, storage.root, 0, []byte{}, storageStat)
in.writeDumpRecord(owner, storageStat)
}
if in.trySpawn(&wg, run) {
break
}
run()
}
default:
panic(fmt.Sprintf("%T: invalid node: %v", n, n))
}
// Wait for all goroutines spawned at this level before recording
// the current node. This ensures the entire subtree is counted
// before this call returns.
wg.Wait()
// Record stats for current height.
stat.add(n, height)
}
// Summarize performs pass 2 over a trie dump and reports account stats,
// aggregate storage statistics, and top-N rankings.
func Summarize(dumpPath string, config *InspectConfig) error {
config = normalizeInspectConfig(config)
if dumpPath == "" {
dumpPath = config.DumpPath
}
if dumpPath == "" {
return errors.New("missing dump path")
}
file, err := os.Open(dumpPath)
if err != nil {
return fmt.Errorf("failed to open trie dump %s: %w", dumpPath, err)
}
defer file.Close()
if info, err := file.Stat(); err == nil {
if info.Size()%inspectDumpRecordSize != 0 {
return fmt.Errorf("invalid trie dump size %d (not a multiple of %d)", info.Size(), inspectDumpRecordSize)
}
}
depthTop := newStorageStatsTopN(config.TopN, compareStorageStatsByDepth)
totalTop := newStorageStatsTopN(config.TopN, compareStorageStatsByTotal)
valueTop := newStorageStatsTopN(config.TopN, compareStorageStatsByValue)
summary := &inspectSummary{}
reader := bufio.NewReaderSize(file, 1<<20)
var buf [inspectDumpRecordSize]byte
for {
_, err := io.ReadFull(reader, buf[:])
if errors.Is(err, io.EOF) {
break
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return fmt.Errorf("truncated trie dump %s", dumpPath)
}
if err != nil {
return fmt.Errorf("failed reading trie dump %s: %w", dumpPath, err)
}
record := decodeDumpRecord(buf[:])
snapshot := newStorageStats(record.Owner, record.Levels)
if record.Owner == (common.Hash{}) {
summary.Account = snapshot
continue
}
summary.StorageCount++
summary.DepthHistogram[snapshot.MaxDepth]++
for i := 0; i < trieStatLevels; i++ {
summary.StorageLevels[i].Short += record.Levels[i].Short
summary.StorageLevels[i].Full += record.Levels[i].Full
summary.StorageLevels[i].Value += record.Levels[i].Value
summary.StorageLevels[i].Size += record.Levels[i].Size
}
depthTop.TryInsert(snapshot)
totalTop.TryInsert(snapshot)
valueTop.TryInsert(snapshot)
}
if summary.Account == nil {
return fmt.Errorf("dump file %s does not contain the account trie sentinel record", dumpPath)
}
for i := 0; i < trieStatLevels; i++ {
summary.StorageTotals.Short += summary.StorageLevels[i].Short
summary.StorageTotals.Full += summary.StorageLevels[i].Full
summary.StorageTotals.Value += summary.StorageLevels[i].Value
summary.StorageTotals.Size += summary.StorageLevels[i].Size
}
summary.TopByDepth = depthTop.Sorted()
summary.TopByTotalNodes = totalTop.Sorted()
summary.TopByValueNodes = valueTop.Sorted()
if config.Path != "" {
return summary.writeJSON(config.Path)
}
summary.display()
return nil
}
type dumpRecord struct {
Owner common.Hash
Levels [trieStatLevels]jsonLevel
}
func decodeDumpRecord(raw []byte) dumpRecord {
var (
record dumpRecord
off = 32
)
copy(record.Owner[:], raw[:32])
for i := 0; i < trieStatLevels; i++ {
record.Levels[i] = jsonLevel{
Short: uint64(binary.LittleEndian.Uint32(raw[off:])),
Full: uint64(binary.LittleEndian.Uint32(raw[off+4:])),
Value: uint64(binary.LittleEndian.Uint32(raw[off+8:])),
Size: binary.LittleEndian.Uint64(raw[off+12:]),
}
off += 20
}
return record
}
type storageStats struct {
Owner common.Hash
Levels [trieStatLevels]jsonLevel
Summary jsonLevel
MaxDepth int
TotalNodes uint64
TotalSize uint64
}
func newStorageStats(owner common.Hash, levels [trieStatLevels]jsonLevel) *storageStats {
snapshot := &storageStats{Owner: owner, Levels: levels}
for i := 0; i < trieStatLevels; i++ {
level := levels[i]
if level.Short != 0 || level.Full != 0 || level.Value != 0 {
snapshot.MaxDepth = i
}
snapshot.Summary.Short += level.Short
snapshot.Summary.Full += level.Full
snapshot.Summary.Value += level.Value
snapshot.Summary.Size += level.Size
}
snapshot.TotalNodes = snapshot.Summary.Short + snapshot.Summary.Full + snapshot.Summary.Value
snapshot.TotalSize = snapshot.Summary.Size
return snapshot
}
func trimLevels(levels [trieStatLevels]jsonLevel) []jsonLevel {
n := len(levels)
for n > 0 && levels[n-1] == (jsonLevel{}) {
n--
}
return levels[:n]
}
func (s *storageStats) MarshalJSON() ([]byte, error) {
type jsonStorageSnapshot struct {
Owner common.Hash `json:"Owner"`
MaxDepth int `json:"MaxDepth"`
TotalNodes uint64 `json:"TotalNodes"`
TotalSize uint64 `json:"TotalSize"`
ValueNodes uint64 `json:"ValueNodes"`
Levels []jsonLevel `json:"Levels"`
Summary jsonLevel `json:"Summary"`
}
return json.Marshal(jsonStorageSnapshot{
Owner: s.Owner,
MaxDepth: s.MaxDepth,
TotalNodes: s.TotalNodes,
TotalSize: s.TotalSize,
ValueNodes: s.Summary.Value,
Levels: trimLevels(s.Levels),
Summary: s.Summary,
})
}
func (s *storageStats) toLevelStats() *LevelStats {
stats := NewLevelStats()
for i := 0; i < trieStatLevels; i++ {
stats.level[i].short.Store(s.Levels[i].Short)
stats.level[i].full.Store(s.Levels[i].Full)
stats.level[i].value.Store(s.Levels[i].Value)
stats.level[i].size.Store(s.Levels[i].Size)
}
return stats
}
type storageStatsCompare func(a, b *storageStats) int
type storageStatsTopN struct {
limit int
cmp storageStatsCompare
heap storageStatsHeap
}
type storageStatsHeap struct {
items []*storageStats
cmp storageStatsCompare
}
func (h storageStatsHeap) Len() int { return len(h.items) }
func (h storageStatsHeap) Less(i, j int) bool {
// Keep the weakest entry at the root (min-heap semantics).
return h.cmp(h.items[i], h.items[j]) < 0
}
func (h storageStatsHeap) Swap(i, j int) { h.items[i], h.items[j] = h.items[j], h.items[i] }
func (h *storageStatsHeap) Push(x any) {
h.items = append(h.items, x.(*storageStats))
}
func (h *storageStatsHeap) Pop() any {
item := h.items[len(h.items)-1]
h.items = h.items[:len(h.items)-1]
return item
}
func newStorageStatsTopN(limit int, cmp storageStatsCompare) *storageStatsTopN {
h := storageStatsHeap{cmp: cmp}
heap.Init(&h)
return &storageStatsTopN{limit: limit, cmp: cmp, heap: h}
}
func (t *storageStatsTopN) TryInsert(item *storageStats) {
if t.limit <= 0 {
return
}
if t.heap.Len() < t.limit {
heap.Push(&t.heap, item)
return
}
if t.cmp(item, t.heap.items[0]) <= 0 {
return
}
heap.Pop(&t.heap)
heap.Push(&t.heap, item)
}
func (t *storageStatsTopN) Sorted() []*storageStats {
out := append([]*storageStats(nil), t.heap.items...)
sort.Slice(out, func(i, j int) bool { return t.cmp(out[i], out[j]) > 0 })
return out
}
func compareStorageStatsByDepth(a, b *storageStats) int {
return cmp.Or(
cmp.Compare(a.MaxDepth, b.MaxDepth),
cmp.Compare(a.TotalNodes, b.TotalNodes),
cmp.Compare(a.Summary.Value, b.Summary.Value),
bytes.Compare(a.Owner[:], b.Owner[:]),
)
}
func compareStorageStatsByTotal(a, b *storageStats) int {
return cmp.Or(
cmp.Compare(a.TotalNodes, b.TotalNodes),
cmp.Compare(a.MaxDepth, b.MaxDepth),
cmp.Compare(a.Summary.Value, b.Summary.Value),
bytes.Compare(a.Owner[:], b.Owner[:]),
)
}
func compareStorageStatsByValue(a, b *storageStats) int {
return cmp.Or(
cmp.Compare(a.Summary.Value, b.Summary.Value),
cmp.Compare(a.MaxDepth, b.MaxDepth),
cmp.Compare(a.TotalNodes, b.TotalNodes),
bytes.Compare(a.Owner[:], b.Owner[:]),
)
}
type inspectSummary struct {
Account *storageStats
StorageCount uint64
StorageTotals jsonLevel
StorageLevels [trieStatLevels]jsonLevel
DepthHistogram [trieStatLevels]uint64
TopByDepth []*storageStats
TopByTotalNodes []*storageStats
TopByValueNodes []*storageStats
}
func (s *inspectSummary) display() {
s.displayCombinedDepthTable()
s.Account.toLevelStats().display("Accounts trie")
fmt.Println("Storage trie aggregate summary")
fmt.Printf("Total storage tries: %d\n", s.StorageCount)
totalNodes := s.StorageTotals.Short + s.StorageTotals.Full + s.StorageTotals.Value
fmt.Printf("Total nodes: %d\n", totalNodes)
fmt.Printf("Total size: %s\n", common.StorageSize(s.StorageTotals.Size))
fmt.Printf(" Short nodes: %d\n", s.StorageTotals.Short)
fmt.Printf(" Full nodes: %d\n", s.StorageTotals.Full)
fmt.Printf(" Value nodes: %d\n", s.StorageTotals.Value)
b := new(strings.Builder)
table := tablewriter.NewWriter(b)
table.SetHeader([]string{"Max Depth", "Storage Tries"})
for i, count := range s.DepthHistogram {
table.AppendBulk([][]string{{fmt.Sprint(i), fmt.Sprint(count)}})
}
table.Render()
fmt.Print(b.String())
fmt.Println()
s.displayTop("Top storage tries by max depth", s.TopByDepth)
s.displayTop("Top storage tries by total node count", s.TopByTotalNodes)
s.displayTop("Top storage tries by value (slot) count", s.TopByValueNodes)
}
func (s *inspectSummary) displayCombinedDepthTable() {
accountTotal := s.Account.Summary.Short + s.Account.Summary.Full + s.Account.Summary.Value
storageTotal := s.StorageTotals.Short + s.StorageTotals.Full + s.StorageTotals.Value
accountTotalSize := s.Account.Summary.Size
storageTotalSize := s.StorageTotals.Size
fmt.Println("Trie Depth Distribution")
fmt.Printf("Account Trie: %d nodes (%s)\n", accountTotal, common.StorageSize(accountTotalSize))
fmt.Printf("Storage Tries: %d nodes (%s) across %d tries\n", storageTotal, common.StorageSize(storageTotalSize), s.StorageCount)
b := new(strings.Builder)
table := tablewriter.NewWriter(b)
table.SetHeader([]string{"Depth", "Account Nodes", "Account Size", "Storage Nodes", "Storage Size"})
for i := 0; i < trieStatLevels; i++ {
accountNodes := s.Account.Levels[i].Short + s.Account.Levels[i].Full + s.Account.Levels[i].Value
accountSize := s.Account.Levels[i].Size
storageNodes := s.StorageLevels[i].Short + s.StorageLevels[i].Full + s.StorageLevels[i].Value
storageSize := s.StorageLevels[i].Size
if accountNodes == 0 && storageNodes == 0 {
continue
}
table.AppendBulk([][]string{{
fmt.Sprint(i),
fmt.Sprint(accountNodes),
common.StorageSize(accountSize).String(),
fmt.Sprint(storageNodes),
common.StorageSize(storageSize).String(),
}})
}
table.Render()
fmt.Print(b.String())
fmt.Println()
}
func (s *inspectSummary) displayTop(title string, list []*storageStats) {
fmt.Println(title)
if len(list) == 0 {
fmt.Println("No storage tries found")
fmt.Println()
return
}
for i, item := range list {
fmt.Printf("%d: %s\n", i+1, item.Owner)
item.toLevelStats().display("storage trie")
}
}
func (s *inspectSummary) MarshalJSON() ([]byte, error) {
type jsonAccountTrie struct {
Name string `json:"Name"`
Levels []jsonLevel `json:"Levels"`
Summary jsonLevel `json:"Summary"`
}
type jsonStorageSummary struct {
TotalStorageTries uint64 `json:"TotalStorageTries"`
Totals jsonLevel `json:"Totals"`
Levels []jsonLevel `json:"Levels"`
DepthHistogram [trieStatLevels]uint64 `json:"DepthHistogram"`
}
type jsonInspectSummary struct {
AccountTrie jsonAccountTrie `json:"AccountTrie"`
StorageSummary jsonStorageSummary `json:"StorageSummary"`
TopByDepth []*storageStats `json:"TopByDepth"`
TopByTotalNodes []*storageStats `json:"TopByTotalNodes"`
TopByValueNodes []*storageStats `json:"TopByValueNodes"`
}
return json.Marshal(jsonInspectSummary{
AccountTrie: jsonAccountTrie{
Name: "account trie",
Levels: trimLevels(s.Account.Levels),
Summary: s.Account.Summary,
},
StorageSummary: jsonStorageSummary{
TotalStorageTries: s.StorageCount,
Totals: s.StorageTotals,
Levels: trimLevels(s.StorageLevels),
DepthHistogram: s.DepthHistogram,
},
TopByDepth: s.TopByDepth,
TopByTotalNodes: s.TopByTotalNodes,
TopByValueNodes: s.TopByValueNodes,
})
}
func (s *inspectSummary) writeJSON(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer file.Close()
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
return enc.Encode(s)
}
// display will print a table displaying the trie's node statistics.
func (s *LevelStats) display(title string) {
// Shorten title if too long.
if len(title) > 32 {
title = title[0:8] + "..." + title[len(title)-8:]
}
b := new(strings.Builder)
table := tablewriter.NewWriter(b)
table.SetHeader([]string{title, "Level", "Short Nodes", "Full Node", "Value Node"})
stat := &stat{}
for i := range s.level {
if s.level[i].empty() {
continue
}
short, full, value, _ := s.level[i].load()
table.AppendBulk([][]string{{"-", fmt.Sprint(i), fmt.Sprint(short), fmt.Sprint(full), fmt.Sprint(value)}})
stat.add(&s.level[i])
}
short, full, value, _ := stat.load()
table.SetFooter([]string{"Total", "", fmt.Sprint(short), fmt.Sprint(full), fmt.Sprint(value)})
table.Render()
fmt.Print(b.String())
fmt.Println("Max depth", s.MaxDepth())
fmt.Println()
}
type jsonLevel struct {
Short uint64
Full uint64
Value uint64
Size uint64
}

256
trie/inspect_test.go Normal file
View file

@ -0,0 +1,256 @@
// 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 <http://www.gnu.org/licenses/>.
package trie
import (
"encoding/json"
"math/rand"
"os"
"path/filepath"
"reflect"
"testing"
"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/crypto"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/trie/trienode"
"github.com/holiman/uint256"
)
// TestInspect inspects a randomly generated account trie. It's useful for
// quickly verifying changes to the results display.
func TestInspect(t *testing.T) {
db := newTestDatabase(rawdb.NewMemoryDatabase(), rawdb.HashScheme)
trie, err := NewStateTrie(TrieID(types.EmptyRootHash), db)
if err != nil {
t.Fatalf("failed to create state trie: %v", err)
}
// Create a realistic looking account trie with storage.
addresses, accounts := makeAccountsWithStorage(db, 11, true)
for i := 0; i < len(addresses); i++ {
trie.MustUpdate(crypto.Keccak256(addresses[i][:]), accounts[i])
}
// Insert the accounts into the trie and hash it
root, nodes := trie.Commit(true)
db.Update(root, types.EmptyRootHash, trienode.NewWithNodeSet(nodes))
db.Commit(root)
tempDir := t.TempDir()
dumpPath := filepath.Join(tempDir, "trie-dump.bin")
if err := Inspect(db, root, &InspectConfig{
TopN: 1,
DumpPath: dumpPath,
Path: filepath.Join(tempDir, "trie-summary.json"),
}); err != nil {
t.Fatalf("inspect failed: %v", err)
}
reanalysisPath := filepath.Join(tempDir, "trie-summary-reanalysis.json")
if err := Summarize(dumpPath, &InspectConfig{
TopN: 1,
Path: reanalysisPath,
}); err != nil {
t.Fatalf("summarize failed: %v", err)
}
inspectSummaryPath := filepath.Join(tempDir, "trie-summary.json")
inspectOut := loadInspectJSON(t, inspectSummaryPath)
reanalysisOut := loadInspectJSON(t, reanalysisPath)
if len(inspectOut.StorageSummary.Levels) == 0 {
t.Fatal("expected StorageSummary.Levels to be populated")
}
if inspectOut.AccountTrie.Summary.Size == 0 {
t.Fatal("expected account trie size summary to be populated")
}
if inspectOut.StorageSummary.Totals.Size == 0 {
t.Fatal("expected storage trie size summary to be populated")
}
if !reflect.DeepEqual(inspectOut.AccountTrie, reanalysisOut.AccountTrie) {
t.Fatal("account trie summary mismatch between inspect and summarize")
}
if !reflect.DeepEqual(inspectOut.StorageSummary, reanalysisOut.StorageSummary) {
t.Fatal("storage summary mismatch between inspect and summarize")
}
assertStorageTotalsMatchLevels(t, inspectOut)
assertStorageTotalsMatchLevels(t, reanalysisOut)
assertAccountTotalsMatchLevels(t, inspectOut.AccountTrie)
assertAccountTotalsMatchLevels(t, reanalysisOut.AccountTrie)
var histogramTotal uint64
for _, count := range inspectOut.StorageSummary.DepthHistogram {
histogramTotal += count
}
if histogramTotal != inspectOut.StorageSummary.TotalStorageTries {
t.Fatalf("depth histogram total %d does not match total storage tries %d", histogramTotal, inspectOut.StorageSummary.TotalStorageTries)
}
}
type inspectJSONOutput struct {
// Reuse storageStats for AccountTrie JSON to avoid introducing a parallel
// account summary test type. AccountTrie JSON includes Levels+Summary,
// which map directly; other storageStats fields remain zero-values.
AccountTrie storageStats `json:"AccountTrie"`
StorageSummary struct {
TotalStorageTries uint64 `json:"TotalStorageTries"`
Totals jsonLevel `json:"Totals"`
Levels []jsonLevel `json:"Levels"`
DepthHistogram [trieStatLevels]uint64 `json:"DepthHistogram"`
} `json:"StorageSummary"`
}
func loadInspectJSON(t *testing.T, path string) inspectJSONOutput {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read %s: %v", path, err)
}
var out inspectJSONOutput
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("failed to decode %s: %v", path, err)
}
return out
}
func assertStorageTotalsMatchLevels(t *testing.T, out inspectJSONOutput) {
t.Helper()
var fromLevels jsonLevel
for _, level := range out.StorageSummary.Levels {
fromLevels.Short += level.Short
fromLevels.Full += level.Full
fromLevels.Value += level.Value
fromLevels.Size += level.Size
}
if fromLevels.Short != out.StorageSummary.Totals.Short || fromLevels.Full != out.StorageSummary.Totals.Full || fromLevels.Value != out.StorageSummary.Totals.Value || fromLevels.Size != out.StorageSummary.Totals.Size {
t.Fatalf("storage totals mismatch: levels=%+v totals=%+v", fromLevels, out.StorageSummary.Totals)
}
}
func assertAccountTotalsMatchLevels(t *testing.T, account storageStats) {
t.Helper()
var fromLevels jsonLevel
for _, level := range account.Levels {
fromLevels.Short += level.Short
fromLevels.Full += level.Full
fromLevels.Value += level.Value
fromLevels.Size += level.Size
}
if fromLevels.Short != account.Summary.Short || fromLevels.Full != account.Summary.Full || fromLevels.Value != account.Summary.Value || fromLevels.Size != account.Summary.Size {
t.Fatalf("account totals mismatch: levels=%+v totals=%+v", fromLevels, account.Summary)
}
}
// TestInspectContract tests the InspectContract function on a single account
// with storage and snapshot data.
func TestInspectContract(t *testing.T) {
diskdb := rawdb.NewMemoryDatabase()
db := newTestDatabase(diskdb, rawdb.HashScheme)
// Create a contract address and its storage trie.
address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
accountHash := crypto.Keccak256Hash(address.Bytes())
// Build a storage trie with some entries.
storageTrie := NewEmpty(db)
storageSlots := make(map[common.Hash][]byte)
for i := 0; i < 10; i++ {
k := crypto.Keccak256Hash([]byte{byte(i)})
v := []byte{byte(i + 1)}
storageTrie.MustUpdate(k.Bytes(), v)
storageSlots[k] = v
}
storageRoot, storageNodes := storageTrie.Commit(true)
db.Update(storageRoot, types.EmptyRootHash, trienode.NewWithNodeSet(storageNodes))
db.Commit(storageRoot)
// Build the account trie with the contract account.
account := types.StateAccount{
Nonce: 1,
Balance: uint256.NewInt(1000),
Root: storageRoot,
CodeHash: crypto.Keccak256(nil),
}
accountRLP, err := rlp.EncodeToBytes(&account)
if err != nil {
t.Fatalf("failed to encode account: %v", err)
}
accountTrie := NewEmpty(db)
accountTrie.MustUpdate(crypto.Keccak256(address.Bytes()), accountRLP)
stateRoot, accountNodes := accountTrie.Commit(true)
db.Update(stateRoot, types.EmptyRootHash, trienode.NewWithNodeSet(accountNodes))
db.Commit(stateRoot)
// Write snapshot data for the account and its storage slots.
rawdb.WriteAccountSnapshot(diskdb, accountHash, accountRLP)
for k, v := range storageSlots {
rawdb.WriteStorageSnapshot(diskdb, accountHash, k, v)
}
// InspectContract should succeed without error.
if err := InspectContract(db, diskdb, stateRoot, address); err != nil {
t.Fatalf("InspectContract failed: %v", err)
}
}
func makeAccountsWithStorage(db *testDb, size int, storage bool) (addresses [][20]byte, accounts [][]byte) {
// Make the random benchmark deterministic
random := rand.New(rand.NewSource(0))
addresses = make([][20]byte, size)
for i := 0; i < len(addresses); i++ {
data := make([]byte, 20)
random.Read(data)
copy(addresses[i][:], data)
}
accounts = make([][]byte, len(addresses))
for i := 0; i < len(accounts); i++ {
var (
nonce = uint64(random.Int63())
root = types.EmptyRootHash
code = crypto.Keccak256(nil)
)
if storage {
trie := NewEmpty(db)
for range random.Uint32()%256 + 1 { // non-zero
k, v := make([]byte, 32), make([]byte, 32)
random.Read(k)
random.Read(v)
trie.MustUpdate(k, v)
}
var nodes *trienode.NodeSet
root, nodes = trie.Commit(true)
db.Update(root, types.EmptyRootHash, trienode.NewWithNodeSet(nodes))
db.Commit(root)
}
numBytes := random.Uint32() % 33 // [0, 32] bytes
balanceBytes := make([]byte, numBytes)
random.Read(balanceBytes)
balance := new(uint256.Int).SetBytes(balanceBytes)
data, _ := rlp.EncodeToBytes(&types.StateAccount{
Nonce: nonce,
Balance: balance,
Root: root,
CodeHash: code,
})
accounts[i] = data
}
return addresses, accounts
}

123
trie/levelstats.go Normal file
View file

@ -0,0 +1,123 @@
// 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 <http://www.gnu.org/licenses/>.
package trie
import (
"fmt"
"sync/atomic"
)
const trieStatLevels = 16
// LevelStats tracks the type and count of trie nodes at each level in a trie.
//
// Note: theoretically it is possible to have up to 64 trie levels, but
// LevelStats supports exactly 16 levels and panics on deeper paths.
type LevelStats struct {
level [trieStatLevels]stat
}
// NewLevelStats creates an empty trie statistics collector.
func NewLevelStats() *LevelStats {
return &LevelStats{}
}
// MaxDepth iterates each level and finds the deepest level with at least one
// trie node.
func (s *LevelStats) MaxDepth() int {
depth := 0
for i := range s.level {
if s.level[i].short.Load() != 0 || s.level[i].full.Load() != 0 || s.level[i].value.Load() != 0 {
depth = i
}
}
return depth
}
// TotalNodes returns the total number of nodes across all levels and types.
func (s *LevelStats) TotalNodes() uint64 {
var total uint64
for i := range s.level {
total += s.level[i].short.Load() + s.level[i].full.Load() + s.level[i].value.Load()
}
return total
}
// add increases the node count by one for the specified node type and depth.
func (s *LevelStats) add(n node, depth uint32) {
d := int(depth)
switch (n).(type) {
case *shortNode:
s.level[d].short.Add(1)
case *fullNode:
s.level[d].full.Add(1)
case valueNode:
s.level[d].value.Add(1)
default:
panic(fmt.Sprintf("%T: invalid node: %v", n, n))
}
}
// addSize increases the raw byte-size tally at the specified depth.
func (s *LevelStats) addSize(depth uint32, size uint64) {
s.level[depth].size.Add(size)
}
// AddLeaf records a leaf depth. Witness collection reuses the value-node bucket
// for leaf accounting. It panics if the depth is outside [0, 15].
func (s *LevelStats) AddLeaf(depth int) {
s.level[depth].value.Add(1)
}
// LeafDepths returns leaf counts grouped by depth.
func (s *LevelStats) LeafDepths() [trieStatLevels]int64 {
var leaves [trieStatLevels]int64
for i := range s.level {
leaves[i] = int64(s.level[i].value.Load())
}
return leaves
}
// stat is a specific level's count of each node type.
type stat struct {
short atomic.Uint64
full atomic.Uint64
value atomic.Uint64
size atomic.Uint64
}
// empty is a helper that returns whether there are any trie nodes at the level.
func (s *stat) empty() bool {
if s.full.Load() == 0 && s.short.Load() == 0 && s.value.Load() == 0 && s.size.Load() == 0 {
return true
}
return false
}
// load is a helper that loads each node type's value.
func (s *stat) load() (uint64, uint64, uint64, uint64) {
return s.short.Load(), s.full.Load(), s.value.Load(), s.size.Load()
}
// add is a helper that adds two level's stats together.
func (s *stat) add(other *stat) *stat {
s.short.Add(other.short.Load())
s.full.Add(other.full.Load())
s.value.Add(other.value.Load())
s.size.Add(other.size.Load())
return s
}

37
trie/levelstats_test.go Normal file
View file

@ -0,0 +1,37 @@
// 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 <http://www.gnu.org/licenses/>.
package trie
import "testing"
func TestLevelStatsAddLeafDepthBounds(t *testing.T) {
stats := NewLevelStats()
stats.AddLeaf(15)
if got := stats.LeafDepths()[15]; got != 1 {
t.Fatalf("leaf count at depth 15 = %d, want 1", got)
}
}
func TestLevelStatsAddLeafPanicsOnDepth16(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for depth >= 16")
}
}()
NewLevelStats().AddLeaf(16)
}