From d15a37c7a1b7a19d7477032e264b99ab3b629c7f Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 18 Mar 2026 22:12:40 +0800 Subject: [PATCH 1/9] snapshot: resolve zkStateRoot to mptStateRoot for snapshot generation and loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MPT nodes syncing ZK-era blocks store trie data under the locally-computed mptStateRoot (Keccak256), but the block header and snapshot system use the zkStateRoot (Poseidon hash). The snapshot generator calls trie.New(root, triedb) directly — bypassing the OpenTrie/ReadDiskStateRoot translation layer — so it can never find MPT trie nodes when root is a zkStateRoot. This causes the generator goroutine to block forever on genAbort, hanging the caller. Fix three sites: 1. generate.go (generateSnapshot): resolve zkStateRoot → mptStateRoot via ReadDiskStateRoot before writing SnapshotRoot and starting the generator, so trie.New uses the correct on-disk root. 2. journal.go (loadSnapshot): when the on-disk snapshot root (mptStateRoot) differs from the requested root (zkStateRoot), check ReadDiskStateRoot to verify they correspond — accept the snapshot without a costly rebuild. 3. pruner.go (NewPruner): in the journal-missing fallback path, translate the persisted SnapshotRoot from zkStateRoot to mptStateRoot and update the DB so that subsequent snapshot.New calls load correctly. Co-Authored-By: Claude Opus 4.6 --- core/state/pruner/pruner.go | 15 +++++++++++++++ core/state/snapshot/generate.go | 17 +++++++++++++++++ core/state/snapshot/journal.go | 28 +++++++++++++++++++--------- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go index f1f56b0fc..a7ec10b04 100644 --- a/core/state/pruner/pruner.go +++ b/core/state/pruner/pruner.go @@ -104,6 +104,21 @@ func NewPruner(db ethdb.Database, datadir, trieCachePath string, bloomSize uint6 if snapDiskRoot == (common.Hash{}) { return nil, err // No snapshot at all — nothing we can do. } + // The persisted snapshot root may be a zkStateRoot (Poseidon hash) + // written when an MPT node first tried — and failed — to generate a + // snapshot for a ZK-era block. The snapshot trie walk calls + // trie.New(root, triedb) directly, bypassing the OpenTrie translation + // layer, so it can only succeed with the actual on-disk mptStateRoot. + // Resolve the mapping here so that the snapshot.New call below (and + // the generator it may start) both operate on the correct root. + if mptRoot, err2 := rawdb.ReadDiskStateRoot(db, snapDiskRoot); err2 == nil { + log.Info("Pruner: resolved snapshot ZK root to MPT root", + "zkRoot", snapDiskRoot, "mptRoot", mptRoot) + snapDiskRoot = mptRoot + // Persist the corrected root so that subsequent snapshot.New + // calls (inside loadSnapshot) can locate the disk layer. + rawdb.WriteSnapshotRoot(db, snapDiskRoot) + } log.Warn("Snapshot journal missing, falling back to snapshot disk-layer root", "snapDiskRoot", snapDiskRoot, "chainHead", headBlock.Root()) // If the snapshot was mid-generation when the node was killed, New will diff --git a/core/state/snapshot/generate.go b/core/state/snapshot/generate.go index 5e2d695a4..d685e7443 100644 --- a/core/state/snapshot/generate.go +++ b/core/state/snapshot/generate.go @@ -147,6 +147,23 @@ func (gs *generatorStats) Log(msg string, root common.Hash, marker []byte) { // database and head block asynchronously. The snapshot is returned immediately // and generation is continued in the background until done. func generateSnapshot(diskdb ethdb.KeyValueStore, triedb *trie.Database, cache int, root common.Hash) *diskLayer { + // For MPT nodes that sync ZK-era blocks the block header carries a + // zkStateRoot (Poseidon hash) while the on-disk trie is keyed by the + // locally-computed mptStateRoot (Keccak256 hash). The snapshot trie + // walk uses trie.New(root, triedb) directly — it does NOT go through + // cachingDB.OpenTrie, so it never sees the ReadDiskStateRoot redirect. + // If we leave root as the zkStateRoot the trie lookup fails immediately + // with "missing trie node", the generator goroutine blocks on genAbort + // forever, and waitBuild() hangs the caller. + // + // Resolve the zkStateRoot → mptStateRoot mapping before doing anything + // else so that both WriteSnapshotRoot and the generator goroutine use + // the correct on-disk root. + if mptRoot, err := rawdb.ReadDiskStateRoot(diskdb, root); err == nil { + log.Info("Snapshot generation: resolved ZK state root to MPT root", + "zkRoot", root, "mptRoot", mptRoot) + root = mptRoot + } // Create a new disk layer with an initialized state marker at zero var ( stats = &generatorStats{start: time.Now()} diff --git a/core/state/snapshot/journal.go b/core/state/snapshot/journal.go index ddb929c13..65160118a 100644 --- a/core/state/snapshot/journal.go +++ b/core/state/snapshot/journal.go @@ -159,17 +159,27 @@ func loadSnapshot(diskdb ethdb.KeyValueStore, triedb *trie.Database, cache int, // which is below the snapshot. In this case the snapshot can be recovered // by re-executing blocks but right now it's unavailable. if head := snapshot.Root(); head != root { - // If it's legacy snapshot, or it's new-format snapshot but - // it's not in recovery mode, returns the error here for - // rebuilding the entire snapshot forcibly. - if !recovery { + // Special case: MPT nodes syncing ZK-era blocks store the snapshot + // under the locally-computed mptStateRoot while the block header + // (and therefore the `root` argument) carries the zkStateRoot. + // If the on-disk snapshot root equals the MPT translation of the + // requested root, the snapshot is perfectly valid — accept it + // without triggering a costly rebuild. + if translated, err := rawdb.ReadDiskStateRoot(diskdb, root); err == nil && head == translated { + log.Info("Snapshot root is MPT translation of block root — accepting", + "blockRoot", root, "mptRoot", head) + } else if !recovery { + // If it's legacy snapshot, or it's new-format snapshot but + // it's not in recovery mode, returns the error here for + // rebuilding the entire snapshot forcibly. return nil, false, fmt.Errorf("head doesn't match snapshot: have %#x, want %#x", head, root) + } else { + // It's in snapshot recovery, the assumption is held that + // the disk layer is always higher than chain head. It can + // be eventually recovered when the chain head beyonds the + // disk layer. + log.Warn("Snapshot is not continuous with chain", "snaproot", head, "chainroot", root) } - // It's in snapshot recovery, the assumption is held that - // the disk layer is always higher than chain head. It can - // be eventually recovered when the chain head beyonds the - // disk layer. - log.Warn("Snapshot is not continuous with chain", "snaproot", head, "chainroot", root) } // Everything loaded correctly, resume any suspended operations if !generator.Done { From d0ec38ba2a2130bd5922f0220aed5688c73d8bb1 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 18 Mar 2026 22:39:39 +0800 Subject: [PATCH 2/9] snapshot: use translated base.root as map key in Rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateSnapshot may translate zkStateRoot to mptStateRoot internally (for MPT nodes syncing ZK-era blocks). Rebuild was still using the original root argument as the layers map key, creating a mismatch: key=zkRoot but diskLayer.root=mptRoot. This caused diffToDisk's stale-layer cleanup to miss the disk layer's children (children map is keyed by parent.Root()=mptRoot, but the stale disk layer was keyed by zkRoot), leaving orphaned diffLayers with a stale origin — triggering ErrSnapshotStale on subsequent reads. Fix: capture the returned base and use base.root as the map key. Co-Authored-By: Claude Opus 4.6 --- core/state/snapshot/snapshot.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/state/snapshot/snapshot.go b/core/state/snapshot/snapshot.go index dac8413a7..479e1218d 100644 --- a/core/state/snapshot/snapshot.go +++ b/core/state/snapshot/snapshot.go @@ -730,9 +730,11 @@ func (t *Tree) Rebuild(root common.Hash) { // Start generating a new snapshot from scratch on a background thread. The // generator will run a wiper first if there's not one running right now. log.Info("Rebuilding state snapshot") - t.layers = map[common.Hash]snapshot{ - root: generateSnapshot(t.diskdb, t.triedb, t.cache, root), - } + base := generateSnapshot(t.diskdb, t.triedb, t.cache, root) + // generateSnapshot may have translated root (e.g. zkStateRoot → mptStateRoot + // for MPT nodes syncing ZK-era blocks). Use base.root as the map key so that + // all subsequent Snapshot()/Update() lookups find the layer correctly. + t.layers = map[common.Hash]snapshot{base.root: base} } // AccountIterator creates a new account iterator for the specified root hash and From fed227ccd70ac1fc940c37ad22e7a59225bdf4eb Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 18 Mar 2026 23:15:20 +0800 Subject: [PATCH 3/9] snapshot: add ZK-MPT compatibility tests for prune-state fixes Test generateSnapshot root translation, loadSnapshot ZK/MPT mismatch tolerance, and Rebuild map-key consistency to ensure prune-state succeeds on MPT nodes syncing ZK-era blocks. Co-Authored-By: Claude Opus 4.6 --- core/state/snapshot/zk_mpt_compat_test.go | 278 ++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 core/state/snapshot/zk_mpt_compat_test.go diff --git a/core/state/snapshot/zk_mpt_compat_test.go b/core/state/snapshot/zk_mpt_compat_test.go new file mode 100644 index 000000000..0e3cda244 --- /dev/null +++ b/core/state/snapshot/zk_mpt_compat_test.go @@ -0,0 +1,278 @@ +// Copyright 2024 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 . + +// Tests for ZK-MPT compatibility fixes: +// - generateSnapshot resolves zkStateRoot → mptStateRoot via ReadDiskStateRoot +// - loadSnapshot accepts a snapshot whose disk root is the MPT translation of +// the requested zkStateRoot +// - Rebuild stores the disk layer under base.root (the possibly-translated root) +// as the map key so that Snapshot() lookups work correctly after translation + +package snapshot + +import ( + "math/big" + "testing" + "time" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/rawdb" + "github.com/morph-l2/go-ethereum/ethdb" + "github.com/morph-l2/go-ethereum/ethdb/memorydb" + "github.com/morph-l2/go-ethereum/rlp" + "github.com/morph-l2/go-ethereum/trie" +) + +// buildMPTTrie creates a small MPT account trie in diskdb/triedb and returns its root. +func buildMPTTrie(t *testing.T, diskdb *memorydb.Database) (common.Hash, *trie.Database) { + t.Helper() + triedb := trie.NewDatabase(diskdb) + accTrie, _ := trie.NewSecure(common.Hash{}, triedb) + for _, key := range []string{"acc-1", "acc-2", "acc-3"} { + acc := &Account{ + Balance: big.NewInt(1), + Root: emptyRoot.Bytes(), + KeccakCodeHash: emptyKeccakCode.Bytes(), + PoseidonCodeHash: emptyPoseidonCode.Bytes(), + } + val, _ := rlp.EncodeToBytes(acc) + accTrie.Update([]byte(key), val) + } + mptRoot, _, _ := accTrie.Commit(nil) + triedb.Commit(mptRoot, false, nil) + return mptRoot, triedb +} + +// writeDoneGenerator writes a completed (Done=true) snapshot generator record +// into the given db — required by loadAndParseJournal before loadSnapshot +// proceeds to the head-root comparison. +func writeDoneGenerator(db ethdb.KeyValueWriter) { + blob, _ := rlp.EncodeToBytes(&journalGenerator{Done: true}) + rawdb.WriteSnapshotGenerator(db, blob) +} + +// ---- generate.go: generateSnapshot root translation ------------------------- + +// TestGenerateSnapshotTranslatesZkRoot verifies that when a DiskStateRoot +// mapping (zkRoot → mptRoot) exists, generateSnapshot uses mptRoot for both +// WriteSnapshotRoot and the trie walk, allowing generation to complete. +func TestGenerateSnapshotTranslatesZkRoot(t *testing.T) { + diskdb := memorydb.New() + mptRoot, triedb := buildMPTTrie(t, diskdb) + + // Simulate ZK-era: block header carries zkRoot, local trie is at mptRoot. + zkRoot := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") + rawdb.WriteDiskStateRoot(diskdb, zkRoot, mptRoot) + + snap := generateSnapshot(diskdb, triedb, 16, zkRoot) + + // diskLayer.root must be mptRoot, not zkRoot. + if snap.root != mptRoot { + t.Fatalf("diskLayer.root: got %x, want mptRoot %x", snap.root, mptRoot) + } + // SnapshotRoot persisted to DB must be mptRoot. + if stored := rawdb.ReadSnapshotRoot(diskdb); stored != mptRoot { + t.Fatalf("SnapshotRoot in DB: got %x, want mptRoot %x", stored, mptRoot) + } + // Generation must complete successfully. + select { + case <-snap.genPending: + case <-time.After(3 * time.Second): + t.Fatal("snapshot generation timed out — trie walk likely used zkRoot instead of mptRoot") + } + // The generated snapshot data must reproduce mptRoot exactly. + checkSnapRoot(t, snap, mptRoot) + + stop := make(chan *generatorStats) + snap.genAbort <- stop + <-stop +} + +// TestGenerateSnapshotNoTranslation verifies that when no DiskStateRoot mapping +// exists (post-Jade-fork MPT blocks where mptRoot IS the block root), root is +// used unchanged and generation still succeeds. +func TestGenerateSnapshotNoTranslation(t *testing.T) { + diskdb := memorydb.New() + mptRoot, triedb := buildMPTTrie(t, diskdb) + // No WriteDiskStateRoot call — no mapping. + + snap := generateSnapshot(diskdb, triedb, 16, mptRoot) + + if snap.root != mptRoot { + t.Fatalf("diskLayer.root: got %x, want %x", snap.root, mptRoot) + } + if stored := rawdb.ReadSnapshotRoot(diskdb); stored != mptRoot { + t.Fatalf("SnapshotRoot in DB: got %x, want %x", stored, mptRoot) + } + select { + case <-snap.genPending: + case <-time.After(3 * time.Second): + t.Fatal("snapshot generation timed out") + } + checkSnapRoot(t, snap, mptRoot) + + stop := make(chan *generatorStats) + snap.genAbort <- stop + <-stop +} + +// ---- journal.go: loadSnapshot ZK/MPT mismatch tolerance -------------------- + +// TestLoadSnapshotAcceptsZkMptMismatch verifies that loadSnapshot succeeds when +// the snapshot was stored under mptRoot but is requested with the corresponding +// zkRoot (MPT node syncing ZK-era blocks, snapshot already generated with fix). +func TestLoadSnapshotAcceptsZkMptMismatch(t *testing.T) { + db := rawdb.NewMemoryDatabase() + triedb := trie.NewDatabase(db) + + mptRoot := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + zkRoot := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + // Snapshot is stored at mptRoot (written by generateSnapshot after translation). + rawdb.WriteSnapshotRoot(db, mptRoot) + writeDoneGenerator(db) + // The ZK→MPT mapping tells loadSnapshot they correspond. + rawdb.WriteDiskStateRoot(db, zkRoot, mptRoot) + + snap, disabled, err := loadSnapshot(db, triedb, 16, zkRoot, false /* recovery */) + if err != nil { + t.Fatalf("loadSnapshot with ZK/MPT mismatch should succeed, got: %v", err) + } + if disabled { + t.Fatal("snapshot should not be disabled") + } + if snap.Root() != mptRoot { + t.Fatalf("snapshot root: got %x, want mptRoot %x", snap.Root(), mptRoot) + } +} + +// TestLoadSnapshotRejectsTrueMismatch verifies that a genuine root mismatch +// (no DiskStateRoot mapping, and not in recovery mode) is still rejected so +// that the ZK/MPT tolerance does not become a security hole. +func TestLoadSnapshotRejectsTrueMismatch(t *testing.T) { + db := rawdb.NewMemoryDatabase() + triedb := trie.NewDatabase(db) + + storedRoot := common.HexToHash("0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + requestedRoot := common.HexToHash("0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd") + + // Snapshot stored at storedRoot, no DiskStateRoot mapping for requestedRoot. + rawdb.WriteSnapshotRoot(db, storedRoot) + writeDoneGenerator(db) + // Intentionally no WriteDiskStateRoot call. + + _, _, err := loadSnapshot(db, triedb, 16, requestedRoot, false /* recovery */) + if err == nil { + t.Fatal("loadSnapshot should return error for genuine root mismatch without DiskStateRoot mapping") + } +} + +// ---- snapshot.go: Rebuild uses base.root as map key ------------------------ + +// TestRebuildZkRootMapKeyConsistency verifies that after Rebuild(zkRoot), the +// snapshot tree's layers map is keyed by mptRoot (base.root after translation), +// not zkRoot, so that Snapshot(mptRoot) and DiskRoot() return correct results. +func TestRebuildZkRootMapKeyConsistency(t *testing.T) { + diskdb := memorydb.New() + mptRoot, triedb := buildMPTTrie(t, diskdb) + + zkRoot := common.HexToHash("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + rawdb.WriteDiskStateRoot(diskdb, zkRoot, mptRoot) + + snaps := &Tree{ + diskdb: diskdb, + triedb: triedb, + cache: 16, + layers: make(map[common.Hash]snapshot), + } + snaps.Rebuild(zkRoot) + + // Wait for generation to complete. + select { + case <-snaps.disklayer().genPending: + case <-time.After(3 * time.Second): + t.Fatal("snapshot generation timed out") + } + + // DiskRoot() must return mptRoot. + if got := snaps.DiskRoot(); got != mptRoot { + t.Fatalf("DiskRoot(): got %x, want mptRoot %x", got, mptRoot) + } + // The layers map must be keyed by mptRoot so Snapshot(mptRoot) finds it. + if snaps.Snapshot(mptRoot) == nil { + t.Fatal("Snapshot(mptRoot) returned nil — map key was not translated from zkRoot") + } + // Snapshot(zkRoot) should NOT return a layer (key is mptRoot, not zkRoot). + if snaps.Snapshot(zkRoot) != nil { + t.Fatal("Snapshot(zkRoot) returned non-nil — map should be keyed by mptRoot only") + } + // Verify layers map has exactly one entry (the disk layer at mptRoot). + snaps.lock.RLock() + count := len(snaps.layers) + snaps.lock.RUnlock() + if count != 1 { + t.Fatalf("layers map has %d entries, want 1", count) + } + + // Cap/Update should work correctly using mptRoot as parent. + diffRoot := common.HexToHash("0xff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00") + if err := snaps.Update(diffRoot, mptRoot, nil, map[common.Hash][]byte{ + common.HexToHash("0xab"): randomAccount(), + }, nil); err != nil { + t.Fatalf("Update after Rebuild failed: %v", err) + } + if snaps.Snapshot(diffRoot) == nil { + t.Fatal("diffLayer not found after Update — parent lookup using mptRoot failed") + } + + stop := make(chan *generatorStats) + snaps.disklayer().genAbort <- stop + <-stop +} + +// TestRebuildWithoutTranslation verifies that when no DiskStateRoot mapping +// exists, Rebuild(mptRoot) stores the disk layer under mptRoot as before — +// ensuring no regression for the post-Jade-fork (pure MPT) case. +func TestRebuildWithoutTranslation(t *testing.T) { + diskdb := memorydb.New() + mptRoot, triedb := buildMPTTrie(t, diskdb) + // No DiskStateRoot mapping — pure MPT case. + + snaps := &Tree{ + diskdb: diskdb, + triedb: triedb, + cache: 16, + layers: make(map[common.Hash]snapshot), + } + snaps.Rebuild(mptRoot) + + select { + case <-snaps.disklayer().genPending: + case <-time.After(3 * time.Second): + t.Fatal("snapshot generation timed out") + } + + if got := snaps.DiskRoot(); got != mptRoot { + t.Fatalf("DiskRoot(): got %x, want %x", got, mptRoot) + } + if snaps.Snapshot(mptRoot) == nil { + t.Fatal("Snapshot(mptRoot) returned nil after Rebuild(mptRoot)") + } + + stop := make(chan *generatorStats) + snaps.disklayer().genAbort <- stop + <-stop +} From 507d82f114476c2a941016058fa1e5956dc32202 Mon Sep 17 00:00:00 2001 From: curryxbo Date: Thu, 19 Mar 2026 17:13:49 +0800 Subject: [PATCH 4/9] fix snapshot --- core/blockchain.go | 75 +++++++++++++++++++++++++++---------- core/state/pruner/pruner.go | 25 +++++++++++-- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 47f8c5202..02d34b55f 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -315,14 +315,36 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par if diskRoot != (common.Hash{}) { log.Warn("Head state missing, repairing", "number", head.Number(), "hash", head.Hash(), "snaproot", diskRoot) - snapDisk, err := bc.setHeadBeyondRoot(head.NumberU64(), diskRoot, true) - if err != nil { - return nil, err - } - // Chain rewound, persist old snapshot number to indicate recovery procedure - if snapDisk != 0 { - rawdb.WriteSnapshotRecoveryNumber(bc.db, snapDisk) + // Walk backwards to find a block whose state matches the + // snapshot disk root. Only update the full-block (state) + // marker — do NOT rewind the header chain, fast block, or + // delete any block data so the node can re-execute the + // remaining blocks to catch up. + newHead := head + for newHead.NumberU64() > 0 { + // Resolve the block's root to its on-disk MPT root. + blockRoot := newHead.Root() + if mptRoot, err2 := rawdb.ReadDiskStateRoot(bc.db, blockRoot); err2 == nil { + blockRoot = mptRoot + } + if blockRoot == diskRoot { + if _, err2 := state.New(newHead.Root(), bc.stateCache, bc.snaps); err2 == nil { + break + } + } + parent := bc.GetBlock(newHead.ParentHash(), newHead.NumberU64()-1) + if parent == nil { + newHead = bc.genesisBlock + break + } + newHead = parent } + log.Warn("Rewound full block to snapshot state", + "from", head.NumberU64(), "to", newHead.NumberU64(), "snaproot", diskRoot) + rawdb.WriteHeadBlockHash(bc.db, newHead.Hash()) + bc.currentBlock.Store(newHead) + headBlockGauge.Update(int64(newHead.NumberU64())) + rawdb.WriteSnapshotRecoveryNumber(bc.db, newHead.NumberU64()) } else { log.Warn("Head state missing, repairing", "number", head.Number(), "hash", head.Hash()) if _, err := bc.setHeadBeyondRoot(head.NumberU64(), common.Hash{}, true); err != nil { @@ -340,21 +362,28 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par // The head full block may be rolled back to a very low height due to // blockchain repair. If the head full block is even lower than the ancient // chain, truncate the ancient store. + // + // However, after state pruning the full block is intentionally behind + // while the fast block (header chain) is still at the tip. In that case + // the ancient data is still valid and needed for re-execution, so only + // truncate when the fast block is ALSO behind the freezer. fullBlock := bc.CurrentBlock() - if fullBlock != nil && fullBlock.Hash() != bc.genesisBlock.Hash() && fullBlock.NumberU64() < frozen-1 { - needRewind = true - low = fullBlock.NumberU64() - } - // In fast sync, it may happen that ancient data has been written to the - // ancient store, but the LastFastBlock has not been updated, truncate the - // extra data here. fastBlock := bc.CurrentFastBlock() - if fastBlock != nil && fastBlock.NumberU64() < frozen-1 { + + fullBehind := fullBlock != nil && fullBlock.Hash() != bc.genesisBlock.Hash() && fullBlock.NumberU64() < frozen-1 + fastBehind := fastBlock != nil && fastBlock.NumberU64() < frozen-1 + + if fullBehind && fastBehind { needRewind = true - if fastBlock.NumberU64() < low || low == 0 { + low = fullBlock.NumberU64() + if fastBlock.NumberU64() < low { low = fastBlock.NumberU64() } + } else if fastBehind { + needRewind = true + low = fastBlock.NumberU64() } + // If only the full block is behind (post-pruning), don't truncate. if needRewind { log.Error("Truncating ancient chain", "from", bc.CurrentHeader().Number.Uint64(), "to", low) if err := bc.SetHead(low); err != nil { @@ -558,9 +587,17 @@ func (bc *BlockChain) setHeadBeyondRoot(head uint64, root common.Hash, repair bo beyondRoot := (root == common.Hash{}) // Flag whether we're beyond the requested root (no root, always true) for { - // If a root threshold was requested but not yet crossed, check - if root != (common.Hash{}) && !beyondRoot && newHeadBlock.Root() == root { - beyondRoot, rootNumber = true, newHeadBlock.NumberU64() + // If a root threshold was requested but not yet crossed, check. + // The block root may be a zkStateRoot while the target root is + // an mptStateRoot, so also compare via the on-disk mapping. + if root != (common.Hash{}) && !beyondRoot { + blockRoot := newHeadBlock.Root() + if mptRoot, err := rawdb.ReadDiskStateRoot(bc.db, blockRoot); err == nil { + blockRoot = mptRoot + } + if blockRoot == root { + beyondRoot, rootNumber = true, newHeadBlock.NumberU64() + } } if _, err := state.New(newHeadBlock.Root(), bc.stateCache, bc.snaps); err != nil { log.Trace("Block state missing, rewinding further", "number", newHeadBlock.NumberU64(), "hash", newHeadBlock.Hash()) diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go index a7ec10b04..baf0c3ad8 100644 --- a/core/state/pruner/pruner.go +++ b/core/state/pruner/pruner.go @@ -301,6 +301,19 @@ func (p *Pruner) Prune(root common.Hash) error { // root directly as the pruning target instead of requiring 128 diff // layers that don't exist. if p.snapDiskRoot != (common.Hash{}) { + // Verify the snapshot has caught up to the chain head. + // The head block root may be a zkStateRoot; resolve it to + // the on-disk mptStateRoot for comparison. + headRoot := p.headHeader.Root + if mptRoot, err2 := rawdb.ReadDiskStateRoot(p.db, headRoot); err2 == nil { + headRoot = mptRoot + } + if p.snapDiskRoot != headRoot { + log.Warn("Snapshot is behind chain head; pruning will target the snapshot root, "+ + "node will need to re-execute blocks from snapshot to head on next startup", + "snapDiskRoot", p.snapDiskRoot, "headMptRoot", headRoot, + "headNumber", p.headHeader.Number) + } log.Info("Using snapshot disk-layer root as pruning target (journal was missing)", "snapDiskRoot", p.snapDiskRoot) root = p.snapDiskRoot @@ -467,7 +480,13 @@ func extractGenesis(db ethdb.Database, stateBloom *stateBloom) error { if genesis == nil { return errors.New("missing genesis block") } - t, err := trie.NewSecure(genesis.Root(), trie.NewDatabase(db)) + // The genesis block root may be a zkStateRoot; resolve to the + // on-disk mptStateRoot so trie.NewSecure can find the nodes. + genesisRoot := genesis.Root() + if mptRoot, err := rawdb.ReadDiskStateRoot(db, genesisRoot); err == nil { + genesisRoot = mptRoot + } + t, err := trie.NewSecure(genesisRoot, trie.NewDatabase(db)) if err != nil { return err } @@ -546,11 +565,11 @@ const warningLog = ` WARNING! -The clean trie cache is not found. Please delete it by yourself after the +The clean trie cache is not found. Please delete it by yourself after the pruning. Remember don't start the Geth without deleting the clean trie cache otherwise the entire database may be damaged! -Check the command description "geth snapshot prune-zk-state --help" for more details. +Check the command description "geth snapshot prune-state --help" for more details. ` func deleteCleanTrieCache(path string) { From 18b9b3b4a9d9815993750bf809af7e6cedd63948 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 19 Mar 2026 18:29:42 +0800 Subject: [PATCH 5/9] blockchain: translate snapshot disk root from ZK to MPT before backward walk ReadSnapshotRoot may return a zkStateRoot written by an older code path. Translate it via ReadDiskStateRoot before comparing with block roots in the post-prune repair walk, so the walk can find the correct block instead of falling through to genesis. Co-Authored-By: Claude Opus 4.6 --- core/blockchain.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/blockchain.go b/core/blockchain.go index 02d34b55f..790f0b2ab 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -311,6 +311,13 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par var diskRoot common.Hash if bc.cacheConfig.SnapshotLimit > 0 { diskRoot = rawdb.ReadSnapshotRoot(bc.db) + // The stored snapshot root may be a zkStateRoot (Poseidon hash) written + // by an older code path. Translate it to the on-disk mptStateRoot so that + // the backward walk below can compare apples-to-apples with translated + // block roots. + if mptRoot, err := rawdb.ReadDiskStateRoot(bc.db, diskRoot); err == nil { + diskRoot = mptRoot + } } if diskRoot != (common.Hash{}) { log.Warn("Head state missing, repairing", "number", head.Number(), "hash", head.Hash(), "snaproot", diskRoot) From 6bdbbdfeb8361da7c6f4a72f5b349ba4e855d7a7 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 19 Mar 2026 18:39:50 +0800 Subject: [PATCH 6/9] pruner: use current chain head as snapshot target instead of stale persisted root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old fallback used ReadSnapshotRoot which returns the root from the last restart's generateSnapshot call. If the node synced N more blocks since that restart, the snapshot and prune target would be N blocks behind the current head, causing a post-prune rollback of N blocks on restart. Always resolve the current headBlock root (translating zkStateRoot → mptStateRoot via ReadDiskStateRoot) and use that as both the snapshot generation and pruning target, so the node restarts at the head height. Co-Authored-By: Claude Opus 4.6 --- core/state/pruner/pruner.go | 41 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go index baf0c3ad8..c00519e75 100644 --- a/core/state/pruner/pruner.go +++ b/core/state/pruner/pruner.go @@ -96,36 +96,25 @@ func NewPruner(db ethdb.Database, datadir, trieCachePath string, bloomSize uint6 snaptree, err := snapshot.New(db, trie.NewDatabase(db), 256, headBlock.Root(), false, false, false) var snapDiskRoot common.Hash if err != nil { - // The snapshot journal may be missing because geth was not shut down - // cleanly (SIGKILL before BlockChain.Stop could write the journal). - // Fall back: initialise the snapshot tree with the persisted disk - // snapshot root so that Prune() can still target that state. - snapDiskRoot = rawdb.ReadSnapshotRoot(db) - if snapDiskRoot == (common.Hash{}) { - return nil, err // No snapshot at all — nothing we can do. - } - // The persisted snapshot root may be a zkStateRoot (Poseidon hash) - // written when an MPT node first tried — and failed — to generate a - // snapshot for a ZK-era block. The snapshot trie walk calls - // trie.New(root, triedb) directly, bypassing the OpenTrie translation - // layer, so it can only succeed with the actual on-disk mptStateRoot. - // Resolve the mapping here so that the snapshot.New call below (and - // the generator it may start) both operate on the correct root. + // The snapshot journal is missing (unclean shutdown or first prune). + // Always target the CURRENT chain head so that after pruning the node + // can restart at the head height instead of being rolled back to + // wherever the last snapshot attempt happened to start. + // + // The block header carries a zkStateRoot (Poseidon hash); resolve it + // to the on-disk mptStateRoot via the DiskStateRoot mapping. For + // post-Jade pure-MPT blocks the mapping does not exist and the head + // root is already the mptStateRoot. + snapDiskRoot = headBlock.Root() if mptRoot, err2 := rawdb.ReadDiskStateRoot(db, snapDiskRoot); err2 == nil { - log.Info("Pruner: resolved snapshot ZK root to MPT root", + log.Info("Pruner: resolved head ZK root to MPT root", "zkRoot", snapDiskRoot, "mptRoot", mptRoot) snapDiskRoot = mptRoot - // Persist the corrected root so that subsequent snapshot.New - // calls (inside loadSnapshot) can locate the disk layer. - rawdb.WriteSnapshotRoot(db, snapDiskRoot) } - log.Warn("Snapshot journal missing, falling back to snapshot disk-layer root", - "snapDiskRoot", snapDiskRoot, "chainHead", headBlock.Root()) - // If the snapshot was mid-generation when the node was killed, New will - // resume and wait for generation to finish (async=false). This can take - // a long time for large state; the log below makes that visible. - log.Info("Loading snapshot from disk-layer root (may wait for snapshot generation to finish)...", - "snapDiskRoot", snapDiskRoot) + // Persist so that loadSnapshot inside snapshot.New finds the right base root. + rawdb.WriteSnapshotRoot(db, snapDiskRoot) + log.Warn("Snapshot journal missing, generating snapshot at chain head", + "snapDiskRoot", snapDiskRoot, "headNumber", headBlock.NumberU64()) snaptree, err = snapshot.New(db, trie.NewDatabase(db), 256, snapDiskRoot, false, false, false) if err != nil { return nil, err From ec50821b02c8b6338eebdb66f4b65ba300a59597 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 19 Mar 2026 18:48:59 +0800 Subject: [PATCH 7/9] pruner: always set snapDiskRoot so Prune() bypasses 128-layer check When snapshot.New(headRoot) succeeds via the normal path (e.g. journal.go accepts the mptRoot translation), the fallback block was skipped and snapDiskRoot stayed zero. Prune() then tried to find 128 diff layers which never exist for the pruner CLI, causing "snapshot not old enough" error. Always capture the disk root from the successfully loaded snapshot tree so that Prune() uses the disk-layer root as the pruning target. Co-Authored-By: Claude Opus 4.6 --- core/state/pruner/pruner.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go index c00519e75..e3763d0aa 100644 --- a/core/state/pruner/pruner.go +++ b/core/state/pruner/pruner.go @@ -120,6 +120,13 @@ func NewPruner(db ethdb.Database, datadir, trieCachePath string, bloomSize uint6 return nil, err } log.Info("Snapshot ready", "snapDiskRoot", snapDiskRoot) + } else { + // snapshot.New succeeded via the normal path (journal was present and + // head matched). Still populate snapDiskRoot so that Prune() uses the + // disk-layer root as the pruning target and does not require 128 diff + // layers (which the pruner CLI never has, since it doesn't write a + // snapshot journal on exit). + snapDiskRoot = snaptree.DiskRoot() } // Sanitize the bloom filter size if it's too small. if bloomSize < 256 { From 835ec37452c2ab6e37102c5f99a063db06262aa0 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 19 Mar 2026 19:17:34 +0800 Subject: [PATCH 8/9] blockchain, pruner: restore standard geth repair and pruning logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the custom backward walk in NewBlockChain back to the standard setHeadBeyondRoot call, which already handles ZK→MPT root translation since commit 507d82f11. The custom walk only rewound currentBlock without touching currentFastBlock/currentHeader, creating an inconsistent chain state. Also restore the original freezer truncation conditions. In the pruner, only fall back to the snapshot disk-layer root when fewer than 128 diff layers are available, preserving the standard HEAD-127 pruning target when sufficient layers exist. Co-Authored-By: Claude Opus 4.6 --- core/blockchain.go | 61 ++++++++++--------------------------- core/state/pruner/pruner.go | 24 ++++++++------- 2 files changed, 29 insertions(+), 56 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 790f0b2ab..28f04d591 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -322,36 +322,14 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par if diskRoot != (common.Hash{}) { log.Warn("Head state missing, repairing", "number", head.Number(), "hash", head.Hash(), "snaproot", diskRoot) - // Walk backwards to find a block whose state matches the - // snapshot disk root. Only update the full-block (state) - // marker — do NOT rewind the header chain, fast block, or - // delete any block data so the node can re-execute the - // remaining blocks to catch up. - newHead := head - for newHead.NumberU64() > 0 { - // Resolve the block's root to its on-disk MPT root. - blockRoot := newHead.Root() - if mptRoot, err2 := rawdb.ReadDiskStateRoot(bc.db, blockRoot); err2 == nil { - blockRoot = mptRoot - } - if blockRoot == diskRoot { - if _, err2 := state.New(newHead.Root(), bc.stateCache, bc.snaps); err2 == nil { - break - } - } - parent := bc.GetBlock(newHead.ParentHash(), newHead.NumberU64()-1) - if parent == nil { - newHead = bc.genesisBlock - break - } - newHead = parent + snapDisk, err := bc.setHeadBeyondRoot(head.NumberU64(), diskRoot, true) + if err != nil { + return nil, err + } + // Chain rewound, persist old snapshot number to indicate recovery procedure + if snapDisk != 0 { + rawdb.WriteSnapshotRecoveryNumber(bc.db, snapDisk) } - log.Warn("Rewound full block to snapshot state", - "from", head.NumberU64(), "to", newHead.NumberU64(), "snaproot", diskRoot) - rawdb.WriteHeadBlockHash(bc.db, newHead.Hash()) - bc.currentBlock.Store(newHead) - headBlockGauge.Update(int64(newHead.NumberU64())) - rawdb.WriteSnapshotRecoveryNumber(bc.db, newHead.NumberU64()) } else { log.Warn("Head state missing, repairing", "number", head.Number(), "hash", head.Hash()) if _, err := bc.setHeadBeyondRoot(head.NumberU64(), common.Hash{}, true); err != nil { @@ -369,28 +347,21 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par // The head full block may be rolled back to a very low height due to // blockchain repair. If the head full block is even lower than the ancient // chain, truncate the ancient store. - // - // However, after state pruning the full block is intentionally behind - // while the fast block (header chain) is still at the tip. In that case - // the ancient data is still valid and needed for re-execution, so only - // truncate when the fast block is ALSO behind the freezer. fullBlock := bc.CurrentBlock() - fastBlock := bc.CurrentFastBlock() - - fullBehind := fullBlock != nil && fullBlock.Hash() != bc.genesisBlock.Hash() && fullBlock.NumberU64() < frozen-1 - fastBehind := fastBlock != nil && fastBlock.NumberU64() < frozen-1 - - if fullBehind && fastBehind { + if fullBlock != nil && fullBlock.Hash() != bc.genesisBlock.Hash() && fullBlock.NumberU64() < frozen-1 { needRewind = true low = fullBlock.NumberU64() - if fastBlock.NumberU64() < low { + } + // In fast sync, it may happen that ancient data has been written to the + // ancient store, but the LastFastBlock has not been updated, truncate the + // extra data here. + fastBlock := bc.CurrentFastBlock() + if fastBlock != nil && fastBlock.NumberU64() < frozen-1 { + needRewind = true + if fastBlock.NumberU64() < low || low == 0 { low = fastBlock.NumberU64() } - } else if fastBehind { - needRewind = true - low = fastBlock.NumberU64() } - // If only the full block is behind (post-pruning), don't truncate. if needRewind { log.Error("Truncating ancient chain", "from", bc.CurrentHeader().Number.Uint64(), "to", low) if err := bc.SetHead(low); err != nil { diff --git a/core/state/pruner/pruner.go b/core/state/pruner/pruner.go index e3763d0aa..4ae003f4d 100644 --- a/core/state/pruner/pruner.go +++ b/core/state/pruner/pruner.go @@ -121,12 +121,15 @@ func NewPruner(db ethdb.Database, datadir, trieCachePath string, bloomSize uint6 } log.Info("Snapshot ready", "snapDiskRoot", snapDiskRoot) } else { - // snapshot.New succeeded via the normal path (journal was present and - // head matched). Still populate snapDiskRoot so that Prune() uses the - // disk-layer root as the pruning target and does not require 128 diff - // layers (which the pruner CLI never has, since it doesn't write a - // snapshot journal on exit). - snapDiskRoot = snaptree.DiskRoot() + // snapshot.New succeeded via the normal path (journal was present). + // Only fall back to the disk-layer root when there are fewer than 128 + // diff layers — the standard pruning target is HEAD-127, which requires + // exactly 128 layers. The pruner CLI doesn't accumulate layers while + // running, so this handles the case where the journal exists but was + // created with fewer than 128 blocks since the last snapshot flush. + if layers := snaptree.Snapshots(headBlock.Root(), 128, true); len(layers) < 128 { + snapDiskRoot = snaptree.DiskRoot() + } } // Sanitize the bloom filter size if it's too small. if bloomSize < 256 { @@ -292,10 +295,9 @@ func (p *Pruner) Prune(root common.Hash) error { // - the probability of this layer being reorg is very low var layers []snapshot.Snapshot if root == (common.Hash{}) { - // When the snapshot journal was missing (unclean shutdown), we fell - // back to the persisted disk snapshot root in NewPruner. Use that - // root directly as the pruning target instead of requiring 128 diff - // layers that don't exist. + // When the snapshot journal was missing or there were fewer than 128 + // diff layers, we fell back to the disk snapshot root in NewPruner. + // Use that root directly as the pruning target. if p.snapDiskRoot != (common.Hash{}) { // Verify the snapshot has caught up to the chain head. // The head block root may be a zkStateRoot; resolve it to @@ -310,7 +312,7 @@ func (p *Pruner) Prune(root common.Hash) error { "snapDiskRoot", p.snapDiskRoot, "headMptRoot", headRoot, "headNumber", p.headHeader.Number) } - log.Info("Using snapshot disk-layer root as pruning target (journal was missing)", + log.Info("Using snapshot disk-layer root as pruning target", "snapDiskRoot", p.snapDiskRoot) root = p.snapDiskRoot } else { From 3f02794613bd62132f6c42c413a3c4516c7489b4 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 19 Mar 2026 23:38:34 +0800 Subject: [PATCH 9/9] blockchain: translate zkRoot to mptRoot before journaling snapshot on shutdown BlockChain.Stop() calls snaps.Journal(currentBlock.Root()) to persist the snapshot tree, but currentBlock.Root() is a zkStateRoot while the snapshot disk layer is keyed by mptStateRoot. When no diff layers exist (e.g. right after prune-state or fresh snapshot generation), Snapshot(zkRoot) returns nil and the journal write silently fails. On next startup the journal is missing (diffs=missing), which cascades into pruner failures. Resolve zkRoot to mptRoot via ReadDiskStateRoot before calling Journal() so the disk layer can always be found in the layers map. Co-Authored-By: Claude Opus 4.6 --- core/blockchain.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/blockchain.go b/core/blockchain.go index 28f04d591..673ba9b0a 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -860,8 +860,16 @@ func (bc *BlockChain) Stop() { // Ensure that the entirety of the state snapshot is journalled to disk. var snapBase common.Hash if bc.snaps != nil { + // The snapshot disk layer is keyed by mptStateRoot (after ZK→MPT + // translation in generateSnapshot/Rebuild), but CurrentBlock().Root() + // may be a zkStateRoot. Resolve to mptStateRoot so that Journal() + // can find the layer in the snapshot tree. + journalRoot := bc.CurrentBlock().Root() + if mptRoot, err := rawdb.ReadDiskStateRoot(bc.db, journalRoot); err == nil { + journalRoot = mptRoot + } var err error - if snapBase, err = bc.snaps.Journal(bc.CurrentBlock().Root()); err != nil { + if snapBase, err = bc.snaps.Journal(journalRoot); err != nil { log.Error("Failed to journal state snapshot", "err", err) } }