diff --git a/core/blockchain.go b/core/blockchain.go index f3602d98f9..45eee13a19 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -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() diff --git a/core/blockchain_partial.go b/core/blockchain_partial.go index 3a24f37f69..6b50ac1c26 100644 --- a/core/blockchain_partial.go +++ b/core/blockchain_partial.go @@ -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) diff --git a/core/blockchain_partial_test.go b/core/blockchain_partial_test.go index 367d1ccaae..d473ed70a8 100644 --- a/core/blockchain_partial_test.go +++ b/core/blockchain_partial_test.go @@ -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. diff --git a/core/state/partial/state.go b/core/state/partial/state.go index c14c58f3db..e5f29a889c 100644 --- a/core/state/partial/state.go +++ b/core/state/partial/state.go @@ -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 } diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 0212210e06..d23a174afa 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -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)