Skip to content

Commit ab501fc

Browse files
AskAlexSharovmh0ltMark Holtclaude
authored
[r3.4] exec: fix partial block receipt reconstruction (#20452) (#20849)
Cherry-pick of #20467 to release/3.4. When execution resumes from a snapshot boundary mid-block (`initialBlockTxOffset > 0`), the receipts passed to `engine.Finalize()` only contained receipts from the re-executed portion. This causes Pectra requests hash validation to fail because deposit request extraction needs ALL receipt logs. Reproduces as: `invalid requests root hash in header` at block 24966723 on mainnet re-sync. Co-authored-by: Mark Holt <135143369+mh0lt@users.noreply.github.com> Co-authored-by: Mark Holt <erigon@dev-bm-e3-ethmainnet-n4.erigon.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f1cb909 commit ab501fc

3 files changed

Lines changed: 209 additions & 6 deletions

File tree

execution/receipts/derive.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright 2026 The Erigon Authors
2+
// This file is part of Erigon.
3+
//
4+
// Erigon is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Erigon is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.
16+
17+
// Package receipts provides shared receipt derivation by replaying transactions.
18+
// Used by both the RPC layer (rpc/jsonrpc/receipts) and the execution pipeline
19+
// (execution/stagedsync) to avoid duplicating transaction replay logic.
20+
package receipts
21+
22+
import (
23+
"context"
24+
"fmt"
25+
26+
"github.com/erigontech/erigon/common"
27+
"github.com/erigontech/erigon/execution/chain"
28+
"github.com/erigontech/erigon/execution/protocol"
29+
"github.com/erigontech/erigon/execution/protocol/rules"
30+
"github.com/erigontech/erigon/execution/state"
31+
"github.com/erigontech/erigon/execution/types"
32+
"github.com/erigontech/erigon/execution/types/accounts"
33+
"github.com/erigontech/erigon/execution/vm"
34+
)
35+
36+
// GetHeaderFunc returns a header by hash+number. Used for BLOCKHASH opcode.
37+
type GetHeaderFunc = func(hash common.Hash, number uint64) (*types.Header, error)
38+
39+
// DeriveForRange replays transactions fromIdx..toIdx-1 (0-based within the block)
40+
// against the provided IntraBlockState and returns receipts for each.
41+
//
42+
// The caller is responsible for:
43+
// - Creating the IntraBlockState with the correct state reader (history or live)
44+
// - Providing a GasPool with the block's gas limit
45+
// - Providing the GetHeader function for BLOCKHASH resolution
46+
//
47+
// No caching — callers wrap this with their own caching layer.
48+
func DeriveForRange(
49+
ctx context.Context,
50+
cfg *chain.Config,
51+
engine rules.EngineReader,
52+
header *types.Header,
53+
txns types.Transactions,
54+
fromIdx int,
55+
toIdx int,
56+
ibs *state.IntraBlockState,
57+
gp *protocol.GasPool,
58+
getHeader GetHeaderFunc,
59+
) (types.Receipts, error) {
60+
if fromIdx < 0 {
61+
fromIdx = 0
62+
}
63+
if toIdx > len(txns) {
64+
toIdx = len(txns)
65+
}
66+
if fromIdx >= toIdx {
67+
return nil, nil
68+
}
69+
70+
blockNum := header.Number.Uint64()
71+
gasUsed := new(protocol.GasUsed)
72+
noopWriter := state.NewNoopWriter()
73+
hashFn := protocol.GetHashFn(header, getHeader)
74+
vmCfg := vm.Config{}
75+
76+
// If starting mid-block, we need to replay 0..fromIdx-1 first to get
77+
// cumulative gas and state to the right point. We discard those receipts.
78+
for i := 0; i < fromIdx; i++ {
79+
select {
80+
case <-ctx.Done():
81+
return nil, ctx.Err()
82+
default:
83+
}
84+
ibs.SetTxContext(blockNum, i)
85+
evm := protocol.CreateEVM(cfg, hashFn, engine, accounts.NilAddress, ibs, header, vmCfg)
86+
_, err := protocol.ApplyTransactionWithEVM(cfg, engine, gp, ibs, noopWriter, header, txns[i], gasUsed, vmCfg, evm)
87+
if err != nil {
88+
return nil, fmt.Errorf("receipts.DeriveForRange: replay tx %d (warmup): %w", i, err)
89+
}
90+
}
91+
92+
// Now execute the target range and collect receipts.
93+
receipts := make(types.Receipts, 0, toIdx-fromIdx)
94+
for i := fromIdx; i < toIdx; i++ {
95+
select {
96+
case <-ctx.Done():
97+
return nil, ctx.Err()
98+
default:
99+
}
100+
ibs.SetTxContext(blockNum, i)
101+
evm := protocol.CreateEVM(cfg, hashFn, engine, accounts.NilAddress, ibs, header, vmCfg)
102+
receipt, err := protocol.ApplyTransactionWithEVM(cfg, engine, gp, ibs, noopWriter, header, txns[i], gasUsed, vmCfg, evm)
103+
if err != nil {
104+
return nil, fmt.Errorf("receipts.DeriveForRange: replay tx %d: %w", i, err)
105+
}
106+
receipts = append(receipts, receipt)
107+
}
108+
109+
return receipts, nil
110+
}
111+
112+
// DeriveBlockReceipts replays all transactions in a block and returns their receipts.
113+
// Convenience wrapper around DeriveForRange(ctx, cfg, engine, header, txns, 0, len(txns), ...).
114+
func DeriveBlockReceipts(
115+
ctx context.Context,
116+
cfg *chain.Config,
117+
engine rules.EngineReader,
118+
header *types.Header,
119+
txns types.Transactions,
120+
ibs *state.IntraBlockState,
121+
gp *protocol.GasPool,
122+
getHeader GetHeaderFunc,
123+
) (types.Receipts, error) {
124+
return DeriveForRange(ctx, cfg, engine, header, txns, 0, len(txns), ibs, gp, getHeader)
125+
}
126+
127+
// DerivePriorReceipts replays transactions 0..startTxIndex-1 and returns their
128+
// receipts. Used when execution resumes mid-block from a snapshot boundary and
129+
// Finalize needs the full receipt set for requests hash computation.
130+
func DerivePriorReceipts(
131+
ctx context.Context,
132+
cfg *chain.Config,
133+
engine rules.EngineReader,
134+
header *types.Header,
135+
txns types.Transactions,
136+
startTxIndex int,
137+
ibs *state.IntraBlockState,
138+
gp *protocol.GasPool,
139+
getHeader GetHeaderFunc,
140+
) (types.Receipts, error) {
141+
return DeriveForRange(ctx, cfg, engine, header, txns, 0, startTxIndex, ibs, gp, getHeader)
142+
}

execution/stagedsync/exec3_parallel.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/erigontech/erigon/execution/exec"
3030
"github.com/erigontech/erigon/execution/protocol"
3131
"github.com/erigontech/erigon/execution/protocol/rules"
32+
"github.com/erigontech/erigon/execution/receipts"
3233
"github.com/erigontech/erigon/execution/state"
3334
"github.com/erigontech/erigon/execution/tests/chaos_monkey"
3435
"github.com/erigontech/erigon/execution/tracing"
@@ -610,14 +611,23 @@ func (pe *parallelExecutor) execLoop(ctx context.Context) (err error) {
610611
reader = state.NewReaderV3(pe.rs.Domains().AsGetter(applyTx))
611612
}
612613
ibs := state.New(state.NewBufferedReader(pe.rs, reader))
614+
defer ibs.Release(true)
613615
ibs.SetVersion(finalVersion.Incarnation)
614616
localVersionMap := state.NewVersionMap(nil)
615617
ibs.SetVersionMap(localVersionMap)
616618
ibs.SetTxContext(finalVersion.BlockNum, finalVersion.TxIndex)
617619

618-
txTask, ok := result.Task.(*taskVersion).Task.(*exec.TxTask)
620+
var txTask *exec.TxTask
621+
switch t := result.Task.(type) {
622+
case *taskVersion:
623+
if tt, ok := t.Task.(*exec.TxTask); ok {
624+
txTask = tt
625+
}
626+
case *exec.TxTask:
627+
txTask = t
628+
}
619629

620-
if !ok {
630+
if txTask == nil {
621631
return state.StateUpdates{}, nil
622632
}
623633

@@ -631,15 +641,39 @@ func (pe *parallelExecutor) execLoop(ctx context.Context) (err error) {
631641
}
632642

633643
chainReader := consensuschain.NewReader(pe.cfg.chainConfig, applyTx, pe.cfg.blockReader, pe.logger)
644+
645+
// For partial blocks, reconstruct prior receipts. See #20452.
646+
finalizeReceipts := blockReceipts
647+
if blockResult.isPartial && len(txTask.Txs) > 0 {
648+
firstTxIndex := blockExecutor.tasks[0].Version().TxIndex
649+
if firstTxIndex > 0 {
650+
blockStartTxNum := txTask.TxNum - uint64(txTask.TxIndex)
651+
priorReader := state.NewHistoryReaderV3(applyTx, blockStartTxNum)
652+
priorIbs := state.New(priorReader)
653+
defer priorIbs.Release(true)
654+
priorGp := protocol.NewGasPool(txTask.Header.GasLimit, pe.cfg.chainConfig.GetMaxBlobGasPerBlock(txTask.Header.Time))
655+
getHeader := func(hash common.Hash, number uint64) (*types.Header, error) {
656+
return pe.cfg.blockReader.Header(ctx, applyTx, hash, number)
657+
}
658+
priorReceipts, priorErr := receipts.DerivePriorReceipts(ctx, pe.cfg.chainConfig, pe.cfg.engine, txTask.Header, txTask.Txs, firstTxIndex, priorIbs, priorGp, getHeader)
659+
if priorErr != nil {
660+
pe.logger.Warn("[parallel] failed to reconstruct prior receipts for partial block",
661+
"block", blockResult.BlockNum, "startTxIndex", firstTxIndex, "err", priorErr)
662+
} else {
663+
finalizeReceipts = append(priorReceipts, blockReceipts...)
664+
}
665+
}
666+
}
667+
634668
if pe.isBlockProduction {
635669
_, _, err =
636670
pe.cfg.engine.FinalizeAndAssemble(
637-
pe.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Txs, txTask.Uncles, blockReceipts,
671+
pe.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Txs, txTask.Uncles, finalizeReceipts,
638672
txTask.Withdrawals, chainReader, syscall, nil, pe.logger)
639673
} else {
640674
_, err =
641675
pe.cfg.engine.Finalize(
642-
pe.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Uncles, blockReceipts,
676+
pe.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Uncles, finalizeReceipts,
643677
txTask.Withdrawals, chainReader, syscall, false, pe.logger)
644678
}
645679

execution/stagedsync/exec3_serial.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/erigontech/erigon/execution/exec"
2121
"github.com/erigontech/erigon/execution/protocol"
2222
"github.com/erigontech/erigon/execution/protocol/rules"
23+
"github.com/erigontech/erigon/execution/receipts"
2324
"github.com/erigontech/erigon/execution/state"
2425
"github.com/erigontech/erigon/execution/tests/chaos_monkey"
2526
"github.com/erigontech/erigon/execution/types"
@@ -378,14 +379,40 @@ func (se *serialExecutor) executeBlock(ctx context.Context, tasks []exec.Task, i
378379

379380
chainReader := consensuschain.NewReader(se.cfg.chainConfig, se.applyTx, se.cfg.blockReader, se.logger)
380381

382+
// For partial blocks (resuming from snapshot boundary), reconstruct
383+
// prior receipts so Finalize receives the full receipt set for requests
384+
// hash computation (deposit extraction from logs). See #20452.
385+
finalizeReceipts := blockReceipts
386+
if startTxIndex > 0 && len(txTask.Txs) > 0 {
387+
// Use the first executed user tx task (not the block-end task) to
388+
// derive blockStartTxNum, since the block-end task's TxIndex is
389+
// len(txs) which would compute the wrong range.
390+
firstTask := tasks[0].(*exec.TxTask)
391+
blockStartTxNum := firstTask.TxNum - uint64(firstTask.TxIndex)
392+
reader := state.NewHistoryReaderV3(se.applyTx, blockStartTxNum)
393+
priorIbs := state.New(reader)
394+
defer priorIbs.Release(true)
395+
priorGp := protocol.NewGasPool(txTask.Header.GasLimit, se.cfg.chainConfig.GetMaxBlobGasPerBlock(txTask.Header.Time))
396+
getHeader := func(hash common.Hash, number uint64) (*types.Header, error) {
397+
return se.cfg.blockReader.Header(ctx, se.applyTx, hash, number)
398+
}
399+
priorReceipts, priorErr := receipts.DerivePriorReceipts(ctx, se.cfg.chainConfig, se.cfg.engine, txTask.Header, txTask.Txs, startTxIndex, priorIbs, priorGp, getHeader)
400+
if priorErr != nil {
401+
se.logger.Warn(fmt.Sprintf("[%s] failed to reconstruct prior receipts for partial block", se.logPrefix),
402+
"block", txTask.BlockNumber(), "startTxIndex", startTxIndex, "err", priorErr)
403+
} else {
404+
finalizeReceipts = append(priorReceipts, blockReceipts...)
405+
}
406+
}
407+
381408
if se.isBlockProduction {
382409
_, _, err = se.cfg.engine.FinalizeAndAssemble(
383410
se.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Txs, txTask.Uncles,
384-
blockReceipts, txTask.Withdrawals, chainReader, syscall, nil, se.logger)
411+
finalizeReceipts, txTask.Withdrawals, chainReader, syscall, nil, se.logger)
385412
} else {
386413
_, err = se.cfg.engine.Finalize(
387414
se.cfg.chainConfig, types.CopyHeader(txTask.Header), ibs, txTask.Uncles,
388-
blockReceipts, txTask.Withdrawals, chainReader, syscall, false, se.logger)
415+
finalizeReceipts, txTask.Withdrawals, chainReader, syscall, false, se.logger)
389416
}
390417

391418
if err != nil {

0 commit comments

Comments
 (0)