core, eth: PR review fixes and remove stateRoot field from PartialState

Apply review fixes: BAL iterator start (Fix 2), fatal root mismatch when
all storage resolved (Fix 3), WriteBlockWithoutState error handling (Fix 4),
contract filter construction order (Fix 5), canonical hash backfill (Fix 6),
underflow guard in gap processing (Fix 8), O(n²) prepend fix (Fix 9),
ReadBALHistory corruption detection (Fix 11), incomplete resolution error
(Fix 13), RLP encode panic (Fix 14), gap processing log level (Fix 16),
TriggerPartialResync message (Fix 18), and comment accuracy fixes.

Remove the stateRoot field and sync.RWMutex from PartialState entirely.
Since partial state maintains the full account trie, the computed root
always matches the header root (assuming storage root resolution succeeds).
ProcessBlockWithBAL now derives parent root from parent.Root() directly,
matching how full nodes derive state root from currentBlock headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CPerezz 2026-02-18 10:54:39 +01:00
parent e59c92959c
commit d50dee20ab
No known key found for this signature in database
GPG key ID: 62045F34B97177DD
11 changed files with 130 additions and 153 deletions

View file

@ -1389,16 +1389,21 @@ func (bc *BlockChain) AdvancePartialHead(hash common.Hash) error {
// Write canonical hashes for all blocks between the old head and the new head.
// During snap sync, InsertReceiptChain skips blocks that already have bodies
// (HasBlock returns true), so canonical hashes aren't written for post-pivot
// blocks. We backfill them here by walking from the new head back to the
// current canonical head.
// blocks. We backfill them here by walking backward from the new block via
// ParentHash() — this avoids relying on GetHeaderByNumber which itself
// depends on canonical hash mappings that don't exist yet.
batch := bc.db.NewBatch()
currentHead := bc.CurrentBlock()
for num := block.NumberU64(); num > currentHead.Number.Uint64(); num-- {
h := bc.GetHeaderByNumber(num)
if h == nil {
current := block.Header()
for current.Number.Uint64() > currentHead.Number.Uint64() {
rawdb.WriteCanonicalHash(batch, current.Hash(), current.Number.Uint64())
parent := bc.GetHeader(current.ParentHash, current.Number.Uint64()-1)
if parent == nil {
log.Warn("Missing parent during canonical hash backfill",
"number", current.Number.Uint64()-1, "target", block.NumberU64())
break
}
rawdb.WriteCanonicalHash(batch, h.Hash(), num)
current = parent
}
rawdb.WriteHeadBlockHash(batch, block.Hash())
rawdb.WriteHeadHeaderHash(batch, block.Hash())
@ -1836,8 +1841,8 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [
}
// WriteBlockWithoutState writes only the block and its metadata to the database,
// but does not write any state. This is used to construct competing side forks
// up to the point where they exceed the canonical total difficulty.
// but does not write any state. Used by the Engine API to persist blocks before
// state is available (e.g., during partial state sync or when the parent is unknown).
func (bc *BlockChain) WriteBlockWithoutState(block *types.Block) (err error) {
if bc.insertStopped() {
return errInsertionInterrupted
@ -3006,23 +3011,14 @@ 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 {
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())
return common.Hash{}, fmt.Errorf("partial state: missing state for block %d root %x",
head.NumberU64(), head.Root())
}
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

@ -37,8 +37,8 @@ var ErrDeepReorg = errors.New("reorg depth exceeds BAL retention")
// # Trust Model - Why We Don't Re-Verify Consensus Attestations
//
// Post-Merge (PoS) Architecture Trust Boundary:
// - Consensus Layer (CL): Responsible for block proposal, attestations (2/3+ sync committee
// threshold), finality proofs, proposer signatures, and all consensus rules
// - Consensus Layer (CL): Responsible for block proposal, validator attestations,
// finality (Casper FFG), proposer signatures, and all consensus rules
// - Execution Layer (EL): Responsible for transaction execution, state computation, receipts
//
// Blocks received via Engine API (engine_newPayloadV5) have ALREADY been attested by the CL
@ -81,31 +81,32 @@ func (bc *BlockChain) ProcessBlockWithBAL(
// balHash, block.Header().BlockAccessListHash)
// }
// 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()
// 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")
}
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
// state's untracked contracts.
newRoot, err := bc.partialState.ApplyBALAndComputeRoot(parentRoot, block.Root(), accessList)
newRoot, unresolved, err := bc.partialState.ApplyBALAndComputeRoot(parentRoot, block.Root(), accessList)
if err != nil {
return fmt.Errorf("failed to apply BAL: %w", err)
}
// 5. Verify computed root matches header (warning, not fatal — may use fallback)
// 5. Verify computed root matches header.
// If all storage roots were resolved, a mismatch indicates a real bug.
// If some were unresolved, a mismatch is expected (stale storage roots).
if newRoot != block.Root() {
log.Warn("Partial state root sanity check",
"computed", newRoot, "header", block.Root(), "block", block.NumberU64())
if unresolved == 0 {
return fmt.Errorf("state root mismatch (all storage resolved): computed %x, header %x, block %d",
newRoot, block.Root(), block.NumberU64())
}
log.Warn("Partial state root mismatch (unresolved storage roots)",
"computed", newRoot, "header", block.Root(), "block", block.NumberU64(),
"unresolved", unresolved)
}
// 6. Track last processed block for gap detection and HasState checks.
@ -165,11 +166,7 @@ func (bc *BlockChain) HandlePartialReorg(
}
}
// Step 1: Revert state to common ancestor
// Simply set state root to ancestor's root (we have all account trie data)
bc.partialState.SetRoot(commonAncestor.Root())
log.Debug("Reverted partial state to ancestor",
log.Debug("Starting partial state reorg from ancestor",
"ancestor", commonAncestor.Number(),
"ancestorRoot", commonAncestor.Root().Hex(),
"reorgDepth", reorgDepth)
@ -233,5 +230,5 @@ func (bc *BlockChain) TriggerPartialResync(ancestor *types.Header) error {
// 2. Use snap sync to fetch state at ancestor.Root
// 3. Apply ContractFilter to only store tracked contract storage
// 4. Resume normal operation once state is available
return errors.New("partial state resync not yet implemented - manual intervention required")
return errors.New("partial state resync not yet implemented: restart node to re-sync from scratch, or increase --partial-state.bal-retention to handle deeper reorgs")
}

View file

@ -19,6 +19,7 @@ package core
import (
"bytes"
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/common"
@ -189,11 +190,14 @@ func TestProcessBlockWithBAL_StateRootMismatch(t *testing.T) {
}
accessList := constructionToBlockAccessListCore(t, &cbal)
// Root mismatch is now a warning, not an error — the expectedRoot fallback
// is used as the PathDB layer label when peer resolution isn't available.
// When all storage roots are resolved (no untracked contracts), a root
// mismatch is a fatal error — it indicates a real inconsistency.
err := bc.ProcessBlockWithBAL(block, accessList)
if err != nil {
t.Fatalf("unexpected error (root mismatch should be a warning): %v", err)
if err == nil {
t.Fatal("expected error for state root mismatch with no unresolved storage, got nil")
}
if !strings.Contains(err.Error(), "state root mismatch") {
t.Fatalf("expected state root mismatch error, got: %v", err)
}
}
@ -266,16 +270,11 @@ func TestHandlePartialReorg_EmptyNewBlocks(t *testing.T) {
return &bal.BlockAccessList{}, nil
}
// Empty reorg should succeed (just sets root to ancestor)
// Empty reorg should succeed
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

@ -18,6 +18,7 @@ package rawdb
import (
"encoding/binary"
"fmt"
"github.com/ethereum/go-ethereum/core/types/bal"
"github.com/ethereum/go-ethereum/ethdb"
@ -35,18 +36,21 @@ func balHistoryKey(blockNum uint64) []byte {
}
// ReadBALHistory retrieves the Block Access List for a specific block number.
// Returns nil if the BAL is not found or cannot be decoded.
func ReadBALHistory(db ethdb.KeyValueReader, blockNum uint64) *bal.BlockAccessList {
// Returns (nil, nil) if the BAL is not found.
// Returns (nil, error) if the BAL exists but is corrupted.
func ReadBALHistory(db ethdb.KeyValueReader, blockNum uint64) (*bal.BlockAccessList, error) {
data, err := db.Get(balHistoryKey(blockNum))
if err != nil || len(data) == 0 {
return nil
if err != nil {
return nil, nil // Not found (leveldb returns error for missing keys)
}
if len(data) == 0 {
return nil, nil
}
var accessList bal.BlockAccessList
if err := rlp.DecodeBytes(data, &accessList); err != nil {
log.Warn("Failed to decode BAL history", "block", blockNum, "err", err)
return nil
return nil, fmt.Errorf("corrupted BAL at block %d: %w", blockNum, err)
}
return &accessList
return &accessList, nil
}
// WriteBALHistory stores a Block Access List for a specific block number.
@ -70,34 +74,20 @@ func DeleteBALHistory(db ethdb.KeyValueWriter, blockNum uint64) {
// PruneBALHistory removes all BALs before the specified block number.
// This uses range iteration for safe, interruptible pruning.
func PruneBALHistory(db ethdb.Database, beforeBlock uint64) error {
// Create iterator for BAL history range
start := balHistoryKey(0)
end := balHistoryKey(beforeBlock)
// Use batch deletion for efficiency
batch := db.NewBatch()
it := db.NewIterator(balHistoryPrefix, start)
it := db.NewIterator(balHistoryPrefix, nil) // nil = start from beginning of prefix
defer it.Release()
deleted := 0
for it.Next() {
key := it.Key()
// Stop if we've passed the end key
// Extract block number and stop if we've passed the target
if len(key) >= len(balHistoryPrefix)+8 {
blockNum := binary.BigEndian.Uint64(key[len(balHistoryPrefix):])
if blockNum >= beforeBlock {
break
}
}
// Check if key is within our prefix
if len(key) < len(balHistoryPrefix) {
continue
}
for i := range balHistoryPrefix {
if key[i] != balHistoryPrefix[i] {
goto done
}
}
batch.Delete(key)
deleted++
@ -109,7 +99,6 @@ func PruneBALHistory(db ethdb.Database, beforeBlock uint64) error {
batch.Reset()
}
}
done:
// Write remaining items
if batch.ValueSize() > 0 {
if err := batch.Write(); err != nil {
@ -119,7 +108,6 @@ done:
if deleted > 0 {
log.Debug("Pruned BAL history", "deleted", deleted, "beforeBlock", beforeBlock)
}
_ = end // silence unused variable warning (used for documentation)
return it.Error()
}

View file

@ -20,6 +20,7 @@ import (
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types/bal"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/log"
)
// BALHistory manages storage and retrieval of Block Access Lists for reorg handling.
@ -45,7 +46,11 @@ func (h *BALHistory) Store(blockNum uint64, accessList *bal.BlockAccessList) {
// Get retrieves the BAL for a specific block number.
// Returns nil, false if not found.
func (h *BALHistory) Get(blockNum uint64) (*bal.BlockAccessList, bool) {
accessList := rawdb.ReadBALHistory(h.db, blockNum)
accessList, err := rawdb.ReadBALHistory(h.db, blockNum)
if err != nil {
log.Error("Corrupted BAL history entry", "block", blockNum, "err", err)
return nil, false
}
return accessList, accessList != nil
}

View file

@ -49,11 +49,7 @@ type PartialState struct {
history *BALHistory
resolver StorageRootResolver // optional, for resolving untracked storage roots
// Current state root (the actual computed root, may differ from header root)
stateRoot common.Hash
// Last block successfully processed via BAL
lastProcessedNum uint64
lastProcessedNum uint64 // last block successfully processed via BAL
}
// SetResolver sets the storage root resolver used to fetch updated storage roots
@ -77,16 +73,6 @@ func (s *PartialState) Filter() ContractFilter {
return s.filter
}
// SetRoot sets the current state root.
func (s *PartialState) SetRoot(root common.Hash) {
s.stateRoot = root
}
// Root returns the current state root.
func (s *PartialState) Root() common.Hash {
return s.stateRoot
}
// History returns the BAL history manager.
func (s *PartialState) History() *BALHistory {
return s.history
@ -125,11 +111,11 @@ type accountState struct {
// Phase 1.5: Resolve storage roots for untracked contracts with storage changes
// Phase 2: Update account Root fields with committed storage roots
// Phase 3: Commit account trie to get final state root
func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRoot common.Hash, accessList *bal.BlockAccessList) (common.Hash, error) {
func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRoot common.Hash, accessList *bal.BlockAccessList) (common.Hash, int, error) {
// Open state trie at parent root
tr, err := trie.NewStateTrie(trie.StateTrieID(parentRoot), s.trieDB)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to open state trie: %w", err)
return common.Hash{}, 0, fmt.Errorf("failed to open state trie: %w", err)
}
// Collect all account states with origin tracking
@ -145,7 +131,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
// Get current account state with origin tracking
data, err := tr.GetAccount(addr)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to get account %s: %w", addr.Hex(), err)
return common.Hash{}, 0, fmt.Errorf("failed to get account %s: %w", addr.Hex(), err)
}
existed := data != nil
@ -214,7 +200,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
newStorageRoot, storageNodes, err := s.applyStorageChanges(
addr, parentRoot, account.Root, &access)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to apply storage for %s: %w",
return common.Hash{}, 0, fmt.Errorf("failed to apply storage for %s: %w",
addr.Hex(), err)
}
state.storageRoot = newStorageRoot
@ -223,7 +209,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
// Merge storage nodes
if storageNodes != nil {
if err := allNodes.Merge(storageNodes); err != nil {
return common.Hash{}, err
return common.Hash{}, 0, err
}
}
}
@ -278,7 +264,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
// Only delete if it existed before (don't delete never-existed accounts)
if state.existed {
if err := tr.DeleteAccount(state.addr); err != nil {
return common.Hash{}, fmt.Errorf("failed to delete account %s: %w",
return common.Hash{}, 0, fmt.Errorf("failed to delete account %s: %w",
state.addr.Hex(), err)
}
}
@ -287,7 +273,7 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
}
if err := tr.UpdateAccount(state.addr, state.account, 0); err != nil {
return common.Hash{}, fmt.Errorf("failed to update account %s: %w",
return common.Hash{}, 0, fmt.Errorf("failed to update account %s: %w",
state.addr.Hex(), err)
}
}
@ -298,19 +284,19 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
// Merge account nodes
if accountNodes != nil {
if err := allNodes.Merge(accountNodes); err != nil {
return common.Hash{}, err
return common.Hash{}, 0, err
}
}
// Build StateSet for PathDB compatibility
stateSet := s.buildStateSet(accounts, accessList)
// Always use the actual computed root for the PathDB layer. Even if untracked
// contracts have stale storage roots (making the computed root differ from the
// header), subsequent blocks must chain off the real trie structure.
// ProcessBlockWithBAL uses partialState.Root() (not header root) as parentRoot.
// 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.
unresolvedCount := 0
if len(untrackedAddrs) > 0 {
unresolvedCount := len(untrackedAddrs)
unresolvedCount = len(untrackedAddrs)
if resolved != nil {
for _, addr := range untrackedAddrs {
if _, ok := resolved[addr]; ok {
@ -327,11 +313,10 @@ func (s *PartialState) ApplyBALAndComputeRoot(parentRoot common.Hash, expectedRo
// Write all trie nodes and state to database
if err := s.trieDB.Update(root, parentRoot, 0, allNodes, stateSet); err != nil {
return common.Hash{}, fmt.Errorf("failed to update trie db: %w", err)
return common.Hash{}, 0, fmt.Errorf("failed to update trie db: %w", err)
}
s.stateRoot = root
return root, nil
return root, unresolvedCount, nil
}
// buildStateSet constructs StateSet for trieDB.Update() (required for PathDB).
@ -386,7 +371,10 @@ func (s *PartialState) addStorageToStateSet(stateSet *triedb.StateSet, addr comm
storageMap[slotHash] = nil // nil = deletion
} else {
// Prefix-zero-trimmed RLP encoding
blob, _ := rlp.EncodeToBytes(common.TrimLeftZeroes(value[:]))
blob, err := rlp.EncodeToBytes(common.TrimLeftZeroes(value[:]))
if err != nil {
panic(fmt.Sprintf("failed to RLP-encode storage value: %v", err))
}
storageMap[slotHash] = blob
}
}

View file

@ -157,7 +157,7 @@ func TestApplyBALAndComputeRoot_EmptyBAL(t *testing.T) {
emptyBAL := bal.BlockAccessList{}
accessList := &emptyBAL
newRoot, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply empty BAL: %v", err)
}
@ -189,7 +189,7 @@ func TestApplyBALAndComputeRoot_BalanceChange(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -232,7 +232,7 @@ func TestApplyBALAndComputeRoot_NonceChange(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -273,7 +273,7 @@ func TestApplyBALAndComputeRoot_StorageChange(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -333,7 +333,7 @@ func TestApplyBALAndComputeRoot_UntrackedContractStorageIgnored(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -365,7 +365,7 @@ func TestApplyBALAndComputeRoot_NewAccount(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -412,7 +412,7 @@ func TestApplyBALAndComputeRoot_CodeChange(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -459,7 +459,7 @@ func TestApplyBALAndComputeRoot_MultipleTransactions(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -527,7 +527,7 @@ func TestApplyBALAndComputeRoot_StorageDeletion(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -567,7 +567,7 @@ func TestApplyBALAndComputeRoot_MultipleStorageWritesSameSlot(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -613,7 +613,7 @@ func TestApplyBALAndComputeRoot_AccountDeletion_EIP161(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -642,7 +642,7 @@ func TestApplyBALAndComputeRoot_NeverExistedEmptyAccount(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(emptyRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -700,7 +700,7 @@ func TestApplyBALAndComputeRoot_CodeChangeUntracked(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -762,7 +762,7 @@ func TestApplyBALAndComputeRoot_MixedChanges(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -809,7 +809,7 @@ func TestApplyBALAndComputeRoot_ErrorInvalidParentRoot(t *testing.T) {
cbal.BalanceChange(0, addr, uint256.NewInt(1000))
accessList := cbal.Build(t)
_, err := ps.ApplyBALAndComputeRoot(invalidRoot, common.Hash{}, accessList)
_, _, err := ps.ApplyBALAndComputeRoot(invalidRoot, common.Hash{}, accessList)
if err == nil {
t.Fatal("expected error for invalid parent root, got nil")
}
@ -928,7 +928,7 @@ func TestBuildStateSet_AccountModification(t *testing.T) {
cbal.BalanceChange(0, addr, uint256.NewInt(2000))
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -973,7 +973,7 @@ func TestBuildStateSet_StorageRLPEncoding(t *testing.T) {
cbal.StorageWrite(0, addr, slot, value)
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -1019,7 +1019,7 @@ func TestBuildStateSet_OriginTracking(t *testing.T) {
cbal.NonceChange(addr, 0, 11)
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}
@ -1092,7 +1092,7 @@ func TestApplyBALAndComputeRoot_MultipleAccountTypes(t *testing.T) {
accessList := cbal.Build(t)
newRoot, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
newRoot, _, err := ps.ApplyBALAndComputeRoot(parentRoot, common.Hash{}, accessList)
if err != nil {
t.Fatalf("failed to apply BAL: %v", err)
}

View file

@ -284,8 +284,13 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
options.Overrides = &overrides
options.BALExecutionMode = config.BALExecutionMode
// Wire partial state configuration into the blockchain
// Wire partial state configuration into the blockchain.
// Load contracts from file FIRST, before wiring into blockchain, so both
// blockchain and downloader see the same contract list.
if config.PartialState.Enabled {
if err := config.PartialState.LoadPartialStateContracts(); err != nil {
return nil, fmt.Errorf("failed to load partial state contracts: %w", err)
}
options.PartialStateEnabled = true
options.PartialStateContracts = config.PartialState.Contracts
options.PartialStateBALRetention = config.PartialState.BALRetention
@ -348,12 +353,9 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
// Permit the downloader to use the trie cache allowance during fast sync
cacheLimit := options.TrieCleanLimit + options.TrieDirtyLimit + options.SnapshotLimit
// Create partial state filter if enabled
// Create partial state filter if enabled (contracts already loaded above)
var partialFilter partial.ContractFilter
if config.PartialState.Enabled {
if err := config.PartialState.LoadPartialStateContracts(); err != nil {
return nil, fmt.Errorf("failed to load partial state contracts: %w", err)
}
partialFilter = partial.NewConfiguredFilter(config.PartialState.Contracts)
log.Info("Partial state mode enabled",
"contracts", len(config.PartialState.Contracts),

View file

@ -23,6 +23,7 @@ import (
"fmt"
"reflect"
"strconv"
"slices"
"sync"
"sync/atomic"
"time"
@ -299,16 +300,8 @@ 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()) &&
(partialRoot == common.Hash{} || !api.eth.BlockChain().HasState(partialRoot)) {
!api.eth.BlockChain().HasState(block.Root()) {
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)
@ -885,6 +878,7 @@ func (api *ConsensusAPI) newPayload(ctx context.Context, params engine.Executabl
if api.eth.BlockChain().SupportsPartialState() {
if err := api.eth.BlockChain().WriteBlockWithoutState(block); err != nil {
log.Warn("NewPayload: failed to persist block for partial state catch-up", "number", block.NumberU64(), "err", err)
return engine.PayloadStatusV1{Status: engine.SYNCING}, nil
}
if params.BlockAccessList != nil {
rawdb.WriteAccessList(api.eth.ChainDb(), block.Hash(), block.NumberU64(), params.BlockAccessList)
@ -909,6 +903,7 @@ func (api *ConsensusAPI) newPayload(ctx context.Context, params engine.Executabl
if api.eth.BlockChain().SupportsPartialState() {
if err := api.eth.BlockChain().WriteBlockWithoutState(block); err != nil {
log.Warn("NewPayload: failed to persist block for partial state catch-up", "number", block.NumberU64(), "err", err)
return engine.PayloadStatusV1{Status: engine.SYNCING}, nil
}
if params.BlockAccessList != nil {
rawdb.WriteAccessList(api.eth.ChainDb(), block.Hash(), block.NumberU64(), params.BlockAccessList)
@ -922,12 +917,13 @@ func (api *ConsensusAPI) newPayload(ctx context.Context, params engine.Executabl
if api.eth.BlockChain().SupportsPartialState() && params.BlockAccessList != nil {
log.Info("NewPayload: entering BAL processing path",
"number", block.NumberU64(), "hash", block.Hash(),
"parent", parent.NumberU64(), "hasBAL", params.BlockAccessList != nil)
"parent", parent.NumberU64())
// Before processing this block, catch up any unprocessed ancestor
// blocks that accumulated during the second state sync phase. Their
// bodies and BALs were persisted to the database when delayed.
if err := api.processPartialStateGap(block); err != nil {
log.Warn("Failed to process partial state gap", "block", block.NumberU64(), "error", err)
log.Error("Failed to process partial state gap, delaying block",
"block", block.NumberU64(), "error", err)
return api.delayPayloadImport(block), nil
}
log.Trace("Processing block with BAL (partial state mode)", "hash", block.Hash(), "number", block.Number())
@ -942,8 +938,7 @@ func (api *ConsensusAPI) newPayload(ctx context.Context, params engine.Executabl
}
processingTime := time.Since(start)
// Write block to DB so ForkchoiceUpdated can find it via GetBlockByHash.
// This writes header + body + BAL without requiring receipts or full state.
// Write block (header + body) to DB so ForkchoiceUpdated can find it via GetBlockByHash.
if err := api.eth.BlockChain().WriteBlockWithoutState(block); err != nil {
return api.invalid(err, parent.Header()), nil
}
@ -1054,6 +1049,9 @@ func (api *ConsensusAPI) processPartialStateGap(target *types.Block) error {
var gap []*types.Block
current := target
for {
if current.NumberU64() == 0 {
break
}
parentHash := current.ParentHash()
parentNum := current.NumberU64() - 1
@ -1067,9 +1065,10 @@ func (api *ConsensusAPI) processPartialStateGap(target *types.Block) error {
if bc.HasState(parent.Root()) || parent.NumberU64() <= bc.PartialState().LastProcessedBlock() {
break // Found an ancestor with state — this is our starting point
}
gap = append([]*types.Block{parent}, gap...)
gap = append(gap, parent)
current = parent
}
slices.Reverse(gap)
if len(gap) == 0 {
return nil // No gap to fill
}

View file

@ -98,6 +98,9 @@ func (h *handler) ResolveStorageRoots(
log.Warn("Failed to resolve storage root", "addr", addr, "attempts", storageRootMaxRetries)
}
}
if len(resolved) < len(addrs) {
return resolved, fmt.Errorf("resolved %d/%d storage roots", len(resolved), len(addrs))
}
return resolved, nil
}

View file

@ -172,7 +172,7 @@ func (e *invalidBlockTimestampError) ErrorCode() int { return errCodeBlockTimest
type blockGasLimitReachedError struct{ message string }
// Partial state error codes per EIP-7928 / partial statefulness spec
// Partial state error codes for untracked contract queries
const (
errCodeStorageNotTracked = -32001
errCodeCodeNotTracked = -32002