diff --git a/core/blockchain.go b/core/blockchain.go
index 8da91009cf..90bc6ec32e 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -1394,6 +1394,12 @@ func (bc *BlockChain) AdvancePartialHead(hash common.Hash) error {
// depends on canonical hash mappings that don't exist yet.
batch := bc.db.NewBatch()
currentHead := bc.CurrentBlock()
+ // Include the pivot itself: WriteBlockWithoutState persisted its header+body
+ // via the Engine API newPayload path, and InsertReceiptChain.writeLive
+ // skipped writing its canonical-hash entry because HasBlock was already
+ // true. Without this explicit write, startup's freezer gap-check rejects
+ // the datadir because headerHashKey(pivot) is empty in leveldb.
+ rawdb.WriteCanonicalHash(batch, currentHead.Hash(), currentHead.Number.Uint64())
current := block.Header()
for current.Number.Uint64() > currentHead.Number.Uint64() {
rawdb.WriteCanonicalHash(batch, current.Hash(), current.Number.Uint64())
diff --git a/core/blockchain_partial_restart_test.go b/core/blockchain_partial_restart_test.go
new file mode 100644
index 0000000000..672165eac3
--- /dev/null
+++ b/core/blockchain_partial_restart_test.go
@@ -0,0 +1,167 @@
+// 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 .
+
+// Regression test for the partial-state restart gap bug: AdvancePartialHead
+// must persist the canonical-hash entry for its currentHead (the snap-sync
+// pivot), not only for the blocks above it. Without that entry, leveldb is
+// missing Hn, which the freezer's gap-check at startup rejects with
+// "gap in the chain between ancients ... and leveldb ...".
+
+package core
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+)
+
+// TestAdvancePartialHeadCoversPivot verifies that AdvancePartialHead writes
+// the canonical-hash entry for its currentHead (the "pivot") and not only for
+// the strictly newer blocks written by its backfill loop.
+//
+// Scenario:
+// 1. Build an in-memory partial-state chain and insert a few blocks normally.
+// 2. Simulate the bug's precondition by deleting the pivot's canonical hash
+// entry from leveldb and rewinding the in-memory head back to the pivot.
+// This mimics the state after the Engine API path persisted the pivot via
+// WriteBlockWithoutState (no canonical-hash key) while InsertReceiptChain
+// skipped writing one because HasBlock was already true.
+// 3. Call AdvancePartialHead with a later block. With the fix, the pivot's
+// canonical hash is re-established; without the fix, it stays empty and
+// a subsequent freezer advance would crash on restart.
+func TestAdvancePartialHeadCoversPivot(t *testing.T) {
+ addr := common.HexToAddress("0xbeef")
+ bc, gspec := newPartialBlockchain(t, rawdb.HashScheme, []common.Address{addr})
+ defer bc.Stop()
+
+ // Generate a 6-block canonical chain and insert it fully.
+ _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 6, func(i int, b *BlockGen) {})
+ if _, err := bc.InsertChain(blocks); err != nil {
+ t.Fatalf("failed to insert blocks: %v", err)
+ }
+
+ pivot := blocks[2] // treat block #3 as the pivot
+ target := blocks[5] // advance to block #6
+
+ // Simulate the bug's precondition: pivot's canonical hash is missing
+ // from leveldb, and the chain head is at the pivot.
+ batch := bc.db.NewBatch()
+ rawdb.DeleteCanonicalHash(batch, pivot.NumberU64())
+ if err := batch.Write(); err != nil {
+ t.Fatalf("failed to write batch: %v", err)
+ }
+ bc.currentBlock.Store(pivot.Header())
+ bc.hc.SetCurrentHeader(pivot.Header())
+
+ // Sanity: pivot's canonical hash is now absent.
+ if got := rawdb.ReadCanonicalHash(bc.db, pivot.NumberU64()); got != (common.Hash{}) {
+ t.Fatalf("setup failed: pivot canonical hash still present: %x", got)
+ }
+
+ // The actual call under test.
+ if err := bc.AdvancePartialHead(target.Hash()); err != nil {
+ t.Fatalf("AdvancePartialHead: %v", err)
+ }
+
+ // With the fix: the pivot's canonical hash has been written.
+ if got := rawdb.ReadCanonicalHash(bc.db, pivot.NumberU64()); got != pivot.Hash() {
+ t.Fatalf("pivot canonical hash not written after AdvancePartialHead: got %x, want %x",
+ got, pivot.Hash())
+ }
+ // Existing behavior: blocks strictly above the pivot are also covered by
+ // the backfill loop.
+ mid := blocks[4]
+ if got := rawdb.ReadCanonicalHash(bc.db, mid.NumberU64()); got != mid.Hash() {
+ t.Fatalf("post-pivot canonical hash not written: got %x, want %x",
+ got, mid.Hash())
+ }
+ // And the target itself (bc.CurrentBlock after advance).
+ if got := rawdb.ReadCanonicalHash(bc.db, target.NumberU64()); got != target.Hash() {
+ t.Fatalf("target canonical hash not written: got %x, want %x",
+ got, target.Hash())
+ }
+ if head := bc.CurrentBlock(); head.Number.Uint64() != target.NumberU64() {
+ t.Fatalf("current block not advanced: got %d, want %d", head.Number, target.NumberU64())
+ }
+}
+
+// TestAdvancePartialHeadIdempotent verifies that repeating AdvancePartialHead
+// with a target equal to the current head is a no-op (no error, no panic).
+// This can happen if the Engine API re-requests an advance for a head we
+// already caught up to; the single-line fix introduced a redundant write
+// that must remain harmless.
+func TestAdvancePartialHeadIdempotent(t *testing.T) {
+ addr := common.HexToAddress("0xbeef")
+ bc, gspec := newPartialBlockchain(t, rawdb.HashScheme, []common.Address{addr})
+ defer bc.Stop()
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 3, func(i int, b *BlockGen) {})
+ if _, err := bc.InsertChain(blocks); err != nil {
+ t.Fatalf("failed to insert blocks: %v", err)
+ }
+ head := blocks[2]
+
+ // First advance (redundant — head is already at `head`). Expected: writes
+ // head's canonical hash (already present, so it's a no-op rewrite), loop
+ // does not execute.
+ if err := bc.AdvancePartialHead(head.Hash()); err != nil {
+ t.Fatalf("first AdvancePartialHead: %v", err)
+ }
+ if got := rawdb.ReadCanonicalHash(bc.db, head.NumberU64()); got != head.Hash() {
+ t.Fatalf("head canonical hash lost: got %x, want %x", got, head.Hash())
+ }
+ // And a second call should remain successful.
+ if err := bc.AdvancePartialHead(head.Hash()); err != nil {
+ t.Fatalf("second AdvancePartialHead: %v", err)
+ }
+}
+
+// TestPartialStateRestart_HeadBlock is a small integration check that a
+// partial-state chain reopens cleanly and reports the same head block.
+// The pebble+ancient persistence path is already covered by blockchain_snapshot_test.go;
+// here we only want to confirm that partial-state-enabled config is not
+// itself a blocker on restart.
+func TestPartialStateRestart_HeadBlock(t *testing.T) {
+ // Use the simplified in-memory path. The intent is to catch a regression
+ // where AdvancePartialHead corrupts in-memory state such that a subsequent
+ // CurrentBlock() read returns a stale value. The persistent-restart
+ // scenario is exercised end-to-end via scripts/partial-sync/start_*.sh.
+ addr := common.HexToAddress("0xbeef")
+ bc, gspec := newPartialBlockchain(t, rawdb.HashScheme, []common.Address{addr})
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 5, func(i int, b *BlockGen) {})
+ if _, err := bc.InsertChain(blocks); err != nil {
+ t.Fatalf("failed to insert blocks: %v", err)
+ }
+ want := blocks[4].Hash()
+
+ if err := bc.AdvancePartialHead(blocks[4].Hash()); err != nil {
+ t.Fatalf("AdvancePartialHead: %v", err)
+ }
+ if got := bc.CurrentBlock().Hash(); got != want {
+ t.Fatalf("current block mismatch after advance: got %x, want %x", got, want)
+ }
+
+ // The canonical hash at the new head must be consistent (this is the
+ // property the freezer's gap-check relies on).
+ if got := rawdb.ReadCanonicalHash(bc.db, big.NewInt(5).Uint64()); got != want {
+ t.Fatalf("canonical hash at head mismatch: got %x, want %x", got, want)
+ }
+ bc.Stop()
+}
diff --git a/core/rawdb/chain_freezer.go b/core/rawdb/chain_freezer.go
index 7b5e463900..5de632651c 100644
--- a/core/rawdb/chain_freezer.go
+++ b/core/rawdb/chain_freezer.go
@@ -317,6 +317,15 @@ func (f *chainFreezer) freeze(db ethdb.KeyValueStore) {
frozen, _ = f.Ancients()
if frozen > f.chainRetention {
newTail := frozen - f.chainRetention
+ // Never prune past the snap-sync pivot. Partial-state mode
+ // relies on the pivot block as the anchor for state
+ // reconstruction; if its body/receipts are pruned from the
+ // ancient store, a future reorg spanning the pivot cannot
+ // recover. If lastPivotNumber is unset we keep the classic
+ // formula untouched.
+ if pivot := ReadLastPivotNumber(nfdb); pivot != nil && *pivot < newTail {
+ newTail = *pivot
+ }
oldTail, _ := f.Tail()
if newTail > oldTail {
if _, err := f.TruncateTail(newTail); err != nil {