Skip to content

rpc/jsonrpc: gate receipts and logs on the data they actually read - #23322

Merged
lupin012 merged 33 commits into
mainfrom
lupin012/fix_check_receipts_available
Aug 30, 2026
Merged

rpc/jsonrpc: gate receipts and logs on the data they actually read#23322
lupin012 merged 33 commits into
mainfrom
lupin012/fix_check_receipts_available

Conversation

@lupin012

@lupin012 lupin012 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Defects in the same family — how receipt- and log-serving endpoints decide availability.

Note: while this PR was under review, main removed Polygon support entirely (#23492..#23497). The Bor-specific fixes made in earlier review rounds are therefore no longer part of this PR — the history gate inside borReceiptForBlock and borStateSyncLogs, the txnLookupWithBorFallback resolution in eth_getTransactionReceipt, the Bor clamp on the receipts capability, and the four Bor gating tests were retired by the merge (34f460e) together with the code they gated. Non-Bor behaviour is untouched: those helpers were wrappers, and their callers now use the generic functions they wrapped (getReceipts, txnLookup).

1. The receipts gate consulted the wrong setting. It read kvcfg.PersistReceipts, a boolean saying the receipt cache exists on disk, and concluded receipts were available for every block. Retention is a separate setting: RCacheDomain is retired on its own --prune.receipts.distance window when one is set, and alongside state history otherwise, which is the default. It now consults prune.Mode.ReceiptsAmount(), distinguishing the same three shapes historyRetireCutoffs already distinguishes. Where the cache stops covering a block, availability falls back to state history, because a missing cache entry is not fatal: ReadReceiptCacheV2 reports it as absent and GetReceipt re-executes the block from ReceiptDomain plus state history, retired at the history cutoff rather than the RCacheDomain one. So a --prune.receipts.distance narrower than the history window costs the caller nothing.

2. Four endpoints were gated on receipts alone, though matching logs can need more. eth_getLogs and erigon_getLogs search LogAddrIdx and LogTopicIdx when the query filters by address or topic; those are standalone inverted indices retired at the history cutoff whatever the receipt retention is, so gated on receipts they answered with an empty log array instead of PrunedError. They now take the history gate on top, but only when the filter actually reaches the indices: usesLogIndex mirrors applyFiltersV3, which skips topic positions that are empty because those match any topic — so "topics": [null] searches nothing and stays on the receipts path, exactly like the unfiltered form. erigon_getLatestLogs re-executes through a TraceWorker and overlay_getLogs re-executes with overridden code, so both take the history gate unconditionally.

3. Ten endpoints serving the receipts of one block were gated on history alone, which both rejects blocks whose body and receipts are present and never checks the body at all. New checkBlockReceiptsAvailable composes the two boundaries: reading a stored receipt needs the block body anyway, since the receipt carries no TxHash and it is derived from the transaction. The two GraphQL block-detail entry points had no gate; theirs is placed before the body read, because after it a pruned body reads back as nil and the endpoint answers "not found" before any gate could fire. The GraphQL and Otterscan block-detail paths also select one overlay view per request and thread it through resolution, gate, block and receipt reads, so a background commit cannot gate one generation while another answers.

4. The log path needs the block body for the same reason. getLogsV3 reads the transaction through TxnByIdxInBlock for every txNum it does not find in the in-memory cache, because GetReceipt derives the receipt from it. With bodies pruned and receipts kept — minimal with --prune.receipts.distance=keep-all — the lookup came back empty and the loop skipped the block silently, answering with an empty array. New checkLogsAvailable composes the three boundaries and replaces the duplicated gate block in the two getLogs, so the rationale is stated once.

5. A block with no transactions could not have its receipts read at all. GetReceipts decided whether the cache had answered by testing len(receiptsFromDB) > 0, which cannot tell "the cache holds nothing for this block" from "this block has no transactions", so an empty block fell through to PrepareEnv and its state-history read. Where history is pruned, eth_getBlockReceipts, debug_getRawReceipts, ots_getBlockDetails and ots_getBlockTransactions answered with a leaked ReceiptsGen: PrepareEnv: ... old data not available instead of an empty list. The fix belongs in the generator rather than the gate: the gate decides availability from retention, and a block it lets through must not leak an execution error. Availability itself is a separate question, and the gate makes no exception for these blocks — a transaction-free block below the cutoff is refused like any other, and the caller gets PrunedError rather than an empty list that cannot be told apart from "no receipts". So this fix covers the blocks the gate serves: receipts retained, archive, or above the cutoff. The two boundaries are the whole gate, so oldestBlock in eth_capabilities is the exact boundary the endpoints honour rather than a floor with holes below it.

6. The blocks gate read the chain-history-expiry sentinel as "nothing is pruned". KeepPostMergeBlocksPruneMode is a policy, not a window: on a chain declaring a merge point, pre-merge transaction segments are never downloaded, so checkPruneBlocks now refuses below MergeHeight, and eth_capabilities resolves its blocks boundary through the same function, so the two cannot diverge. A legacy archive datadir persists the same blocks sentinel, so the datadir decides rather than the stored retention — and neither a pre-merge body nor the oldest available block can: expiry keeps pre-merge headers and bodies, and the transaction segment spanning the merge point reaches below it. The gate reads a pre-merge transaction, sampled by halving the range so the candidates stay clear of the transaction segment spanning the merge point; where the chain carries no pre-merge transaction at all, the cumulative txnum position of the last pre-merge body says so and nothing is left to be missing. The verdict is availability rather than policy, so it is cached for a short TTL in both directions instead of being settled once, and availability widening while segments arrive reopens the gate within that window. The remote rpcdaemon's block reader answers FrozenBlocks through a new ethbackend RPC instead of panicking, so the receipt gates, eth_capabilities and the receipt generator keep consulting the real value everywhere.

7. erigon_getLogsByHash consulted its receipt cache before any gate. A receipt set cached while its block was inside the retention window stayed servable after the boundary moved past it — holes below the advertised oldestBlock, the defect class this PR removes. The gate now runs before the cache, so a hit is gated like a miss, at the cost of one read-only tx on cache hits.

This covers the audit asked for in point 2 of #22260 for erigon_receipts.go and otterscan_block_details.go: migrated to the correct boundary rather than documented as genuinely needing history.

Coverage

prune_gating_test.go pins 34 endpoints across the nine prune mode shapes on an old and a recent block: 612 cells. check_prune_gates_test.go covers the gates directly — the boundary block itself, the boundary named in each error, the archive short circuit, each receipt retention shape including a window wider and a window narrower than history, each leg of both composed gates, and usesLogIndex over the criteria shapes. checkTxFee gains the unit test it never had, so every check* in the package now has one. It also pins the datadir shapes behind the blocks sentinel — aligned segments, bodies without transactions, a chain with no pre-merge user transaction, a sampled block without transactions, blocks arriving later, and a stored history retention that does not change the verdict. The remote block reader's frozen-block refresh is covered directly: a failed fetch is retried instead of being served as fresh, and a slow one delays only the goroutine that fetches.

Verified on a live node

Sepolia at tip with --prune.mode=minimal --prune.include-receipts — receipts on disk, block bodies pruned. Head 11500055, both boundaries at 11400054, old block probed 5750027. 35 checks, all passing.

Family Endpoints Old block Why
headers erigon_getHeaderByNumber, debug_getRawHeader served headers are never pruned
block data eth_getBlockByNumber, eth_getBlockTransactionCountByNumber, eth_getUncleCountByBlockNumber, debug_getRawBlock refused bodies are pruned in minimal
receipts of one block eth_getBlockReceipts, debug_getRawReceipts, erigon_getBlockReceiptsByBlockHash, erigon_getLogsByHash, ots_getBlockDetails, ots_getBlockTransactions refused, naming blocks are available the blocks leg this PR adds — receipts are present, the body is not
logs by filter eth_getLogs, erigon_getLogs refused, naming history is available log indices follow history, not receipts
state / trace eth_getBalance, trace_block, debug_traceBlockByNumber, ots_hasCode refused history is pruned

This table was measured before the log gate took the blocks leg described in point 4. On the current revision its two log rows refuse on the blocks boundary rather than the history one, because minimal prunes bodies and that leg is checked first; the rows above and below are unaffected. The blocks leg is covered end to end by the unit tests, not by a live probe.

Every recent-block probe is served. The two middle rows name different boundaries for the same block, which is what this configuration shows: before the change both said history is available.

Note that in minimal the receipt endpoints refuse either way, so only the named boundary changes. The behavioural flip — refused becoming served — happens where bodies are kept and receipts outlive history (blocks with --prune.receipts.distance=keep-all), which is the largest group of the 26 red cells above.

Second live node: blocks with receipts kept

Sepolia at tip with --prune.mode=blocks --prune.include-receipts --prune.receipts.distance=keep-all — every body and every receipt on disk, state history pruned below block 11238830. Old block probed: 5750484, some 5.5M blocks below the history boundary.

Family Endpoints Old block Why
headers erigon_getHeaderByNumber, debug_getRawHeader served never pruned
block data eth_getBlockByNumber, eth_getBlockTransactionCountByNumber, eth_getUncleCountByBlockNumber, eth_getTransactionByHash served bodies are kept
receipts of one block eth_getBlockReceipts, debug_getRawReceipts, eth_getTransactionReceipt, erigon_getBlockReceiptsByBlockHash, erigon_getLogsByHash, ots_getBlockDetails, ots_getBlockTransactions served body and receipts are both there — on main all seven refuse
logs, unfiltered eth_getLogs, erigon_getLogs served, 125 logs read straight from receipts; no index is consulted
logs, filtered by address eth_getLogs, erigon_getLogs refused, naming history is available the index search needs history — on main both return an empty array
state / trace eth_getBalance, trace_block, debug_traceBlockByNumber, ots_hasCode refused history is pruned
block data, follow-up debug_getRawBlock, debug_getRawTransaction refused still gated on history; see Follow-up

This is the configuration where the change is behavioural rather than a difference in wording. eth_getBlockReceipts returns 101 receipts for a block whose state history is long gone, ots_getBlockDetails computes its totalFees, and erigon_getLogsByHash returns all 101 log positions — none of which main will answer. In the other direction a filtered eth_getLogs now refuses instead of quietly returning [], while the unfiltered form still answers with its 125 logs.

36 of 38 checks pass; the two failures are debug_getRawBlock and debug_getRawTransaction, whose fix is in the follow-up PR.

Topic shapes on the same node

The empty-topic-position case, probed on block 5750932 of the same datadir with an rpcdaemon opened read-only:

filter before after
no topics 1117 logs 1117 logs
"topics": [] 1117 logs 1117 logs
"topics": [null] PrunedError 1117 logs
"topics": [[]] PrunedError 1117 logs
"topics": [null, null] PrunedError 1113 logs

The last row keeps 1113 rather than 1117 because that shape requires two topic positions to match, so it drops the four logs carrying a single topic; the filter is working, the gate is not involved. A query filtered by address still refuses naming history is available, and the single-block receipt endpoints stay served, so the blocks leg added in point 4 introduces no false refusal where bodies are kept.

Gate boundaries measured against the data

Each gate names a boundary; a second rpcdaemon built with checkPruneField short-circuited to nil says where the data actually starts. Both were opened read-only on the same Sepolia blocks datadir — head 11501094, declared boundary 11238950 — and the first block that answers correctly was found by bisection, with a block's own receipts as the ground truth for what its log endpoints must reproduce.

gate endpoints declared first block that answers verdict
checkPruneHistory eth_getBalance, eth_getCode 11238950 11163680 conservative by 75270
checkPruneHistory trace_block, debug_traceBlockByNumber 11238950 11163681 conservative by 75269
index leg of checkLogsAvailable eth_getLogs, erigon_getLogs filtered 11238950 11163681 conservative by 75269
checkBlockReceiptsAvailable eth_getBlockReceipts, debug_getRawReceipts, ots_getBlockDetails, ots_getBlockTransactions never refuses complete at every block with transactions from 800000 to the tip exact
receipts leg of checkLogsAvailable eth_getLogs, erigon_getLogs unfiltered never refuses correct at every block with logs exact

The 75k-block gap is not a defect: file retirement is floored to the file step, so data survives below the declared boundary until the next prune pass removes it. The gate promises the boundary the configuration guarantees rather than the physical one that moves at every retire, which is the pre-existing behaviour of checkPruneField.

The index leg is where the change earns its place. Filtering the same block by an address taken from its own logs:

block gate removed gate in place
5750932 0 of 1 expected log refused
11163680 0 of 1 expected log refused
11163679 0 of 1 expected log refused
11238949 35 logs, correct refused
11238950 116 logs, correct 116 logs

Below the physical floor the filtered query answers silently zero, which is what the history leg turns into PrunedError. The physical floor of the log index, 11163681, coincides with that of state history, 11163680 — the premise this PR rests on, measured rather than assumed. The unfiltered form is correct at every block probed: 1117, 1016, 1360, 525, 443, 1237 and 216 logs, each matching its receipts, identical with and without the gate.

Point 5 was found the same way: blocks 1000, 1000000, 1510087 and 2000000 go from the PrepareEnv error to an empty list, while blocks carrying transactions are unchanged — 800000 keeps 1 receipt, 1511431 keeps 4, 5750932 keeps 99, 11501058 keeps 94.

The blocks leg is the one this datadir cannot exercise, since it keeps every body; it stays covered by the unit tests.

Follow-up

The endpoints that need the block body but not the receipts are left to a second PR: debug_getRawBlock, debug_getRawTransaction and erigon_getBlockByTimestamp still gate on history, so under --prune.mode=blocks they refuse bodies that were never pruned — the defect class #21965 reported. That PR also gates eth_feeHistory, which has none today, and completes the table with the header, trace and Otterscan search endpoints.

@lupin012
lupin012 force-pushed the lupin012/fix_check_receipts_available branch from f80bc45 to 3f49b2b Compare August 16, 2026 09:13
Three defects in the same family.

The receipts gate read kvcfg.PersistReceipts, a boolean saying the receipt
cache exists on disk, and concluded receipts were available for every block.
Retention is a separate setting: RCacheDomain is retired on its own
--prune.receipts.distance window when one is set, and alongside state
history otherwise, which is the default. Consult prune.Mode.ReceiptsAmount()
instead, distinguishing the same three shapes historyRetireCutoffs already
distinguishes.

Endpoints that never read the receipt cache were gated on it. eth_getLogs,
erigon_getLogs and erigon_getLatestLogs match on LogAddrIdx and LogTopicIdx,
standalone inverted indices retired at the history cutoff whatever the
receipt retention; gated on receipts they answered with an empty log array
instead of PrunedError once those indices were gone. erigon_getLatestLogs
also re-executes through a TraceWorker, and overlay_getLogs re-executes with
overridden code, for which stored receipts are unusable by construction.
These four move to checkPruneHistory.

Endpoints serving the receipts of one block were gated on history alone,
which both rejects blocks whose body and receipts are present and misses the
body boundary entirely. Add checkBlockReceiptsAvailable, composing the two,
and move them onto it: reading a stored receipt needs the block body anyway,
since the receipt carries no TxHash and it is derived from the transaction.
The two GraphQL block-detail entry points had no gate at all and gain one,
placed before the body read: after it, a pruned body reads back as nil and
the endpoint answers "not found" before any gate could fire.

The table now pins 30 endpoints across six prune mode shapes on an old and a
recent block; 24 of those cells were red before this change. Direct tests
cover the boundary block itself, the boundary named in each error, the
archive short circuit, each receipt retention shape, and each leg of the
composed gate.
@lupin012
lupin012 force-pushed the lupin012/fix_check_receipts_available branch from 3f49b2b to 8f24508 Compare August 16, 2026 09:16
Comment thread rpc/jsonrpc/eth_receipts.go Outdated
…dices

An unfiltered eth_getLogs never reads them: applyFiltersV3 consults
LogTopicIdx and LogAddrIdx only when the criteria carry topics or addresses,
and otherwise falls through to a plain txNum range, so the query is served
from receipts alone. Gating it on history refused requests that the receipt
cache could answer.

Keep the receipts gate for every call and add the history one only when the
criteria force the index search. erigon_getLogs is the same shape;
erigon_getLatestLogs and overlay_getLogs stay on history unconditionally
because they re-execute rather than read stored receipts.

The table now carries both variants. They diverge under
--prune.receipts.distance=keep-all, where the unfiltered query is served and
the filtered one is refused.
…the index search

Three corrections to the gates this branch introduces, found by probing shapes
the endpoint table did not cover.

checkReceiptsAvailable refused a block outside the receipt-cache window even
when state history still reached it. A missing cache entry is not fatal:
ReadReceiptCacheV2 reports it as absent and GetReceipt re-executes the block,
which reads ReceiptDomain and state history, both retired at the history
cutoff rather than at the RCacheDomain one. The finite-window branch now falls
back to the history gate, which is what the function's own docstring already
described.

The log gate keyed its index leg on len(crit.Topics), but applyFiltersV3 skips
topic positions that are empty because those match any topic. A query written
as "topics": [null] therefore searches no index, yet was refused on a node
where the identical unfiltered form answered with its logs. usesLogIndex now
mirrors applyFiltersV3.

Serving a log means deriving its receipt from the block's transaction, exactly
the reason checkBlockReceiptsAvailable exists, so getLogsV3 needs the block
body as much as the single-block receipt endpoints do. Without the blocks leg,
bodies pruned alongside receipts kept made eth_getLogs skip the block silently
and answer with an empty array instead of PrunedError.

checkLogsAvailable composes the three boundaries and replaces the duplicated
gate block in eth_getLogs and erigon_getLogs, so the rationale is stated once.

Tests: usesLogIndex over the criteria shapes, both legs of checkLogsAvailable,
the receipt window in both directions relative to history, plus a
minimal_receipts_keep_all shape and an empty-topic-position endpoint in the
prune-mode table. checkTxFee gains the unit test it never had. pruneGateFires
no longer resolves a receipt window of its own, which the direct tests own;
a guard fails the table if a shape is added that it cannot predict. The
testing.Short() guards on the gate tests are dropped since each runs in under
half a second.
…sactions

GetReceipts decided whether the receipt cache had answered by testing
len(receiptsFromDB) > 0, which cannot tell "the cache holds nothing for this
block" from "this block has no transactions". A block with no transactions
therefore fell through to PrepareEnv, which reads state history, so on a node
whose history is pruned the four endpoints reading a block's receipts answered
with a leaked low-level error where the correct answer is an empty list:

  ReceiptsGen: PrepareEnv: bn=1000000, old data not available due to pruning

eth_getBlockReceipts, debug_getRawReceipts, ots_getBlockDetails and
ots_getBlockTransactions were affected; eth_getLogs already handled such a
block. The early return sits after CheckBlockExecuted, so a block that has not
been executed is still reported as such rather than answered with an empty
list, and before the pre-Byzantium branch, which has no post-state root to
recompute without transactions.

This is independent of the availability gates and predates them: the previous
gate also served these blocks whenever the receipt cache was enabled.

Measured on Sepolia with --prune.mode=blocks --prune.include-receipts
--prune.receipts.distance=keep-all, history pruned below 11238950: blocks 1000,
1000000, 1510087 and 2000000 go from the error above to an empty list, while
blocks carrying transactions are unchanged (800000 keeps 1 receipt, 1511431
keeps 4, 5750932 keeps 99, 11501058 keeps 94).

No unit test accompanies this: the rpc/jsonrpc harness stores a prune mode
without retiring any file, so a test there would pass without the fix. A
regression test needs a fixture with history actually retired, which
execmoduletester can build with WithPruneMode, WithFcuBackgroundPrune and
WaitForBlockRetirement.
@lupin012
lupin012 marked this pull request as ready for review August 17, 2026 13:06
@lupin012
lupin012 requested a review from yperbasis as a code owner August 17, 2026 13:06
@lupin012
lupin012 requested a review from AskAlexSharov August 17, 2026 13:06
@AskAlexSharov
AskAlexSharov added this pull request to the merge queue Aug 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 18, 2026
@yperbasis
yperbasis requested a balanced review from Copilot August 18, 2026 09:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates receipt and log RPC pruning gates to reflect block-body, receipt-cache, log-index, and state-history availability.

Changes:

  • Adds composed availability gates for receipts and logs.
  • Migrates affected RPC endpoints to the new boundaries.
  • Adds broad endpoint and boundary test coverage, including empty blocks.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
rpc/jsonrpc/receipts/receipts_generator.go Handles transaction-free blocks without re-execution.
rpc/jsonrpc/prune_gating_test.go Expands endpoint gating coverage.
rpc/jsonrpc/overlay_api.go Changes overlay log gating.
rpc/jsonrpc/otterscan_block_details.go Updates block-detail receipt gates.
rpc/jsonrpc/otterscan_api.go Updates block-transaction receipt gating.
rpc/jsonrpc/graphql_api.go Adds block-detail availability checks.
rpc/jsonrpc/eth_receipts.go Adds log-index and block-receipt gates.
rpc/jsonrpc/eth_api.go Implements receipt retention and composed gates.
rpc/jsonrpc/erigon_receipts.go Migrates Erigon receipt and log endpoints.
rpc/jsonrpc/debug_api.go Updates raw-receipt gating.
rpc/jsonrpc/check_prune_gates_test.go Tests gate boundaries and retention shapes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rpc/jsonrpc/erigon_receipts.go Outdated
Comment thread rpc/jsonrpc/overlay_api.go
Comment thread rpc/jsonrpc/eth_receipts.go Outdated
Comment thread rpc/jsonrpc/eth_api.go

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the following blockers.

Please address the four unresolved inline findings on this head:

  1. erigon_getLatestLogs checks history but not block transaction availability, so a shorter blocks window can produce incomplete or empty results.
  2. overlay_getLogs has the same missing blocks boundary and can turn a missing block into an empty result.
  3. eth_getTransactionReceipt checks block zero before resolving a Bor state-sync transaction to its real block.
  4. The receipt gate runs before the body is inspected, so the new empty-block fast path remains unreachable when receipt history is pruned.

I also found these issues:

  1. eth_capabilities is now inconsistent with the new gates. When PersistReceipts is enabled with the default follow-history retention, it still reports receipts and logs from blocksOldest. A focused cross-check reported oldestBlock 0 while checkBlockReceiptsAvailable rejected blocks below 10. Finite receipt windows are also ignored, and filtered logs require the history boundary.
  2. resolveLogsRange handles BlockHash by loading the full block before checkLogsAvailable runs. If transaction snapshots are pruned, eth_getLogs and overlay_getLogs return a block-read/not-found error instead of PrunedError. Resolve the block number from the retained header first.
  3. RCache coverage is not sufficient for receipt paths that bypass RCache. Pre-Byzantium receipts with post-state calculation skip the persistent cache, and Bor synthetic receipts reconstruct historical state. The migrated endpoints can therefore pass the receipt gate and fail below pruned history.
  4. checkPruneBlocks treats KeepPostMergeBlocksPruneMode as disabled. On merge chains, pre-merge transaction segments are intentionally absent, so these requests pass the gate. The capabilities implementation already derives this boundary from MergeHeight.

Validation on commit 866b2f9: go test ./rpc/jsonrpc/... -count=1 passed, and git diff --check was clean.

erigon_getLatestLogs and overlay_getLogs replay every transaction of the
range, so they read the block body on top of the state history the replay
starts from. Gated on history alone they answered with a silently incomplete
result wherever the blocks window is the shorter one, since prune.FromCli
accepts the two distances without ordering them. New checkBlockHistoryAvailable
composes both boundaries, mirroring checkBlockReceiptsAvailable.

eth_getTransactionReceipt gated on a block number the lookup had left at zero
for a Bor state sync txn, whose real block only the bridge can place. It now
resolves through txnLookupWithBorFallback first, like eth_getTransactionByHash
already does, so the gate measures the block the receipt belongs to instead of
genesis. The blockNum == 0 sentinel and the second lookup below the gate go
away with it.

checkBlockReceiptsAvailable refused a block with no transactions where the
receipt cache and state history are both pruned, though the body is retained
and its transaction count is the whole answer. It now serves such a block,
which is also what makes the zero-transaction path of GetReceipts reachable in
that configuration. Bor is excluded: its state sync receipt is reconstructed
from state history even where the body carries nothing.

The gating tests gain a block without transactions, a Bor chain with a bridge
that resolves one state sync hash, and direct coverage of both new gates.

Addresses the first four review findings.
…at bypass RCache

eth_capabilities never consulted ReceiptsAmount(), so with --prune.include-receipts
and no window of its own it advertised the blocks boundary for receipts that are
retired alongside state history. It now mirrors checkReceiptsAvailable: history when
the cache is absent or follows it, genesis on an explicit keep-all, and a window of
its own only where that window is wider than history, since below it the read falls
back to re-execution. deleteStrategy reports the retention that decides the boundary
rather than always the blocks one, and logs no longer equal receipts: they take the
filtered form, which adds the history boundary for the standalone log indices.

checkReceiptsAvailable now consults postStateCalculated, mirroring the generator's
predicate: a pre-Byzantium receipt carries a post state the cache does not store, so
it is always re-executed and reaches only as far back as history. The synthetic Bor
receipt is reconstructed from the state at the end of the block, so borReceiptForBlock
takes the history boundary once it knows the block carries state sync events, leaving
the blocks without events served.

resolveLogsRange resolves a BlockHash range through HeaderNumber instead of loading
the block. Only the number was needed, and the header outlives the body, so a pruned
body now reaches the gate instead of reading back as a missing block.

checkPruneBlocks no longer reads the chain-history-expiry sentinel as "not pruning":
pre-merge bodies are never downloaded on a chain that declares a merge point, so the
cutoff is that point, as eth_capabilities and blocksRetentionCutoff already derive it.
Datadirs carrying the same sentinel in History are excluded, since a legacy archive
one holds every body: prune.Get reads the stored mode, which EnsureNotChanged corrects
in memory without rewriting.

Addresses the four review findings without an inline thread.
@lupin012

Copy link
Copy Markdown
Contributor Author

@yperbasis

All eight are addressed:

The four inline findings are answered in their threads — the first two share a small checkBlockHistoryAvailable helper since both need the same pair of boundaries, the third follows your suggestion with txnLookupWithBorFallback and no new code, and the fourth is the only place I deviated from the shape proposed: the empty-block shortcut lives in the gate rather than in the ten callers.

  1. It never consulted ReceiptsAmount() — hence the oldestBlock 0 against a gate refusing below 10. It now mirrors checkReceiptsAvailable: the history boundary when the cache is absent or follows it, 0 on an explicit keep-all, and a finite window only where it is wider than history, since below it the read falls back to re-execution. deleteStrategy reports the retention that decides rather than always the blocks one, and logs take the filtered form, adding the history boundary for LogAddrIdx and LogTopicIdx.
    TestCapabilitiesAgreeWithGates pins, for every prune shape, that the oldest block each field advertises is served by the matching gate and the block below it refused; TestCapabilitiesAdvertiseTheReceiptWindow covers a finite receipt window.

  2. The BlockHash branch now resolves the number through _blockReader.HeaderNumber instead of loading the block, so a pruned body reaches the gate and gets PrunedError instead of "block not found". TestLogsByBlockHashNamesThePruneBoundary deletes the body with rawdb.DeleteBody first, since the harness stores the prune mode without pruning.

  3. checkReceiptsAvailable now consults postStateCalculated, mirroring the generator's own (commitmentHistory || FrozenBlocks() == 0) && !IsByzantium(block): there the cache is skipped, so only history can answer. For Bor the history gate sits in borReceiptForBlock after the events check, so it applies only to the blocks that actually carry a synthetic receipt. Pinned by TestReceiptsGateFollowsHistoryWherePostStateIsComputed and TestBorStateSyncReceiptFollowsHistory

  4. It now recognises the sentinel and takes MergeHeight as the cutoff, as capabilities and blocksRetentionCutoff already do. Datadirs carrying the same sentinel in History are excluded — legacy archive and prune.MockMode hold every body; pinned by TestBlocksGateServesLegacyArchive.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (4)

rpc/jsonrpc/eth_system.go:190

  • Equal current cutoffs also lose the log-index retention policy here. With a young node whose history window exceeds the head and a keep-all receipt cache, both values are 0, this condition is false, and Logs.DeleteStrategy is omitted even though filtered logs will later be retired with history. Tie-breaking must retain the finite history policy.
	if stateOldest > logsOldest {
		logsOldest, logsAmount = stateOldest, pruneMode.History

rpc/jsonrpc/eth_system.go:180

  • When the head is still below every configured window, both cutoffs are 0, so this >= can replace a finite receipts/history policy with an unlimited blocks policy. For example, with History=Distance(100), blocks kept forever, no receipt cache, and head 20, Receipts.DeleteStrategy becomes nil even though receipts will be pruned after block 100. Resolve equal cutoffs from the configured retention policies rather than treating blocks as stricter automatically.

This issue also appears on line 189 of the same file.

	if blocksOldest >= receiptsOldest {
		receiptsOldest, receiptsAmount = blocksOldest, pruneMode.Blocks

rpc/jsonrpc/eth_system.go:170

  • Keep-all cache retention does not make every receipt available from genesis. The new checkReceiptsAvailable forces receipts requiring a computed post-state (for example pre-Byzantium blocks when FrozenBlocks()==0 or commitment history is enabled) through the history gate. This branch still advertises oldestBlock=0, so routing via eth_capabilities can select this node for blocks that its receipt endpoints reject. Derive this field from the same cache-bypass conditions and fork boundary as the gate.
		switch amount := pruneMode.ReceiptsAmount(); {
		case amount == prune.KeepAllReceiptsPruneMode:
			receiptsOldest, receiptsAmount = 0, amount
		case pruneMode.ReceiptsFollowHistory():

rpc/jsonrpc/eth_receipts.go:168

  • A no-index log query can still require history on Bor. getLogsV3 handles the final transaction via borStateSyncLogs, whose GenerateBorLogs calls ComputeBlockContext; this is the same state-history dependency now gated in borReceiptForBlock. With receipts kept beyond history, this early return lets an old range containing state-sync events pass the gate and fail during generation. Add the history check in borStateSyncLogs after confirming events exist.
	if !usesLogIndex(crit) {
		return nil

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for five findings on d43a562650.

  1. blocksFollowChainHistoryExpiry cannot use History == KeepPostMergeBlocksPruneMode to identify a legacy archive. The supported --prune.mode=archive --prune.distance.blocks=keep-post-merge configuration produces the same persisted pair, while snapshot download treats it as chain-history expiry and omits pre-merge transaction segments. The RPC gate therefore passes requests for missing bodies and can return empty/not-found instead of PrunedError. The legacy mode needs to be normalized in storage or distinguished through another source of truth.

  2. Unfiltered Bor log queries still need state history. checkLogsAvailable adds the history leg only when usesLogIndex is true, but getLogsV3 handles Bor's final synthetic transaction through GenerateBorLogs, which reconstructs end-of-block state. With receipts kept beyond history, an unfiltered query passes the gate and then fails when it reaches a block carrying state-sync events.

  3. eth_capabilities does not include the pre-Byzantium history requirement enforced by postStateCalculated. With blocks and receipts kept, finite state history, and commitment history enabled, it advertises receipt availability from block zero while checkReceiptsAvailable rejects transaction-bearing pre-Byzantium blocks. A routing client can therefore select a node that refuses the advertised range.

  4. The capabilities calculation selects a retention policy by comparing only current PruneTo results. When multiple configured windows have not started pruning, their oldest values are all zero, and the >= branch can replace a finite history or receipt window with keep-all (or with a wider block window). deleteStrategy is then omitted or reports the wrong retention even though the category will be pruned later. Resolve equal cutoffs using the underlying policies, not their current oldest block alone.

  5. blockHasNoReceipts rejects every Bor block before checking whether state-sync events exist. A retained old block with no transactions and no state-sync events is answerable as an empty receipt list, but the gate returns PrunedError. This also blocks GraphQL and Otterscan paths that request only regular receipts. Inspect the events or separate the gate for callers that include synthetic Bor receipts.

Local validation: go test ./rpc/jsonrpc/... -count=1, the focused snapshot-retention tests, and git diff --check all passed.

…pabilities

Review follow-ups on the prune gates.

blocksFollowChainHistoryExpiry read chain-history expiry off the persisted
prune mode, which cannot resolve it: an archive datadir written before keep-all
became the Blocks default and --prune.mode=archive with
--prune.distance.blocks=keep-post-merge store the same sentinel pair, and the
downloader reads that pair as expiry. Resolve it from MinimumBlockAvailable
instead, once per process.

borStateSyncLogs reconstructs logs from the state at the end of the block, so an
unfiltered query reaching that txn needs history even though it searches no log
index. This is what borReceiptForBlock already did on the receipts path.

eth_capabilities did not mirror postStateCalculated: below Byzantium the receipt
carries a computed post state, so those blocks follow history rather than the
receipt cache.

The capabilities retention was picked by comparing current PruneTo results,
which are all zero before a window starts pruning, so a tie could drop a finite
window in favour of keep-all. Resolve equal cutoffs on the policies themselves.

blockHasNoReceipts excluded every Bor block; only a block carrying state sync
events has a receipt to reconstruct, so an old empty one is answerable.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 29, 2026
r.frozenBlocksValue = reply.FrozenBlocks
r.frozenBlocksFetchedAt = time.Now()
}
return r.frozenBlocksValue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FrozenBlocks() hands out 0 on a failed or timed-out fetch, and 0 is a load-bearing sentinel.

fetchFrozenBlocks returns r.frozenBlocksValue on the error path, which is 0 until the first success. The one consumer this RPC exists for reads that value as a boolean: receipts.PostStateCalculated returns commitmentHistoryEnabled || blockReader.FrozenBlocks() == 0.

So one failed round trip (or one 5s deadline) on a remote rpcdaemon flips every pre-Byzantium block:

  • Generator.GetReceipts sets calculatePostState = true, skips ReadReceiptsCacheV2 and re-executes with the per-tx post-state path;
  • checkReceiptsAvailable takes the computed branch and falls to checkPruneHistory, so eth_getBlockReceipts answers PrunedError for a block whose receipts are on disk;
  • eth_capabilities moves receipts.oldestBlock to the Byzantium height for the same reason.

The doc comment above states exactly this hazard ("Zero is not [conservative]") but the code still returns it. Either surface "never observed" to the caller distinctly, or return a value the predicate reads as "snapshots exist" until something has actually been observed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #23812: FrozenBlocksObserved reports the count together with whether the backend ever answered, and the sentinel readers use it — receipts.PostStateCalculated and the eth_simulateV1 commitment path, which are the two a remote reader can reach. An unobserved zero therefore no longer stands for "no snapshots". Caching a failed attempt (your other note) is only safe with that distinction in place, so the two land together.

// receipt paths read it as "no snapshots" and re-execute, so a failed fetch is not
// cached, and it reaches only the callers that have no observation to wait for.
func (r *RemoteBlockReader) FrozenBlocks() uint64 {
deadline := time.Now().Add(r.frozenBlocksTimeout)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A slow backend costs every caller a full 5s, with the request's read transaction open.

The refresh runs inline in the calling goroutine, and a failed fetch never sets frozenBlocksFetchedAt. Against a backend that is reachable but unresponsive there is therefore no observed fast path at all: line 168 can never fire, so each caller either runs its own 5s fetch or waits out one and then runs its own.

FrozenBlocks() is reached from checkReceiptsAvailable -> postStateCalculated -> receipts.PostStateCalculated, i.e. from an RPC handler that already holds a BeginTemporalRo transaction. One unresponsive backend then parks the whole handler pool for 5s a request and pins one MDBX read transaction per parked handler — the stall TestRemoteBlockReaderFrozenBlocksServesCacheWhileRefreshing protects against, but only once a value has been observed, which is exactly what never happens here.

Refreshing in a background goroutine (and serving the last value, or a distinct "unknown") would keep the handler off the wire entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #23812: the refresh runs on a goroutine of its own and a caller that already has a value returns without waiting for it. A failed attempt is stamped like a successful one, so the fast path exists against a backend that is down: one attempt per TTL instead of one per caller. Pinned by TestRemoteBlockReaderFrozenBlocksSuppressesRepeatFetchesAfterFailure and ...ServesCacheWhileRefreshing, which now asserts the stale caller answers without waiting.

Comment thread rpc/jsonrpc/eth_api.go
return false, nil, err
}
}
return true, chainConfig.MergeHeight, nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expiry boundary is MergeHeight regardless of what the datadir actually holds, so eth_capabilities can advertise blocks that are gone.

holdsPreMergeBlockData already reads MinimumBlockAvailable() (line 607) but the verdict is thrown away — only the boolean survives, and the boundary returned here is always chainConfig.MergeHeight.

EnsureNotChanged explicitly allows Distance <-> KeepPostMergeBlocksPruneMode in both directions, so a datadir that ran with --prune.distance.blocks=N and is later opened with the expiry sentinel has its oldest block above MergeHeight. In that shape:

  • checkPruneBlocks returns nil for every block >= MergeHeight, including the pruned range [MergeHeight, oldest);
  • Capabilities sets blocksOldest = *expiryFrom = MergeHeight, so blocks.oldestBlock / tx.oldestBlock advertise a floor with holes above it.

That is the same "holes below the advertised oldestBlock" class the PR sets out to remove. Since the probe has oldest in hand, max(*MergeHeight, oldest) would close it.

Comment thread rpc/jsonrpc/eth_api.go
}
// Zero is a snapshot set starting at genesis, one a database holding every block
// after it; anything higher starts mid-chain, however far below the merge point.
if oldest > 1 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oldest > 1 is read as proof of expiry without comparing it to mergeHeight.

A datadir whose oldest available block sits below the merge point (segments that start mid-chain, or a datadir previously distance-pruned to a point below MergeHeight on a chain with a low merge height like Chiado) is classified as chain-history expiry. checkPruneBlocks then refuses every block in [oldest, mergeHeight) with PrunedError although the bodies and transactions are on disk, and eth_getBlockByNumber / eth_getTransactionByHash / the new checkBlockReceiptsAvailable gates all inherit that refusal.

The comment says "anything higher starts mid-chain, however far below the merge point" — but starting mid-chain and not holding pre-merge data are different facts, and only the second one justifies the expiry verdict. Comparing oldest against mergeHeight (and only short-circuiting when oldest >= mergeHeight) would keep the cheap path without the false refusal.

Comment thread rpc/jsonrpc/eth_api.go
Comment thread rpc/jsonrpc/eth_api.go
defer func() { api.finishPreMergeProbe(probe, holds, err) }()

holds, decided, err = api.probePreMergeBlockData(ctx, tx, mergeHeight)
if err == nil && decided {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An undecided probe is not cached, so it re-runs on every gated RPC.

_preMergeData is only stored when decided. probePreMergeBlockData returns decided == false whenever the block data it needs is itself unreadable (hasEarlyTransaction with last == nil and no sampled body, or earlyTxnUnread from the search). On such a datadir every single call into checkPruneBlocks — which now sits on eth_getBlockByNumber, eth_getTransactionByHash, all the eth_getUncle*, and the new checkBlockReceiptsAvailable / checkLogsAvailable gates — reruns the whole probe: MinimumBlockAvailable + ~log2(mergeHeight) CanonicalBodyForStorage reads + up to earlyTxnSearchBudget (256) more in searchUserTxnBlock.

On a remote rpcdaemon each of those is a gRPC round trip, and joinPreMergeProbe serialises them, so concurrent requests queue behind one another instead of fanning out. A negative-cache entry with a short TTL for the undecided verdict would bound this; as written the "open question is not remembered" rule makes the worst datadir the most expensive one.

Comment thread rpc/jsonrpc/eth_receipts.go
Comment thread rpc/jsonrpc/receipts/receipts_generator.go
Comment thread rpc/jsonrpc/erigon_receipts.go
// availability gates and is served while a refresh runs. Zero is not: the pre-Byzantium
// receipt paths read it as "no snapshots" and re-execute, so a failed fetch is not
// cached, and it reaches only the callers that have no observation to wait for.
func (r *RemoteBlockReader) FrozenBlocks() uint64 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse: this is the second hand-rolled "TTL cache + single-flight" in the same PR.

RemoteBlockReader.FrozenBlocks (here, lines 162-229) and BaseAPI.holdsPreMergeBlockData / joinPreMergeProbe / runPreMergeProbe / finishPreMergeProbe (rpc/jsonrpc/eth_api.go 542-590) implement the same mechanism — cache a value for a TTL, dedupe concurrent refreshes, publish the result to waiters, do not cache a failure — with about 120 lines between them and different rules for what a waiter gets (FrozenBlocks gives waiters the stale value and a one-retry budget; the probe gives waiters the error and retries unbounded).

golang.org/x/sync/singleflight is already in the module graph and covers the dedupe half exactly; a small shared cachedValue[T] helper would cover the rest and leave one set of rules to reason about instead of two.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #23812: both sites now hold a common/concurrent.CachedValue[T] — one TTL, one dedup, one rule for what a failed pass leaves behind. Each keeps its own waiting policy, which is the part that legitimately differs.

singleflight was tried and dropped. DoChan cannot join the pass in flight without possibly starting one of its own, and it runs the producer on a goroutine the caller does not own; the pre-merge probe reads through the caller kv.Tx, so a caller released by ctx.Done() rolls that transaction back under a live reader. Produce runs the pass on the caller goroutine instead, and Go is the background form for FrozenBlocks, which borrows nothing. DoChan also turns a producer panic into go panic(e), which ends the process rather than the request.

Comment thread rpc/jsonrpc/eth_api.go
Comment thread db/snapshotsync/freezeblocks/block_reader_test.go
Comment thread db/snapshotsync/freezeblocks/block_reader.go
@lupin012

Copy link
Copy Markdown
Contributor Author

@AskAlexSharov
Thanks for the review. This PR has already been through eight review rounds. Rather than reopen it, I would like to land it as approved and take all thirteen comments into dedicated follow-up PRs. Before deciding that, I checked each one against origin/main: none of them is a regression this PR introduces.

I have saved all thirteen into the follow-up list

@lupin012
lupin012 added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 47f107e Aug 30, 2026
175 checks passed
@lupin012
lupin012 deleted the lupin012/fix_check_receipts_available branch August 30, 2026 20:11
lupin012 added a commit that referenced this pull request Aug 31, 2026
Reconcile the pin with the read-view work that landed in #22533 and #23322.

- db/kv/membatchwithdb, rpc/rpchelper: drop #22533's temporary
  IsOverlayReadView marker in favour of OverlayViewCarrier, so one marker
  system serves both contracts. WithOverlay and WithTemporalOverlay now key
  on CarriesOverlayView and keep #22533's NewTemporalReadView choice.
- rpc/jsonrpc/eth_system.go: main's hexutil.U256 signatures with this
  branch's pinned acquisition on the four gas-oracle endpoints; #23322's
  receipt and log gating is unchanged.
- rpc/jsonrpc/overlay_race_test.go: newOverlayAheadHarness becomes the
  single builder and main's newOverlayAheadTestAPI helpers wrap it;
  newPublishedOverlayTestBase also returns the overlay read tx, which the
  receipt-domain writes need. No test is dropped from either side.
- rpc/jsonrpc/trace_view_consistency_test.go: the committed-read assertion
  moves to the carrier.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Sep 2, 2026
…#23322 (erigontech#23690)

Follow-up to erigontech#23322, which merged as approved with thirteen inline
review notes deferred.
This closes the seven that are self-contained; the other six are grouped
at the end.

| Note | Site | Change |
| --- | --- | --- |
|
[r3885706832](erigontech#23322 (comment))
| `erigon_receipts.go` | `erigon_getLogs` no longer answers `[]` for an
unknown or non-canonical block hash |
|
[r3885706060](erigontech#23322 (comment))
| `eth_receipts.go` | `resolveLogsRange` returns the `PrunedError` it
computes |
|
[r3885705986](erigontech#23322 (comment))
| `eth_api.go`, `eth_system.go` | the receipts gate and
`eth_capabilities` follow history for a sentinel retention |
|
[r3885706103](erigontech#23322 (comment))
| `receipts_generator.go` | a transaction-free block is cached and
answered before the exec semaphore |
|
[r3885706918](erigontech#23322 (comment))
| `eth_api.go` | the Byzantium check is read once, from
`receipts.PostStateCalculated` |
|
[r3885706948](erigontech#23322 (comment))
| `block_reader_test.go` | the wall-clock assertion is replaced |
|
[r3885706992](erigontech#23322 (comment))
| `block_reader.go`, `eth_api.go` | three comments trimmed to the
invariant they carry |

Three of them are worth a line:

**By-hash logs.** `resolveLogsRange`'s hash branch is extracted into
`resolveLogsBlockHash`
and `erigon_getLogs` goes through it, so all three by-hash endpoints
answer alike. The same
function no longer discards the refusal it derives; error class and
message are unchanged.

**Sentinel retention.** `Distance.Enabled()` is false for three values
and the switch
special-cased two, so `KeepPostMergeBlocksPruneMode` produced no gate
while
`historyRetireCutoffs` retires the RCache with history for it. Both
switches now ask one
predicate, which also drops the ordering dependency. It is an unexported
helper rather than a
second exported method on `prune.Mode`, since the existing narrow
`ReceiptsFollowHistory()`
still drives RCache download policy — see below.

**Exec semaphore.** It is now taken where execution starts, immediately
before `PrepareEnv`,
so neither a transaction-free block nor the persisted-receipts fast path
queues behind real
re-executions. The per-block mutex still covers the whole function.

## Tests

Five new, one changed.
`TestErigonGetLogsByBlockHashRequiresACanonicalBlock`;
a `resolveLogsRange` leg on `TestLogsByBlockHashNamesThePruneBoundary`;
a
`cache_sentinel_retention` row on `TestReceiptsGateFollowsRetention`
plus
`TestCapabilitiesFollowHistoryForASentinelRetention`;
`TestGetReceiptsCachesAnEmptyBlock` and
`TestGetReceiptsAnswersAnEmptyBlockWithoutAnExecSlot` (semaphore full
and context already
cancelled, so any path reaching the semaphore reports the cancellation).
`make lint` and `make test-short` are clean.

## One deviation

The flaky-assert note suggested `< 2*timeout` or a channel signal; this
asserts
`client.calls.Load() <= 2` instead — each caller reaches the backend at
most once, and every
fetch it runs is bounded by that caller's own deadline. Deterministic,
but it pins the timing
property less tightly; `require.Less(t, time.Since(started), 2*timeout)`
can go back alongside
it if preferred.

## Not in this PR

Each is a behaviour change wanting its own test:

- `erigon_getLatestLogs` is the third by-hash log path and still
hand-rolls `HeaderByHash`.
- `Mode.ReceiptsFollowHistory()` (narrow) still drives RCache download
policy in
`db/snapshotsync`, so for the sentinel retention download and retirement
disagree.
- `Generator.GetReceipt` (singular) takes no exec slot at all, though it
executes.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Sep 3, 2026
`eth_feeHistory` resolved its head on the committed view while
`eth_blockNumber` publishes the overlay head, so during the forkchoice
flush+commit window the fee window lagged the published head by one
block or more. It showed up as intermittent
`eth_feeHistory/test_07.json` failures in the QA runs.

## Design

Each gas-oracle request reads one view, resolved once at acquisition.

- `Filters.BeginTemporalRoWithOverlay` opens the tx and captures the
published overlay as one pair: publishes carry a monotonic sequence
number, and the tx is reopened while the sequence moves around the open.
A capture that never stabilizes ends on an explicit no-overlay pin —
coherent by construction — rather than on a pair the helper could not
match.
- The pin travels inside the tx (`membatchwithdb.OverlayViewCarrier`,
applied by `rpchelper.PinToOverlay`), so downstream wrap points leave it
alone, both when an overlay is pinned and when the absence of one is.
`rpchelper.PinnedRoTx` serves both cases and forwards the tx's optional
capabilities (`BlockFilesRoTx`, `FreezeInfo`, `UnderlyingTx`, `Pin`).
- `NewGasPriceOracleBackend` requires an already-pinned tx, and `Fork`
reuses that pin, so head resolution, per-block sampling and the parallel
fetchers read one view. The parent-snapshot identity `Fork` validates
against is resolved by `PrepareFork` on the request goroutine right
before the fan-out, so a request served from the tip cache performs no
canonical scan.
- Migrated endpoints: `eth_feeHistory`, `eth_gasPrice`,
`eth_maxPriorityFeePerGas`, `eth_baseFee`, `eth_blobBaseFee`,
`eth_blockNumber`, `eth_fillTransaction`. The remaining call sites with
the same exposure are tracked by erigontech#23416.

## Cache strategy

The fee-history cache key is dual-regime. At or below the frozen
boundary — the lowest canonical marker still in the db, minus one, read
once per request through the pinned tx — entries are number-keyed: the
number-to-hash mapping is immutable there, so a hit requires no
per-block canonical-hash lookup. Above it entries are keyed by the block
hash, resolved by one `CanonicalHashes(from, to)` range scan per request
instead of a per-height lookup, which would cost a remote round trip
each in rpcdaemon mode. A same-height sibling after a reorg therefore
misses by construction, and a resolution error degrades the block to
uncacheable instead of failing the request. Blocks are fetched by the
already-resolved (hash, number) pair. `pending` is never memoized: it is
served from the live builder cache and rebuilt continuously.

That boundary is a proxy, and a conservative one: markers are pruned at
a threshold that trails the retired range, so it sits below the real
frozen bound — at zero before the chain has enough retired blocks — and
the number-keyed regime engages less often than it could. A fast-follow
reads the boundary from `blockReader.FrozenBlocks()` instead, which also
removes this method's `Range` per request.

## Data and bounds

Senders and receipts of the in-flight block are served by the overlay:
the senders stage and execution run on the overlay tx before
publication, and receipt domain reads go through the overlay since
erigontech#22511.

A pinned request retains the overlay it captured — including a
`SharedDomains` already superseded by a newer publish, order 10–50 MB —
for its own lifetime. The retention is bounded by the request, not by
the publish rate.

## Relationship to erigontech#22533

This PR is based on erigontech#22533 and erigontech#23322, and replaces erigontech#22533's temporary
`IsOverlayReadView` marker with `OverlayViewCarrier`, so one marker
system serves both contracts. erigontech#22533's committed-view helpers and call
sites are untouched, and `WithTemporalOverlay` keeps
`NewTemporalReadView`. GraphQL stays committed: its resolvers open
several transactions per request, so having the primitive does not by
itself make that path atomic.

## Tests

`overlay_race_test.go`, `gasprice_test.go`,
`carries_overlay_view_test.go`. They pin overlay head resolution on both
the header-only and the block+receipts path; one pin per request across
later publishes, unpublishes and forks; atomic acquisition, including
publish/unpublish cycles during the open and the no-overlay pin under
sustained churn; the pinned handle's capabilities and the constructor
invariant; and cache identity — dead overlay blocks, reorged siblings,
windows straddling the frozen boundary, one scan per request, and the
degrade-to-uncached path. Every fix test was verified red before its fix
and green after.

## QA verification

`QA - RPC Integration Tests Latest` was dispatched manually and
sequentially, and every run read attempt by attempt: the suite retries
internally, so a green run can hide the flake.

At 84ce71f, 10 runs: 20 job executions, one internal attempt each —
no internal retry occurred at all — 386/386 green,
`eth_feeHistory/test_07` and `test_22` passing in every one. The
baseline is `main` itself, where 7 of the last 16 daily runs failed
`test_07` in at least one attempt — a ~40% rate. Earlier-head runs and
two unrelated flakes are recorded in a comment below. The commits after
it are two `main` merges and one test-only change: a test tx that
dropped the block-files view capability the merged `main` now asserts
on.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Sep 4, 2026
…23759)

Follow-up to erigontech#23322. erigontech#23690 closed seven of its deferred review notes;
this closes the
two P3 ones it left, one per commit.

| Note | Site | Change |
| --- | --- | --- |
|
[r3880873445](erigontech#23322 (comment))
| `eth_api.go` | a log query no longer inherits the post-state
requirement of a full receipt |
|
[r3880873497](erigontech#23322 (comment))
| `prune_gating_test.go` | the fixtures persist the receipts their
retention promises |

## Logs

`checkLogsAvailable` reused the full-receipt gate, which refuses a
pre-Byzantium block
below the history cutoff: a full receipt carries a post state the cache
does not store
and only a re-execution can recompute. A log query never reads that
field — `getLogsV3`
asks `GetReceipt` for a receipt *without* a post state, which is exactly
why the
generator serves the cache. So with receipts kept beyond history, an
unfiltered
pre-Byzantium query was refused although every byte it reads was
retained.

`checkReceiptsAvailable` keeps the post-state leg; the rest moves
unchanged into
`checkReceiptSourceAvailable`. `checkLogsAvailable` composes the blocks
leg with that
one and keeps the history leg behind `usesLogIndex`, since an indexed
filter still
searches `LogAddrIdx`/`LogTopicIdx`. Filtered queries are unaffected,
and
`overlay_getLogs` gates on `checkBlockHistoryAvailable` and is
untouched.

`eth_capabilities` is unchanged on purpose: `caps.Logs` is already
`stricterRetention(receipts, history)`, so the post-state clamp was a
no-op for it.
`TestCapabilitiesAgreeWithTheLogsGatePreByzantium` pins that the
advertised boundary
does not move — the gate now reaches further back than advertised, never
short of it.

## Fixtures

They force-wrote `PersistReceipts` after `InsertChain` and never enabled
`RCacheDomain`,
so a disabled domain dropped everything execution wrote: the keep-all
cells passed by
re-executing against unpruned history. The domain is now enabled before
the chain runs,
and `requirePersistedReceipts` asserts the receipts really are on disk.

## Tests

Both commits are TDD; the fixture one first failed with `"[]" should
have 1 item(s), but
has 0`. New: `TestLogsGateSkipsThePostStateLegPreByzantium` (one block,
three answers),
`TestCapabilitiesAgreeWithTheLogsGatePreByzantium`,
`TestReceiptEndpointsCloseWhenTheCacheIsNotServed`. `go test -count=1
./rpc/jsonrpc/...`
green, `-race` green on the gate tests, `make lint` clean.

## Not in this PR

The fixture note also asked for an endpoint test with history
*physically* unavailable.
A negative control confirms the gap is real: the "endpoint answers" leg
passes even with
zero persisted receipts. Real pruning (`WithStepSize` + `WithPruneMode`)
is the right
route, but it reverses this fixture's premise of inserting without
physical pruning, so
it wants its own PR.
Sahil-4555 pushed a commit to Sahil-4555/erigon that referenced this pull request Sep 5, 2026
…e data they read (erigontech#23760)

Follow-up to erigontech#23322, same defect class as erigontech#21965: an endpoint that gates
on state history refuses data that was never pruned away.

- `debug_getRawBlock`, `debug_getRawTransaction` and
`erigon_getBlockByTimestamp` read the body and nothing else, so they now
gate on `Mode.Blocks` instead of `Mode.History`.
- `eth_feeHistory` had no gate. Headers carry the base-fee and gas-used
series, so only the reward-percentile path is gated, on the oldest block
of the resolved range, via a new
`OracleBackend.CheckBlockReceiptsAvailable`.
- The endpoints that replay a block read its transactions as well as the
state history before it, and gated on history alone: `trace_block`,
`trace_transaction`, `trace_filter`, `trace_replayTransaction`,
`trace_replayBlockTransactions`, `debug_traceBlockByNumber`,
`debug_traceBlockByHash`, `debug_traceTransaction`, the Otterscan
searches and the Otterscan tracer helper. They now gate on both
boundaries. The `*_call` endpoints execute against the state a block
leaves behind and stay on history.

In the named presets history is always the stricter boundary, which is
why the missing blocks leg stayed invisible. It shows up with
`--prune.mode=archive --prune.distance.blocks=N`.

Tests: the prune-gating table gains rows for the header, block-data,
fee-history, trace and Otterscan-search endpoints, plus an
`archive_blocks_window` mode row — without a mode where blocks is
stricter than history, the blocks leg cannot be observed.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Sep 6, 2026
…#23322 (erigontech#23812)

Follow-up to erigontech#23322. erigontech#23690 closed seven of its deferred review notes
and erigontech#23759 two more; this closes the three that are about
`RemoteBlockReader.FrozenBlocks` and the mechanism behind it.

| Note | Site | Change |
| --- | --- | --- |
|
[r3885705116](erigontech#23322 (comment))
| `block_reader.go` | a slow backend no longer costs every caller a full
timeout |
|
[r3885704185](erigontech#23322 (comment))
| `block_reader.go` | the zero handed out before the backend answers is
no longer read as "no snapshots" |
|
[r3885706881](erigontech#23322 (comment))
| `block_reader.go`, `eth_api.go` | one shared TTL-cache instead of two
hand-rolled ones |

## Change

- The refresh runs on a goroutine of its own; a caller that has a value
to serve returns without waiting for the next one. A failed attempt is
stamped like a successful one, so an unreachable backend costs one
attempt per TTL instead of one per caller and per request.
- `FrozenBlocksObserved() (uint64, bool)` reports the count together
with whether the backend ever answered. `receipts.PostStateCalculated`
and the `eth_simulateV1` commitment path both read it; those are the two
sentinel readers a remote reader can reach.
- `common/concurrent.CachedValue[T]` holds what the getter and
`holdsPreMergeBlockData` each kept by hand: one TTL, one dedup, one rule
for what a failed pass leaves behind. Each site keeps its own waiting
policy — `Produce` runs on the caller's goroutine, `Go` refreshes behind
it.

## For review

**`singleflight` was tried and dropped.** `DoChan` cannot join the pass
in flight without possibly starting one of its own, and it runs the
producer on a goroutine the caller does not own. That is unsafe for the
pre-merge probe, which reads through the caller's `kv.Tx`: a caller
released by `ctx.Done()` rolls that transaction back under a live
reader. `Produce` runs the pass on the caller's goroutine instead.
`DoChan` also turns a producer panic into `go panic(e)`, which ends the
process rather than the request.

**An unobserved count now reads as "snapshots exist".** For a
pre-Byzantium block on a remote rpcdaemon whose backend has not answered
yet, `PostStateCalculated` returns false, so the stored receipt is
served — possibly without its `root` field — where before the block was
re-executed and the gate could answer `PrunedError` for receipts that
are on disk. That is the direction the note asks for, and the window is
one TTL after a failed round trip.

**Panics.** `Produce` publishes the failed pass and re-panics, so the
RPC server logs it as before; `Go` contains and logs it with the stack,
since its goroutine has no caller to recover it.

## Tests

`common/concurrent/cached_value_test.go` (11) pins the contract,
including that a caller running the pass finishes it before returning
while one that only waits is released by its own context.
`TestRemoteBlockReaderFrozenBlocks*` cover the getter,
`rpc/jsonrpc/receipts/post_state_calculated_test.go` the sentinel. The
`eth_simulateV1` leg has no dedicated test — the branch sits inside the
commitment path.

`make lint` clean; `./common/concurrent/...`,
`./db/snapshotsync/freezeblocks/...`, `./rpc/jsonrpc/...`,
`./execution/blockreplay/...`, `./cmd/rpcdaemon/...` green, with `-race`
on the concurrency tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants