Skip to content

cmd, db/integrity: drop Polygon wiring from the standalone tools - #23492

Merged
AskAlexSharov merged 4 commits into
mainfrom
awskii/polygon-removal-cmd-tools
Aug 22, 2026
Merged

AskAlexSharov merged 4 commits into
mainfrom
awskii/polygon-removal-cmd-tools

Conversation

@awskii

@awskii awskii commented Aug 22, 2026

Copy link
Copy Markdown
Member

The standalone binaries were still building Bor readers, a read-only Bor engine and Bor snapshot stores that nothing consumes now that the bor RPC namespace and the node-side services are gone. This removes that, which leaves cmd/ and node/ free of any polygon/ import. Fourth in the Polygon removal series, branched off #23491 — review the last commit only.

Changes

  • cmd/rpcdaemon/cli: drop the BridgeReader and HeimdallReader interfaces, the Bor snapshot and bridge/Heimdall store setup, the bor.NewRo arms in both the local and remote engine paths, and the two Bor version-compatibility checks; RemoteServices returns 9 values instead of 11
  • cmd/utils/app: drop the Bor snapshot open/close in snapshots and seg, the Bor stores in openSnaps, the FrozenBorBlocks retire clamp and CheckBorChain
  • cmd/integration: drop the Bor snapshot and store singletons, the --bor.heimdall flag, the Heimdall/polygon-bridge DB migration paths and their migratable labels, and the bor line in print_stages
  • db/integrity: remove the BorEvents, BorSpans and BorCheckpoints checks
  • drop the remaining _ "polygon/heimdall" blank imports from cmd/downloader, cmd/integration and cmd/rpcdaemon
  • erigon init now rejects a genesis carrying a bor config instead of decoding it

Notes

erigon init used to rehydrate Genesis.Config.Bor from the bor JSON key. Simply dropping that would have let a Polygon genesis initialise with Bor == nil and run under the wrong consensus rules, so it fails with a pointer to 0xPolygon/erigon instead. The two remaining rehydration sites, in db/rawdb and txnprovider/txpool, get the same treatment when chain.Config.Bor itself is removed.

cmd/rpcdaemon/rpcservices still implements BorSnapshots() and FrozenBorBlocks() because they are still on the FullBlockReader interface. That interface, the Bor snapshot machinery in db/snapshotsync and the 15 kv.Bor* tables are the next PR.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AskAlexSharov

Copy link
Copy Markdown
Collaborator

Reviewed abe5c9268 (last commit only, as the PR body asks).

Crash: nil bor reader meets live chainConfig.Bor != nil branches

BlockRetire is now built with a nil bor BlockReader and nil heimdall/bridge stores, but chainConfig still comes from fromdb.ChainConfig(chainDB), and db/rawdb/accessors_metadata.go:52 still rehydrates config.Bor from the stored bor JSON key. Every br.chainConfig.Bor != nil branch in db/snapshotsync/freezeblocks/block_snapshots.go (lines 363, 470, 533, 553, 563, 571) then calls br.borSnapshots(), which is an unchecked type assertion on a nil interface:

// block_snapshots.go:198
return br.blockReader.BorSnapshots().(*heimdall.RoSnapshots)  // panic: interface conversion: interface {} is nil
  • cmd/utils/app/snapshots_cmd.go:3122 — erigon seg integrity|index|retire --datadir=<bor datadir> panics at doIntegrity's defer blockRetire.MadvNormal().DisableReadAhead() (MadvNormal() is evaluated at the defer statement), before a single check runs. PruneAncientBlocks also reaches bordb.PruneHeimdall(nil, nil, ...).
  • cmd/integration/commands/stages.go:1129 — same shape: stages.go:1008 has the identical defer br.(*freezeblocks.BlockRetire).MadvNormal().... blocksIO (line 1209) propagates the nil reader to every other integration stage command.

Better to fix at the source — strip .Bor after reading the config, or reject the datadir once — than to leave live Bor != nil branches wired to nil.

Silent wrong data: rpcdaemon falls through to ethash.NewFaker()

Removing the case cc != nil && cc.Bor != nil: engine = bor.NewRo(...) arm left no guard, in both switches:

  • cmd/rpcdaemon/cli/config.go:553 (datadir path)
  • cmd/rpcdaemon/cli/config.go:1011 (remoteRulesEngine.init, remote path)

With a bor cc the switch now falls to default, and the faker engine's Author() returns header.Coinbase — 0x0 for Bor, where the real author is recovered from the extraData seal. eth_getBlockByNumber(...).miner, eth_getBlockByHash and trace rewards return wrong-but-plausible data with nothing logged. This is inconsistent with the PR's own choice in cmd/utils/app/init_cmd.go:82, which fails fast on a bor config; the same fail-fast belongs here.

