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")
+ }
+}