diff --git a/triedb/database.go b/triedb/database.go index ef95169df1..7d38b814de 100644 --- a/triedb/database.go +++ b/triedb/database.go @@ -142,6 +142,13 @@ func (db *Database) HistoricStateReader(root common.Hash) (*pathdb.HistoricalSta return pdb.HistoricReader(root) } +// PathDB returns the underlying pathdb-backed database if this database is +// using the path-based scheme. Returns nil for hashdb or other backends. +func (db *Database) PathDB() *pathdb.Database { + pdb, _ := db.backend.(*pathdb.Database) + return pdb +} + // HistoricNodeReader constructs a reader for accessing the historical trie node. func (db *Database) HistoricNodeReader(root common.Hash) (*pathdb.HistoricalNodeReader, error) { pdb, ok := db.backend.(*pathdb.Database) diff --git a/triedb/pathdb/history_index.go b/triedb/pathdb/history_index.go index eee4c10273..d83c147b5d 100644 --- a/triedb/pathdb/history_index.go +++ b/triedb/pathdb/history_index.go @@ -124,6 +124,51 @@ func (r *indexReader) refresh() error { return nil } +// count returns the total number of indexed entries across all descriptor +// blocks. +func (r *indexReader) count() int { + var n int + for _, d := range r.descList { + n += int(d.entries) + } + return n +} + +// at returns the i-th indexed history id in ascending order. The caller must +// guarantee 0 <= i < r.count(). +func (r *indexReader) at(i int) (uint64, error) { + if i < 0 { + return 0, fmt.Errorf("negative ordinal %d", i) + } + remaining := i + for _, desc := range r.descList { + n := int(desc.entries) + if remaining < n { + br, err := r.blockReader(desc.id) + if err != nil { + return 0, err + } + return br.readAt(remaining, n) + } + remaining -= n + } + return 0, fmt.Errorf("ordinal out of range, i: %d, total: %d", i, r.count()) +} + +// blockReader returns a cached block reader for the given block id, loading it +// from disk on first access. +func (r *indexReader) blockReader(id uint32) (*blockReader, error) { + if br, ok := r.readers[id]; ok { + return br, nil + } + br, err := newBlockReader(readStateIndexBlock(r.state, r.db, id), r.bitmapSize != 0) + if err != nil { + return nil, err + } + r.readers[id] = br + return br, nil +} + // readGreaterThan locates the first element that is greater than the specified // id. If no such element is found, MaxUint64 is returned. func (r *indexReader) readGreaterThan(id uint64) (uint64, error) { diff --git a/triedb/pathdb/history_index_block.go b/triedb/pathdb/history_index_block.go index bb823bb13f..a71cc56678 100644 --- a/triedb/pathdb/history_index_block.go +++ b/triedb/pathdb/history_index_block.go @@ -194,6 +194,53 @@ func newBlockReader(blob []byte, hasExt bool) (*blockReader, error) { }, nil } +// readAt returns the element at the given block-local ordinal position. +// The caller must guarantee 0 <= i < entries; entries is the number of +// elements stored in the block as recorded in its descriptor. +func (br *blockReader) readAt(i int, entries int) (uint64, error) { + if i < 0 || i >= entries { + return 0, fmt.Errorf("ordinal out of range, i: %d, entries: %d", i, entries) + } + section := i / indexBlockRestartLen + if section >= len(br.restarts) { + return 0, fmt.Errorf("invalid restart section %d, restarts: %d", section, len(br.restarts)) + } + var ( + pos = int(br.restarts[section]) + limit int + skip = i % indexBlockRestartLen + val uint64 + ) + if section == len(br.restarts)-1 { + limit = len(br.data) + } else { + limit = int(br.restarts[section+1]) + } + for step := 0; step <= skip; step++ { + if pos >= limit { + return 0, fmt.Errorf("ordinal walk past section end, pos: %d, limit: %d, step: %d", pos, limit, step) + } + x, n := binary.Uvarint(br.data[pos:]) + if n <= 0 { + return 0, fmt.Errorf("failed to decode element at pos %d", pos) + } + if step == 0 { + val = x + } else { + val += x + } + pos += n + if br.hasExt { + length, ln := binary.Uvarint(br.data[pos:]) + if ln <= 0 { + return 0, fmt.Errorf("failed to decode extension length at pos %d", pos) + } + pos += ln + int(length) + } + } + return val, nil +} + // readGreaterThan locates the first element in the block that is greater than // the specified value. If no such element is found, MaxUint64 is returned. func (br *blockReader) readGreaterThan(id uint64) (uint64, error) { diff --git a/triedb/pathdb/history_index_iterator.go b/triedb/pathdb/history_index_iterator.go index e4aca24f5d..2e570ea152 100644 --- a/triedb/pathdb/history_index_iterator.go +++ b/triedb/pathdb/history_index_iterator.go @@ -446,14 +446,9 @@ type indexIterator struct { // newBlockIter initializes the block iterator with the specified block ID. func (r *indexReader) newBlockIter(id uint32, filter *extFilter) (*blockIterator, error) { - br, ok := r.readers[id] - if !ok { - var err error - br, err = newBlockReader(readStateIndexBlock(r.state, r.db, id), r.bitmapSize != 0) - if err != nil { - return nil, err - } - r.readers[id] = br + br, err := r.blockReader(id) + if err != nil { + return nil, err } return br.newIterator(filter), nil } diff --git a/triedb/pathdb/history_index_test.go b/triedb/pathdb/history_index_test.go index 68cfa8903a..291508b460 100644 --- a/triedb/pathdb/history_index_test.go +++ b/triedb/pathdb/history_index_test.go @@ -208,6 +208,52 @@ func testIndexWriterWithLimit(t *testing.T, bitmapSize int) { } } +func TestIndexReaderOrdinalAccess(t *testing.T) { + testIndexReaderOrdinalAccess(t, 0) + testIndexReaderOrdinalAccess(t, 2) + testIndexReaderOrdinalAccess(t, 34) +} + +func testIndexReaderOrdinalAccess(t *testing.T, bitmapSize int) { + // Build a sequence long enough to span multiple restart sections and + // index blocks, with varying gaps to exercise the delta decoding. + var ( + elements []uint64 + id uint64 + ) + for i := 0; i < 10_000; i++ { + id += uint64(i%7) + 1 + elements = append(elements, id) + } + db := rawdb.NewMemoryDatabase() + iw, _ := newIndexWriter(db, newAccountIdent(common.Hash{0xa}), 0, bitmapSize) + for _, v := range elements { + if err := iw.append(v, randomExt(bitmapSize, 5)); err != nil { + t.Fatalf("Failed to append %d: %v", v, err) + } + } + batch := db.NewBatch() + iw.finish(batch) + batch.Write() + + r, err := newIndexReader(db, newAccountIdent(common.Hash{0xa}), bitmapSize) + if err != nil { + t.Fatalf("Failed to construct index reader: %v", err) + } + if got, want := r.count(), len(elements); got != want { + t.Fatalf("count mismatch: got %d, want %d", got, want) + } + for i, want := range elements { + got, err := r.at(i) + if err != nil { + t.Fatalf("at(%d) returned error: %v", i, err) + } + if got != want { + t.Fatalf("at(%d) = %d, want %d", i, got, want) + } + } +} + func TestIndexDeleterBasic(t *testing.T) { testIndexDeleterBasic(t, 0) testIndexDeleterBasic(t, 2) diff --git a/triedb/pathdb/history_lookup.go b/triedb/pathdb/history_lookup.go new file mode 100644 index 0000000000..a3a5c42403 --- /dev/null +++ b/triedb/pathdb/history_lookup.go @@ -0,0 +1,156 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "errors" + "fmt" + + "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" +) + +// ErrStateHistoryNotIndexed is returned when the state history index is not +// available on this database, either because the indexer is disabled, the +// freezer is not configured, or the initial build has not yet finished. +var ErrStateHistoryNotIndexed = errors.New("state history is not indexed") + +// ErrStateHistoryPruned is returned when the requested state history id has +// been pruned from the freezer tail. +var ErrStateHistoryPruned = errors.New("state history has been pruned") + +// HistoryIndexReader provides random-access reads over the sorted list of +// state-history ids at which a single state element was modified. The +// sequence is strictly increasing. +type HistoryIndexReader interface { + // Count returns the total number of indexed modifications. + Count() int + + // At returns the i-th history id. The caller must ensure 0 <= i < Count(). + At(i int) (uint64, error) +} + +// accountHistoryIndexReader exposes ordinal access over an account's history +// index. Not safe for concurrent use. +type accountHistoryIndexReader struct { + reader *indexReader +} + +// Count implements HistoryIndexReader. +func (r *accountHistoryIndexReader) Count() int { + return r.reader.count() +} + +// At implements HistoryIndexReader. +func (r *accountHistoryIndexReader) At(i int) (uint64, error) { + return r.reader.at(i) +} + +// AccountHistoryIndex returns a random-access reader over the state-history +// ids at which the given account was modified, or ErrStateHistoryNotIndexed +// if the index is unavailable. The returned reader is not safe for +// concurrent use. +func (db *Database) AccountHistoryIndex(addr common.Address) (HistoryIndexReader, error) { + if err := db.checkStateIndexerReady(); err != nil { + return nil, err + } + ident := newAccountIdent(crypto.Keccak256Hash(addr.Bytes())) + r, err := newIndexReader(db.diskdb, ident, 0) + if err != nil { + return nil, err + } + return &accountHistoryIndexReader{reader: r}, nil +} + +// LastIndexedBlockNumber returns the block number of the most recently +// indexed state history; blocks above it are not yet covered by the index. +// Returns 0 with nil error if the indexer is ready but has produced no +// entries yet. +func (db *Database) LastIndexedBlockNumber() (uint64, error) { + if err := db.checkStateIndexerReady(); err != nil { + return 0, err + } + metadata := loadIndexMetadata(db.diskdb, typeStateHistory) + if metadata == nil || metadata.Last == 0 { + return 0, nil + } + m, err := readStateHistoryMeta(db.stateFreezer, metadata.Last) + if err != nil { + return 0, err + } + return m.block, nil +} + +// BlockNumberAt returns the block number associated with the given +// state-history id. Returns ErrStateHistoryPruned if the id falls at or below +// the current freezer tail. +func (db *Database) BlockNumberAt(historyID uint64) (uint64, error) { + if db.stateFreezer == nil { + return 0, ErrStateHistoryNotIndexed + } + tail, err := db.stateFreezer.Tail(rawdb.DefaultHistoryGroup) + if err != nil { + return 0, err + } + if historyID <= tail { + return 0, ErrStateHistoryPruned + } + m, err := readStateHistoryMeta(db.stateFreezer, historyID) + if err != nil { + return 0, err + } + return m.block, nil +} + +// HistoricAccount returns the pre-state of addr at the given history id, +// i.e., the account state at the start of the block recorded by that +// history. Returns nil if the account did not exist at that point. The id +// must correspond to an entry where addr was actually modified (typically +// one drawn from AccountHistoryIndex(addr)). +func (db *Database) HistoricAccount(addr common.Address, historyID uint64) (*types.SlimAccount, error) { + if err := db.checkStateIndexerReady(); err != nil { + return nil, err + } + r := newStateHistoryReader(db.diskdb, db.stateFreezer) + blob, err := r.readAccount(addr, historyID) + if err != nil { + return nil, err + } + if len(blob) == 0 { + return nil, nil + } + account := new(types.SlimAccount) + if err := rlp.DecodeBytes(blob, account); err != nil { + return nil, fmt.Errorf("failed to decode account: %w", err) + } + return account, nil +} + +// checkStateIndexerReady returns nil if the state-history indexer is +// available and the initial build has completed. +func (db *Database) checkStateIndexerReady() error { + if db.stateIndexer == nil || db.stateFreezer == nil { + return ErrStateHistoryNotIndexed + } + if !db.stateIndexer.inited() { + return ErrStateHistoryNotIndexed + } + return nil +} diff --git a/triedb/pathdb/history_lookup_test.go b/triedb/pathdb/history_lookup_test.go new file mode 100644 index 0000000000..1f2ae68153 --- /dev/null +++ b/triedb/pathdb/history_lookup_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package pathdb + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestAccountHistoryIndex_NotIndexed(t *testing.T) { + // Indexing disabled: every accessor should bail with ErrStateHistoryNotIndexed. + env := newTester(t, &testerConfig{layers: 4, enableIndex: false}) + defer env.release() + + var addr common.Address + for h := range env.accounts { + addr = env.accountPreimage(h) + break + } + + if _, err := env.db.AccountHistoryIndex(addr); !errors.Is(err, ErrStateHistoryNotIndexed) { + t.Fatalf("AccountHistoryIndex: want ErrStateHistoryNotIndexed, got %v", err) + } + if _, err := env.db.HistoricAccount(addr, 1); !errors.Is(err, ErrStateHistoryNotIndexed) { + t.Fatalf("HistoricAccount: want ErrStateHistoryNotIndexed, got %v", err) + } +} + +func TestAccountHistoryIndex_Indexed(t *testing.T) { + // Force diff-layer flushes so state history is actually written. + maxDiffLayers = 4 + defer func() { maxDiffLayers = 128 }() + + env := newTester(t, &testerConfig{layers: 32, enableIndex: true}) + defer env.release() + waitIndexing(env.db) + + // Pick any account with at least one indexed entry. + var ( + addr common.Address + idx HistoryIndexReader + ) + for h := range env.accounts { + a := env.accountPreimage(h) + r, err := env.db.AccountHistoryIndex(a) + if err != nil { + t.Fatalf("AccountHistoryIndex(%x): %v", a, err) + } + if r.Count() > 0 { + addr = a + idx = r + break + } + } + if idx == nil { + t.Fatal("no indexed account found across all current-state accounts") + } + + // Every id reported by the index must yield a HistoricAccount read. + for i := 0; i < idx.Count(); i++ { + hid, err := idx.At(i) + if err != nil { + t.Fatalf("idx.At(%d): %v", i, err) + } + if _, err := env.db.HistoricAccount(addr, hid); err != nil { + t.Fatalf("HistoricAccount(addr, %d): %v", hid, err) + } + blockNum, err := env.db.BlockNumberAt(hid) + if err != nil { + t.Fatalf("BlockNumberAt(%d): %v", hid, err) + } + if blockNum >= uint64(len(env.roots)) { + t.Fatalf("BlockNumberAt(%d) = %d, beyond generated range %d", hid, blockNum, len(env.roots)) + } + } + + last, err := env.db.LastIndexedBlockNumber() + if err != nil { + t.Fatalf("LastIndexedBlockNumber: %v", err) + } + if last == 0 { + t.Fatal("LastIndexedBlockNumber returned 0 after waitIndexing") + } +}