Dead code introduced

  • cmd/rpcdaemon/cli/config.go:578 — readChainConfigFromDB's result is now discarded; the call existed only to feed the two deleted Bor version checks. remoteCE.init re-reads the identical config from the same remote KV two lines later, and in --datadir mode it was already read at line 404 into cc. Two to three redundant remote round-trips per startup, for a rootCancel that remoteCE.init would raise anyway.
  • cmd/integration/commands/stages.go:1149 — the emptied errgroup goroutine g.Go(func() error { return nil }) is spawned and joined for nothing. Delete it rather than leaving an empty fourth parallel open.

Stale docs

  • cmd/integration/commands/flags.go:227 — withStageBase's docstring still lists heimdall after withHeimdall(cmd) was deleted from its body. stages.go:286 still justifies not reusing withStageBase for cmdStageSnapshots on a Heimdall difference that is gone (only withUnwind differs now).
  • cmd/integration/Readme.md:155 — still documents the removed Polygon workflow ("How to re-gen bor checkpoints", rm -rf datadir/heimdall, datadir/snapshots/*borch*); lines 63 and 68 still tell users to clear polygon-bridge/bor/heimdall.

Notes

  • cmd/utils/app/init_cmd.go:81 — genesis.Config.BorJSON has no nil guard, so a genesis JSON with no config object panics with a raw nil-pointer error instead of the file's own utils.Fatalf("invalid genesis file: ..."). The line is pre-existing, but this commit rewrites its body and it is now the only statement guarding the new rejection.
  • cmd/utils/app/init_cmd.go:82 — utils.Fatalf (os.Exit(1)) inside a function that returns error skips defer file.Close() and makes the new rejection impossible to unit-test. node, cmd: stop wiring Polygon services and drop the bor CLI flags #23491 pinned its equivalent change with execution/chain/spec/polygon_unsupported_test.go; returning fmt.Errorf(...) would allow the same here.
  • cmd/rpcdaemon/cli/config.go:343 — RemoteServices keeps a 9-value positional return with ~15 repeated return nil, nil, nil, nil, nil, nil, nil, ff, err lines, and the arity is already inconsistent (four error paths pass nil in the ff slot, the rest pass ff). A single result struct returned as (res, err) would make this a one-line change and remove the miscount class for the rest of the series.

Merged via the queue into main with commit 9d22875 Aug 22, 2026
136 checks passed
@AskAlexSharov
AskAlexSharov deleted the awskii/polygon-removal-cmd-tools branch August 22, 2026 05:45
@awskii

awskii commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

All five sections verified against abe5c9268 and fixed in d616963. Details per point.

Crash: nil bor reader meets live Bor != nil branches

Confirmed, including the mechanism. Repro against abe5c9268:

br := &BlockRetire{blockReader: NewBlockReader(nil, nil)}
_ = br.borSnapshots()
// interface conversion: dbservices.BlockSnapshots is nil, not *heimdall.RoSnapshots

BlockReader.BorSnapshots() returns a nil interface when borSn == nil (block_reader.go:389), and MadvNormal/DisableReadAhead reach it whenever chainConfig.Bor != nil. One correction: there are five defer blockRetire.MadvNormal().DisableReadAhead() sites, not three — snapshots_cmd.go:1636,1826,1896,1924 and stages.go:1008.

Fixed at the source, as you suggested: ReadChainConfig now rejects a stored bor section instead of rehydrating config.Bor. That change was queued for #23494; it belongs here, in the commit that introduced the nil reader. fromdb.ChainConfig surfaces it as failed to read chain config: ... Polygon is not supported, so no Bor != nil branch is reachable from a real datadir any more.

rpcdaemon falls through to ethash.NewFaker()

Confirmed on both switches (config.go:553 and remoteRulesEngine.init), and Ethash.Author() does return header.Coinbase (ethash/rules.go:114), so miner would have been wrong-but-plausible.

The same root-cause fix covers both: each reads its config through readChainConfigFromDB → rawdb.ReadChainConfig and checks the error (config.go:404 returns, init returns), so a bor datadir now fails with the rejection instead of silently getting a faker.

Dead code

Both removed. On the first: remoteCE.init re-reads the same config two lines later, so dropping the call removes the redundant round-trips rather than losing a check.

Stale docs

All fixed: the withStageBase docstring, the cmdStageSnapshots comment (only withUnwind differs now), and Readme.md — the re-gen section plus the folder mentions on 63 and 68.

Notes

Both taken. genesis.Config is *chain.Config, so a genesis with no config object did deref nil; there is now an explicit guard. utils.Fatalf is replaced by returned errors, which keeps defer file.Close() and makes the rejection testable — pinned by TestChainConfigRejectsStoredBorSection and TestChainConfigAcceptsNullBorSection in db/rawdb, mutation-checked by reverting the guard.

The fix is merged forward into #23494, #23495 and #23497 so the stack stays consistent.

Sahil-4555 pushed a commit to Sahil-4555/erigon that referenced this pull request Aug 22, 2026
…methods (erigontech#23494)

The Bor snapshot type, its retire/merge path and the two Bor-named
methods on the generic block-reader interfaces existed only to carry
Heimdall data. With nothing constructing them any more, this removes
them, which takes `db/` off the list of packages importing `polygon/`.
Fifth in the Polygon removal series, branched off erigontech#23492 — review the
last commit only.

## Changes
- delete `db/snapshotsync/freezeblocks/bor_snapshots.go`
- `BlockRetire` loses its Heimdall and bridge stores, `BorStore()`,
`borSnapshots()`, the Bor prune metric and the Bor-data-not-ready
backoff; `NewBlockRetire` drops two parameters
- `BlockReader` loses its `borSn` field, `BorSnapshots()` and
`FrozenBorBlocks()`; `NewBlockReader` takes one snapshot set instead of
two
- remove `FrozenBorBlocks` and `BorSnapshots` from
`dbservices.FullBlockReader`, `FrozenBorBlocks` from the engine-facing
`rules.ChainHeaderReader`, and `BorSnapshots` from `snapshotsync`'s
reader interface, along with all seven implementations
- `remotedbserver.NewKvServer` drops its Bor snapshot parameter
- `db/rawdb`: `ReadChainConfig` now rejects a stored `bor` section
instead of decoding it
- `execution/stagedsync`: drop the Bor branches from the snapshots
stage, including the synchronous-indexing exception
- `db/datadir`: keep removing the legacy `heimdall` and `polygon-bridge`
directories, by literal name now that the DB labels are on their way out

## Notes
`ReadChainConfig` used to rehydrate `Config.Bor` from the stored `bor`
JSON. Dropping that silently would let an existing Polygon chaindata
load with `Bor == nil` and execute under the wrong consensus rules, so
it now fails with a pointer to `0xPolygon/erigon`. This is the second of
the three rehydration sites; `txnprovider/txpool` is the last and goes
with `chain.Config.Bor`.

The `heimdall` and `polygon-bridge` datadir cleanup is deliberately
kept: anyone upgrading from a Polygon datadir still wants those
directories removed.

The 15 `kv.Bor*` tables, the `HeimdallDB` / `PolygonBridgeDB` labels and
`snaptype.MinBorEnum` are **not** touched here. `polygon/heimdall` and
`polygon/bridge` are their only readers and `polygon/heimdall` pins the
snapshot enum range, so they can only go in the same PR that deletes the
tree. Removing them earlier would just mean editing code that is about
to disappear.

The `WitnessProcessing` stage stays for the same reason: it reads
`kv.BorWitnesses`.

---------

Co-authored-by: Alexey Sharov <askalexsharov@gmail.com>
lupin012 added a commit that referenced this pull request Aug 22, 2026
Resolves the fallout of the Polygon removal series (#23492..#23497) on this
branch: the Bor-specific gating added in earlier review rounds goes away with
the code it gated. borReceiptForBlock, borStateSyncLogs and
txnLookupWithBorFallback are gone with the bridge, GetTransactionReceipt is
back on the plain txnLookup, the Bor clamp in Capabilities is dropped with
chain.Config.Bor, and the Bor gating tests go with their production paths.
github-merge-queue Bot pushed a commit that referenced this pull request Aug 27, 2026
)

Polygon has not been officially supported since 3.1, but the 3.6 docs
still offer it as a choice. This scopes it without removing the
reference material, because the bor code does still ship in this branch
— the `bor-mainnet` and `amoy` tags are selectable, the `bor` namespace
is servable, and all seven `--bor.*` / `--polygon.*` flags are
registered in `erigon --help`.

**Presented as a choice, now not:**

- Polygon sat in the Mainnets table beside Ethereum and Gnosis, with its
caveat a footnote 25 lines below. It moves to its own "Polygon (not
supported)" section, which absorbs the Amoy tag.
- Three cards advertised a one-command Polygon easy-node setup. That
guide was removed from this branch and
`/get-started/easy-nodes/how-to-run-a-polygon-node` is now a redirect,
so the cards promised something that no longer exists.
- The FAQ answered "supports networks such as Gnosis and Polygon". That
string is in `faqSchema`, so it ships as JSON-LD.

**Kept, and scoped instead:** the `bor_` method reference, the Polygon
gRPC services, and the seven flags — all accurate for 3.6. The flags
move under a "Legacy Polygon flags" heading so the CLI reference stays
complete against `erigon --help`, and the bor namespace entry and the
gRPC section point at Supported Networks.

**Two fixes along the way:** the help center cited `--bor.heimdall.url`,
which is not a flag (the real one is `--bor.heimdall`); that example now
uses `--externalcl`. The Layer 2 page description advertised "Polygon
PoS, Bor" on a page whose body is entirely about running an op-node.

`main` already dropped all of this along with the code (#23492, #23497),
so nothing here forward-ports.

`llms.txt` / `llms-full.txt` regenerated; `--check` and `npm run build`
pass.

---------

Co-authored-by: Bloxster <gianni.morselli@erigon.tech>
github-merge-queue Bot pushed a commit that referenced this pull request Aug 30, 2026
…23322)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants