core, eth: restore stateRoot field using atomic.Pointer

Live testing on bal-devnet-2 confirmed that computed roots DO diverge from
header roots. Block 75315 computed root 0xe909c7.. vs header root
0x9acbbe.. — untracked contracts' storage roots in the local trie are from
snap sync time and differ from the actual current roots, even when the
storage root resolver successfully queries peers.

This means subsequent blocks must chain off the computed root (via
partialState.Root()), not the header root (via parent.Root()). Restore
the stateRoot field using atomic.Pointer[common.Hash] instead of the
previous sync.RWMutex for lock-free concurrent access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CPerezz 2026-02-18 12:00:34 +01:00
parent d50dee20ab
commit 962e2de6e1
No known key found for this signature in database
GPG key ID: 62045F34B97177DD
5 changed files with 65 additions and 17 deletions

View file

@ -3011,14 +3011,23 @@ func (bc *BlockChain) SetCanonical(head *types.Block) (common.Hash, error) {
// Re-execute the reorged chain in case the head state is missing.
if !bc.HasState(head.Root()) {
// Partial state nodes can't re-execute blocks — they only apply BAL diffs.
// The computed root may differ from the header root when untracked contracts
// have unresolved storage roots. Check the partial state's tracked root too.
if bc.partialState != nil {
return common.Hash{}, fmt.Errorf("partial state: missing state for block %d root %x",
head.NumberU64(), head.Root())
partialRoot := bc.partialState.Root()
if partialRoot == (common.Hash{}) || !bc.HasState(partialRoot) {
return common.Hash{}, fmt.Errorf("partial state: missing state for block %d root %x", head.NumberU64(), head.Root())
}
log.Debug("SetCanonical: using partial state root (differs from header)",
"block", head.NumberU64(), "headerRoot", head.Root(),
"partialRoot", partialRoot)
} else {
if latestValidHash, err := bc.recoverAncestors(context.Background(), head, false); err != nil {
return latestValidHash, err
}
log.Info("Recovered head state", "number", head.Number(), "hash", head.Hash())
}
if latestValidHash, err := bc.recoverAncestors(context.Background(), head, false); err != nil {
return latestValidHash, err
}
log.Info("Recovered head state", "number", head.Number(), "hash", head.Hash())
}
// Run the reorg if necessary and set the given block as new head.
start := time.Now()

View file

@ -81,12 +81,18 @@ func (bc *BlockChain) ProcessBlockWithBAL(
// balHash, block.Header().BlockAccessListHash)
// }
// 3. Get parent state root from parent block header.
parent := bc.GetBlock(block.ParentHash(), block.NumberU64()-1)
if parent == nil {
return errors.New("parent block not found")
// 3. Get parent state root. Use partialState's tracked root (the actual
// computed root from the previous block) rather than the header root, which
// may differ when untracked contracts have unresolved storage roots.
parentRoot := bc.partialState.Root()
if parentRoot == (common.Hash{}) {
// First block after sync — use the parent block's header root
parent := bc.GetBlock(block.ParentHash(), block.NumberU64()-1)
if parent == nil {
return errors.New("parent block not found")
}
parentRoot = parent.Root()
}
parentRoot := parent.Root()
// 4. Apply BAL diffs and compute new state root.
// Pass block.Root() as expectedRoot so the resolver can query peers for this
@ -166,7 +172,10 @@ func (bc *BlockChain) HandlePartialReorg(
}
}
log.Debug("Starting partial state reorg from ancestor",
// Step 1: Revert state to common ancestor
bc.partialState.SetRoot(commonAncestor.Root())
log.Debug("Reverted partial state to ancestor",
"ancestor", commonAncestor.Number(),
"ancestorRoot", commonAncestor.Root().Hex(),
"reorgDepth", reorgDepth)

View file

@ -270,11 +270,16 @@ func TestHandlePartialReorg_EmptyNewBlocks(t *testing.T) {
return &bal.BlockAccessList{}, nil
}
// Empty reorg should succeed
// Empty reorg should succeed (sets root to ancestor)
err := bc.HandlePartialReorg(genesisBlock, newBlocks, getBAL)
if err != nil {
t.Fatalf("empty reorg should succeed: %v", err)
}
// Verify state root is set to genesis root
if bc.PartialState().Root() != genesisBlock.Root() {
t.Errorf("expected root to be genesis root after empty reorg")
}
}
// TestHandlePartialReorg_MissingBAL tests error when BAL is missing for a block.

View file

@ -19,6 +19,7 @@ package partial
import (
"bytes"
"fmt"
"sync/atomic"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
@ -49,7 +50,8 @@ type PartialState struct {
history *BALHistory
resolver StorageRootResolver // optional, for resolving untracked storage roots
lastProcessedNum uint64 // last block successfully processed via BAL
stateRoot atomic.Pointer[common.Hash] // computed root (may differ from header root)
lastProcessedNum uint64 // last block successfully processed via BAL
}
// SetResolver sets the storage root resolver used to fetch updated storage roots
@ -73,6 +75,19 @@ func (s *PartialState) Filter() ContractFilter {
return s.filter
}
// SetRoot atomically sets the current computed state root.
func (s *PartialState) SetRoot(root common.Hash) {
s.stateRoot.Store(&root)
}
// Root atomically returns the current computed state root.
func (s *PartialState) Root() common.Hash {
if p := s.stateRoot.Load(); p != nil {
return *p
}
return common.Hash{}
}
// History returns the BAL history manager.
func (s *PartialState) History() *BALHistory {
return s.history
@ -292,8 +307,9 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
stateSet := s.buildStateSet(accounts, accessList)
// Compute unresolved count for caller to decide root mismatch severity.
// The computed root should match the header root since we maintain the full
// account trie and resolve storage roots for untracked contracts.
// The computed root may differ from the header root when untracked contracts
// have unresolved storage roots. Subsequent blocks must chain off the
// computed root (via partialState.Root()), not the header root.
unresolvedCount := 0
if len(untrackedAddrs) > 0 {
unresolvedCount = len(untrackedAddrs)
@ -316,6 +332,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
return common.Hash{}, 0, fmt.Errorf("failed to update trie db: %w", err)
}
s.stateRoot.Store(&root)
return root, unresolvedCount, nil
}

View file

@ -300,8 +300,16 @@ func (api *ConsensusAPI) forkchoiceUpdated(ctx context.Context, update engine.Fo
// If we try to SetCanonical, it will fail because HasState returns false and
// partial state can't recoverAncestors. Instead, treat it like an unknown
// block and trigger BeaconSync so the skeleton can start the sync cycle.
//
// After sync, the computed root may differ from the header root (unresolved
// untracked storage roots), so we also check partialState's tracked root.
partialRoot := common.Hash{}
if api.eth.BlockChain().SupportsPartialState() {
partialRoot = api.eth.BlockChain().PartialState().Root()
}
if api.eth.BlockChain().SupportsPartialState() &&
!api.eth.BlockChain().HasState(block.Root()) {
!api.eth.BlockChain().HasState(block.Root()) &&
(partialRoot == common.Hash{} || !api.eth.BlockChain().HasState(partialRoot)) {
log.Info("Forkchoice: block known but stateless (partial state sync in progress), triggering BeaconSync",
"number", block.NumberU64(), "hash", update.HeadBlockHash, "root", block.Root())
finalized := api.remoteBlocks.get(update.FinalizedBlockHash)