Fix the post-sync deadlock where blocks validated via BAL in newPayload were never written to the database, causing ForkchoiceUpdated to fail finding them and triggering infinite sync cycles. Changes: - Export WriteBlockWithoutState and call it after ProcessBlockWithBAL in newPayload, so FCU can find blocks via GetBlockByHash - Guard SetCanonical against recoverAncestors for partial state nodes (they can't re-execute blocks, only apply BAL diffs) - Auto-disable log indexing when partial state is enabled (no receipts) - Fix BAL type field accesses to match upstream bal-devnet-2 types (StorageChanges, CodeChanges, BalanceChanges, Validate signature) - Update newPayload signature (BAL now comes from ExecutableData params) - Add partial sync scripts and documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17 KiB
Phase 3: BAL Processing & State Updates for Partial Statefulness
Overview
Goal: Enable partial state nodes to process blocks using Block Access Lists (BALs) instead of re-executing transactions. This allows state updates without needing full contract storage.
Key principle: BALs (per EIP-7928) provide state diffs that allow computing the new state root by applying changes directly to the trie, without transaction execution.
Prerequisites
- Phase 1 (Configuration & Infrastructure): ✓ Complete
- Phase 2 (Snap Sync Modifications): ✓ Complete
- EIP-7928 BAL types already exist in
core/types/bal/
Design Overview
┌─────────────────────────────────────────────────────────────────┐
│ Block Processing Flow │
│ │
│ Full Node: Block → Execute TXs → Compute State Root │
│ │
│ Partial Node: Block + BAL → Apply BAL Diffs → Verify Root │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BAL Application Flow │
│ │
│ 1. Receive block + BAL (via Engine API) │
│ 2. Verify: keccak256(rlp(BAL)) == header.BlockAccessListHash │
│ 3. For each AccountAccess in BAL: │
│ a. Load account from trie │
│ b. Apply balance/nonce changes (final values) │
│ c. Apply storage changes (tracked contracts only) │
│ d. Update account in trie │
│ 4. Commit trie → Verify root matches header.stateRoot │
│ 5. Store BAL for reorg handling │
└─────────────────────────────────────────────────────────────────┘
Existing Infrastructure (ALREADY EXISTS - REUSE!)
Based on agent exploration, the following infrastructure already exists and is production-ready:
| Component | Location | Status |
|---|---|---|
| BAL Types | core/types/bal/bal.go |
✅ Complete - ConstructionBlockAccessList, BlockAccessList |
| BAL Encoding | core/types/bal/bal_encoding.go |
✅ Complete - RLP, Hash(), Validate() |
| DB Schema | core/rawdb/schema.go:172 |
✅ Complete - prefix "p" |
| DB Accessors | core/rawdb/accessors_bal.go |
✅ Complete - Read/Write/Delete/Prune |
| BALHistory | core/state/partial/history.go |
✅ Complete - wrapper over rawdb |
| PartialState | core/state/partial/state.go |
⚠️ Skeleton - needs ApplyBALAndComputeRoot() |
| ContractFilter | core/state/partial/filter.go |
✅ Complete - ConfiguredFilter, AllowAllFilter |
| Trie Interface | trie/trie.go |
✅ Standard trie operations |
What this means: Tasks 3.2, 3.3, 3.4 are already done! We only need to implement:
ApplyBALAndComputeRoot()in PartialStateProcessBlockWithBAL()in BlockChain- Reorg handling
- Tests
Detailed Implementation Plan
Task 3.1: Review/Extend Existing PartialState Struct
File: core/state/partial/state.go (ALREADY EXISTS!)
Agent Finding: PartialState skeleton already exists with correct structure:
type PartialState struct {
db ethdb.Database
trieDB *triedb.Database
filter ContractFilter
history *BALHistory // Already includes history!
stateRoot common.Hash
}
Current methods (already implemented):
NewPartialState()- Constructor ✅Filter()- Filter access ✅Root()/SetRoot()- Root management ✅History()- BAL history access ✅
Key patterns from StateDB (confirmed by agent):
- PartialState does NOT need
stateObjectscaching (applies BAL directly to trie) - PartialState does NOT need journal/revert (BAL diffs are immutable)
- PartialState does NOT need prefetcher (not executing contracts)
- Error handling: return errors immediately (no memoization)
What needs to be added:
ApplyBALAndComputeRoot()method (Task 3.5)- Optional metrics fields for monitoring
Estimated changes: ~10 lines (mostly just adding ApplyBALAndComputeRoot)
Task 3.2: ✅ ALREADY EXISTS - BAL History Database Schema
File: core/rawdb/schema.go line 172
Agent confirmed: Schema already exists!
balHistoryPrefix = []byte("p") // balHistoryPrefix + num (uint64 big endian) -> RLP(bal.BlockAccessList)
Key format: "p" + blockNumber(8 bytes, big-endian) → RLP-encoded BlockAccessList
Estimated changes: 0 lines (already exists)
Task 3.3: ✅ ALREADY EXISTS - BAL History Accessors
File: core/rawdb/accessors_bal.go
Agent confirmed: All accessors already implemented!
ReadBALHistory(db, blockNum)✅WriteBALHistory(db, blockNum, accessList)✅DeleteBALHistory(db, blockNum)✅HasBALHistory(db, blockNum)✅PruneBALHistory(db, beforeBlock)✅ (with safe range iteration)
Estimated changes: 0 lines (already exists)
Task 3.4: ✅ ALREADY EXISTS - BALHistory Wrapper
File: core/state/partial/history.go
Agent confirmed: BALHistory wrapper already implemented!
type BALHistory struct {
db ethdb.Database
retention uint64
}
// Methods: Store(), Get(), Delete(), Prune(), Retention()
Design note: We have BOTH:
- BALHistory in
partial/history.go- for explicit BAL storage/retrieval - Blocks contain BALs - can also access via block
For reorgs, we'll use BALHistory since it's already built and tested.
Estimated changes: 0 lines (already exists)
Task 3.5: Implement ApplyBALAndComputeRoot
File: core/state/partial/state.go (extend)
Key implementation requirements (from code review and agent research):
- BAL field names: Use
Accesses(notWrites) andValueAfter(notValue) percore/types/bal/bal_encoding.go - Commit ordering: Storage tries → update account.Root → account trie (critical for correct state root)
- Account origin tracking: Track
existedflag to prevent incorrect EIP-161 deletion - Code handling: Update CodeHash for ALL accounts, store code bytes only for tracked contracts
- PathDB StateSet: Must construct proper
triedb.StateSetfortrieDB.Update()call
PathDB StateSet construction (from agent research on core/state/statedb.go):
The trieDB.Update() signature is:
func (db *Database) Update(root, parent common.Hash, block uint64, nodes *trienode.MergedNodeSet, states *StateSet) error
The StateSet structure requires:
type StateSet struct {
Accounts map[common.Hash][]byte // Mutated accounts in 'slim RLP' encoding
AccountsOrigin map[common.Address][]byte // Original account values (for PathDB)
Storages map[common.Hash]map[common.Hash][]byte // Storage: accountHash → slotHash → value
StoragesOrigin map[common.Address]map[common.Hash][]byte // Original storage values
RawStorageKey bool // false = use hashed keys
}
Key encoding requirements:
- Accounts: Use
types.SlimAccountRLP(account)for encoding - Storage values: Use prefix-zero-trimmed RLP (
rlp.EncodeToBytes(common.TrimLeftZeroes(val[:]))) - Storage keys: Must be hashed (
crypto.Keccak256Hash(rawKey[:])) - Nil values indicate deletion
Estimated changes: ~250 lines (includes PathDB StateSet construction)
Task 3.6: Implement ProcessBlockWithBAL
File: core/blockchain_partial.go (NEW)
Trust Model: Blocks via Engine API are pre-attested by the Consensus Layer. The function documents this trust model clearly in its comments, explaining why no additional attestation verification is needed (same as full nodes).
Estimated changes: ~100 lines
Task 3.7: Implement Reorg Handling
File: core/blockchain_partial.go (extend)
DESIGN: Reorg handling accesses blocks directly (which contain BALs), NOT a separate BALHistory. This mirrors how full nodes handle reorgs.
Key differences from full node reorg:
- Full node: re-executes transactions on new chain
- Partial node: applies BALs from new chain blocks
Estimated changes: ~50 lines
Task 3.8: Wire PartialState into BlockChain
File: core/blockchain.go (modify)
Agent findings on BlockChain state patterns:
Existing state fields (lines 311-366):
type BlockChain struct {
db ethdb.Database // Low-level persistent database
snaps *snapshot.Tree // Snapshot tree for fast trie leaf access
triedb *triedb.Database // TrieDB handler for maintaining trie nodes
statedb *state.CachingDB // State database (reused between imports)
// ... caches, processor, validator, etc.
}
Add partialState alongside existing fields:
type BlockChain struct {
// ... existing fields ...
// Partial state management (nil if full node)
partialState *partial.PartialState
}
Estimated changes: ~40 lines
Task 3.9: Add Unit Tests
File: core/state/partial/state_test.go (NEW)
func TestApplyBALAndComputeRoot(t *testing.T) {
// Test that BAL application produces correct state root
}
func TestApplyStorageChanges(t *testing.T) {
// Test storage updates for tracked contracts
}
func TestApplyBalanceChanges(t *testing.T) {
// Test balance updates from BAL
}
func TestFilteredStorageChanges(t *testing.T) {
// Test that untracked contract storage is not applied
}
Estimated changes: ~100 lines
Task 3.10: Integration Test
File: core/blockchain_partial_test.go (NEW)
Test end-to-end BAL processing:
- Create a chain with known state
- Generate BALs for blocks
- Process blocks with
ProcessBlockWithBAL - Verify state roots match
- Test reorg handling
Estimated changes: ~200 lines
Files to Modify/Create Summary
| File | Status | Changes |
|---|---|---|
docs/partial-state/PARTIAL_STATEFULNESS_PLAN.md |
NEW | Copy master plan from .claude/plans/ |
docs/partial-state/PHASE3_PLAN.md |
NEW | Copy this Phase 3 plan |
core/state/partial/state.go |
EXTEND | Add ApplyBALAndComputeRoot() + StateSet (~250 lines) |
core/rawdb/schema.go |
✅ EXISTS | balHistoryPrefix already defined |
core/rawdb/accessors_bal.go |
✅ EXISTS | All accessors already implemented |
core/state/partial/history.go |
✅ EXISTS | BALHistory wrapper already implemented |
core/blockchain.go |
MODIFY | Add partialState field, initialization (~40 lines) |
core/blockchain_partial.go |
NEW | ProcessBlockWithBAL, reorg, attestation (~150 lines) |
core/state/partial/state_test.go |
NEW | Unit tests (~100 lines) |
core/blockchain_partial_test.go |
NEW | Integration tests (~200 lines) |
Total estimated new code: ~710 lines Infrastructure already exists: ~300 lines (schema, accessors, history)
Task Summary
| Task ID | Description | Dependencies | Effort | Status |
|---|---|---|---|---|
| 1 | Save master plan + Phase 3 plan to docs/partial-state/ | None | S | TODO |
| 3.1 | Review existing PartialState, add metrics | Phase 1 | S | Exists |
| 3.2 | BAL history DB schema | None | - | ✅ EXISTS |
| 3.3 | BAL history accessors | 3.2 | - | ✅ EXISTS |
| 3.4 | BALHistory wrapper | 3.3 | - | ✅ EXISTS |
| 3.5 | Implement ApplyBALAndComputeRoot with PathDB StateSet |
3.1 | L | TODO |
| 3.6 | Implement ProcessBlockWithBAL with trust model docs |
3.5 | M | TODO |
| 3.7 | Implement reorg handling (uses BALHistory) | 3.6 | M | TODO |
| 3.8 | Wire into BlockChain | 3.6 | S | TODO |
| 3.9 | Unit tests | 3.5, 3.7 | M | TODO |
| 3.10 | Integration test | 3.6, 3.7 | L | TODO |
Effort: S = Small (few hours), M = Medium (1-2 days), L = Large (3-5 days)
Good news: Tasks 3.2, 3.3, 3.4 are already implemented! Only need to implement 3.5-3.10.
Dependency Graph
Task 1 (Save master plan + Phase 3 plan)
↓
3.1 (Review existing PartialState) ─── 3.2/3.3/3.4 ✅ ALREADY EXIST
↓
3.5 (ApplyBALAndComputeRoot with PathDB StateSet)
↓
3.6 (ProcessBlockWithBAL with trust model docs)
↓
3.7 (Reorg handling via BALHistory)
↓
3.8 (Wire into BlockChain)
↓
3.9 (Unit tests)
↓
3.10 (Integration test)
Verification Checklist
Pre-implementation (completed):
- Code review completed for ApplyBALAndComputeRoot design
- BAL field names verified:
Accesses,ValueAfter(fromcore/types/bal/bal_encoding.go) - Commit ordering documented: storage tries before account trie
- PathDB StateSet construction researched and documented
- SELFDESTRUCT handling verified: tracked in BAL per EIP-7928
- Engine API delivery researched: standardized via engine_newPayloadV5, etc.
After implementation:
- Master plan saved to
docs/partial-state/PARTIAL_STATEFULNESS_PLAN.md - Phase 3 plan saved to
docs/partial-state/PHASE3_PLAN.md - PartialState struct follows StateDB patterns
- BAL hash verification works correctly
- Balance/nonce/codeHash changes apply correctly for ALL accounts
- Storage/code bytes stored only for tracked contracts
- Commit ordering correct: storage trie commit → update account.Root → account trie commit
- EIP-161 empty account deletion only for modified+empty+existed accounts
- PathDB StateSet properly constructed with origins
- Computed state root matches header
- Reorg handling works (via blocks, not separate BALHistory)
- All unit tests pass
- Integration test passes
Local Testing Strategy
1. Unit Test Execution
go test ./core/state/partial/... -v
go test ./core/rawdb/... -run TestBAL -v
2. Build Verification
go build ./...
go build ./cmd/geth
3. Integration Test
go test ./core/... -run TestPartialBlock -v -timeout 5m
Open Items
- Engine API Integration: BAL delivery is already standardized via extended Engine API methods:
engine_newPayloadV5: Validates computed access lists match provided BALengine_getPayloadV6: ReturnsExecutionPayloadV4containing RLP-encoded BALengine_getPayloadBodiesByHashV2/engine_getPayloadBodiesByRangeV2: Retrieve historical BALs- Status: No additional design needed - use existing Engine API
Critical Invariants
- State root must match: Computed root from BAL application MUST match header's stateRoot
- BAL hash verification: Always verify BAL hash before processing
- Account trie complete: All account changes apply (balance, nonce, codeHash); only storage/code bytes are filtered for untracked
- No execution required: Block processing uses only BAL data, never re-executes transactions
- Commit ordering: Storage tries MUST be committed BEFORE account trie (storage roots needed first)
- EIP-161 compliance: Only delete accounts that were modified AND are now empty AND previously existed
- BAL field names: Use
Accesses(notWrites) andValueAfter(notValue) percore/types/bal/bal_encoding.go - PathDB StateSet: Must construct proper
triedb.StateSetwith accounts/storage and their origins fortrieDB.Update()
Design Decisions
-
SELFDESTRUCT is tracked: Per EIP-7928, "Accounts destroyed within a transaction MUST be included in AccountChanges without nonce or code changes." Self-destructed accounts appear in BAL with balance changes but no nonce/code changes.
-
Code handling for tracked vs untracked contracts:
- All accounts: Update
CodeHashin account trie (required for correct state root) - Tracked contracts only: Store actual code bytes via
rawdb.WriteCode() - Untracked contracts: Skip storing code bytes (saves storage, code not needed for partial state)
- All accounts: Update
-
Block attestation trust model (Post-Merge architecture):
- CL responsibility: Proposer signatures, sync committee attestations (2/3+ threshold), finality proofs, consensus rules
- EL responsibility: Transaction execution, state root computation, receipt validation
- Trust boundary: Blocks via Engine API (
engine_newPayloadV5) are pre-attested by CL; EL trusts CL for consensus - Partial state nodes: Receive blocks via Engine API, so attestations are already verified
- Light client sync (future): If blocks come from untrusted sources, use
beacon/light/CommitteeChain.VerifySignedHeader()