diff --git a/core/blockchain.go b/core/blockchain.go index 5830e93fc6..f3602d98f9 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -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() diff --git a/core/blockchain_partial.go b/core/blockchain_partial.go index 6f1878886f..3a24f37f69 100644 --- a/core/blockchain_partial.go +++ b/core/blockchain_partial.go @@ -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") } diff --git a/core/blockchain_partial_test.go b/core/blockchain_partial_test.go index ef12c2a4bb..367d1ccaae 100644 --- a/core/blockchain_partial_test.go +++ b/core/blockchain_partial_test.go @@ -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. diff --git a/core/rawdb/accessors_bal.go b/core/rawdb/accessors_bal.go index cb0f50ab6e..286d4b2747 100644 --- a/core/rawdb/accessors_bal.go +++ b/core/rawdb/accessors_bal.go @@ -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() } diff --git a/core/state/partial/history.go b/core/state/partial/history.go index af53c041e0..a42875bbd5 100644 --- a/core/state/partial/history.go +++ b/core/state/partial/history.go @@ -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 } diff --git a/core/state/partial/state.go b/core/state/partial/state.go index e747b50ef9..c14c58f3db 100644 --- a/core/state/partial/state.go +++ b/core/state/partial/state.go @@ -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 } } diff --git a/core/state/partial/state_test.go b/core/state/partial/state_test.go index bc99f52190..0929411f2d 100644 --- a/core/state/partial/state_test.go +++ b/core/state/partial/state_test.go @@ -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) } diff --git a/eth/backend.go b/eth/backend.go index d52ac72128..38842ac5eb 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -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), diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index e9f7188102..0212210e06 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -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 } diff --git a/eth/handler_partial.go b/eth/handler_partial.go index 580bb8df8a..53ae62d766 100644 --- a/eth/handler_partial.go +++ b/eth/handler_partial.go @@ -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 } diff --git a/internal/ethapi/errors.go b/internal/ethapi/errors.go index 442aff91fc..7eb9b2a34a 100644 --- a/internal/ethapi/errors.go +++ b/internal/ethapi/errors.go @@ -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