Skip to content

eth, eth/downloader: implement EIP-7975 (eth/70 partial receipt lists) - #2341

Draft
pratikspatil024 wants to merge 6 commits into
ppatil-upstream-v1.17.3from
ppatil-upstream-eth70
Draft

eth, eth/downloader: implement EIP-7975 (eth/70 partial receipt lists)#2341
pratikspatil024 wants to merge 6 commits into
ppatil-upstream-v1.17.3from
ppatil-upstream-eth70

Conversation

@pratikspatil024

Copy link
Copy Markdown
Member

Summary

Adopts upstream #33153 (965bd6b6a), EIP-7975 / eth/70 partial block receipt
lists, which the previous PR in the stack deferred. This is a hand-written port
onto Bor's diverged receipt code, not a merge
— which is exactly why it gets its
own PR rather than being folded into a merge batch.

eth/70 lets a peer answer a receipt request with part of a block when the
response would otherwise exceed the packet limit. The requester buffers the
partial lists in the peer and resumes from the next receipt index using the same
request ID; nothing reaches the downloader until the block is whole again.

Taken now, immediately after batch 20, so the tree still matches the state
upstream's commit expected — every further batch would have made this a
forward-port instead of an application.

Files 26 (incl. 1 new test file)
Diff +1203 / −160
Protocol versions [ETH70, ETH69, ETH68] (Bor keeps eth/68, having declined #33511; upstream is [ETH70, ETH69])

The deferral analysis was wrong, in our favour

The batch-20 note recorded a five-step adoption route and flagged a
consensus-relevant design decision as the blocker. Reading the code rather than
trusting the note, two steps evaporated and the blocker turned out not to exist:

  1. No ReceiptList70 is needed. EIP-7975 changes the packet envelope, not the
    receipt-list encoding, so eth/70 carries ReceiptList69. Upstream likewise
    reuses its single ReceiptList for both ReceiptsPacket69 and
    ReceiptsPacket70.
  2. "What does state-sync exclusion mean for a partial list?" does not arise.
    Bor never applies ExcludeStateSyncReceipt() in the p2p handler.
    handleReceipts passes a deliberately nil metadata func because it has no
    block number; the Madhugiri-gated exclusion runs later in the downloader queue
    (eth/downloader/bor_fetchers_concurrent_receipts.go
    EncodeReceiptsAndPrepareHasher). Since partial lists are never sunk to the
    downloader, the exclusion only ever sees a reassembled complete list —
    unchanged from today.

Consensus-adjacent decisions — please review these first

Area Decision
State-sync receipt across a chunk boundary The exclusion is positional (last element) and the serving side identifies the state-sync receipt by absolute index. So the encoder keeps its loop counter absolute when receipts are skipped, and advances the tx-type iterator in lockstep. A truncated response therefore cannot displace it. Covered by TestPartialReceipts_StateSyncAcrossChunkBoundary, which splits every fixture at every size limit that splits it and asserts the reassembled receipt root on both sides of the Madhugiri gate.
Amsterdam gate on the response bound Upstream keys the reduced minimum transaction gas (4500 vs 21000) off AmsterdamTime. Bor has no Amsterdam timestamp, so this is rewired to block-based IsAmsterdam(number), and RequestReceipts carries block numbers where upstream carries timestamps. AmsterdamBlock is nil, so the 21000 bound is what applies today; both sides of the gate are pinned by TestValidateLastBlockReceiptAmsterdamGate. No fork gate flipped.
Receipt-count bound vs Bor's free state-sync tx Upstream bounds a truncated response by receipts <= gasUsed/minTxGas. A Bor block carries one receipt more than its gas can account for, because the state-sync transaction burns none, so the bound gained a +1 tolerance. Without it, a peer serving a legitimate Bor block could be rejected.

Deliberate scope beyond a straight port

Two existing eth/69 functions were refactored rather than cloned. Both came out
of running the Bor-only twin scan against my own diff, and both widen the eth/69
serving-path diff — flagging them because they are the parts most likely to want
splitting out.

  • blockReceiptsToNetwork69 gained a receiptQueryParams argument instead of
    being cloned into a …70. The first cut had the clone; it differed from its
    sibling only by the bounds, which is precisely the silent-drift pattern the twin
    scan exists to catch. Upstream has one parameterised function, so this converges
    rather than diverges. The zero value reproduces the previous whole-block
    behaviour. One deliberate behaviour change on the eth/69 path, aligned with
    upstream: a receipt with no corresponding body transaction is now an error
    rather than being encoded as type 0 — reachable only from locally corrupt data,
    and the caller skips the block.
  • gatherBlockReceipts extracted from ServiceGetReceiptsQuery69. The
    alternative was duplicating ~70 lines of pre-Madhugiri state-sync merge logic
    into the eth/70 service function, i.e. a second new twin.

Divergences from upstream, kept on purpose

  • Bor's GetReceiptsPacket is not renamed to …69 as upstream did, because
    in Bor it serves both eth/68 and eth/69.
  • The eth/70 size limit reuses Bor's existing maxMessageSize (10 MiB) rather
    than adding upstream's duplicate maxPacketSize constant.
  • The eth70 handler map mirrors Bor's eth69 map, which keeps
    NewBlockHashesMsg/NewBlockMsg; upstream's does not.
  • Two small defensive additions over upstream: the buffer entry is dropped when
    dispatchRequest fails (upstream leaks it — its own TODO acknowledges the
    concern), and bufferReceipts rejects a response claiming more blocks than were
    requested before indexing into gasUsed.

Documentation

This PR also carries the v1.17.3 milestone documentation for batch 20 as well
as its own, since docs are excluded from the pure merge commits:
docs/upstream-merges/v1.17.4/{plan,ledger,fork-register,needs-wiring}.md.
needs-wiring.md's EIP-7975 row moves deferredadopted and records what the
original route got wrong; fork-register.md moves deferadopt.

Executed tests

New coverage in eth/protocols/eth/receipt70_test.go:

  • Chunked reassembly — for every fixture, every size limit that splits the
    block, asserting the reassembled list is byte-identical to the whole-block
    response in both network and storage encodings. Fails the test if no limit ever
    splits, so it cannot silently go vacuous.
  • State-sync across a chunk boundary — receipt root verified on both sides of
    the Madhugiri gate.
  • First-receipt-too-large, firstIndex resumption, Append, LogsSize, both
    response bounds, and the Amsterdam gate.

Plus an ETH70 case in testGetBlockReceipts covering the full handler round
trip and a resumed (FirstBlockReceiptIndex: 1) request.

Other gates, beyond CI's standard set:

  • go build ./..., go vet ./... — clean apart from the two pre-existing
    //nolint copylocks; gofmt -l clean; go mod tidy a no-op.
  • go test green on eth/protocols/{eth,snap,wit}, eth/downloader,
    eth/downloader/whitelist, eth, eth/fetcher.
  • Both fork meta-guards green: TestReinforceMultiClientPreCompilesTest,
    TestV2ForkParity.

Known coverage gap, stated rather than hidden: the truncation path only fires
on blocks whose receipts exceed 10 MiB. No devnet produces those, so the split
behaviour is exercised by unit tests over synthetic size limits, not end to end.
It matters most on the largest blocks — plausibly more on Polygon than on L1 given
our gas limits and transaction counts. Upstream's cmd/devp2p/internal/ethtest
eth/70 suite and its regenerated testdata were not ported: the testdata is
specific to the geth test chain, and Bor's ethtest is already a known-failing
surface.

Rollout notes

  • Not consensus-affecting. eth/70 is a wire protocol version negotiated per
    peer, not a state transition. The receipt root derivation path is untouched: the
    Madhugiri-gated exclusion still runs on a complete list, in the downloader
    queue, exactly as before.
  • Backwards-compatible. Bor advertises eth/68, eth/69 and eth/70; peers on
    older versions negotiate down and take the unchanged code paths.
  • No coordinated upgrade required, and no operator-facing change.
  • The one fork-gated line introduced (reduced minimum tx gas) is dormant —
    AmsterdamBlock is nil on every Bor preset.

Stacked PR — do not squash

Part of the v1.17.4 upstream sync stack. Squash-merging any PR in this stack
breaks every PR above it
, because squashing rewrites commits into new SHAs, so
the PRs above would re-show all of this PR's changes and conflict against their
base. Team standard for upstream syncs is a merge commit, never squash.

upstream-merge-v1.17.4                (base)
 └ ppatil-upstream-v1.16.9      #2308
    └ ppatil-upstream-v1.17.0   #2319
       └ ppatil-upstream-v1.17.1 #2325
          └ ppatil-upstream-v1.17.2 #2328
             └ ppatil-corevm-catchup #2337
                └ ppatil-upstream-v1.17.3  (v1.17.3 part 1, batch 20)
                   └ ppatil-upstream-eth70 <-- THIS PR
                      └ v1.17.3 part 2     (batches 21-26 + milestone chores)

Reviews are deliberately not being requested yet — the sync is mid-flight and
several decisions are still open. This will be marked ready once all six
milestones are complete.

Ports upstream #33153 (965bd6b) onto Bor's diverged receipt code. Batch 20 of
the v1.17.4 sync reverted the feature's 31-file footprint wholesale, so this is a
hand-written adoption rather than a merge, taken before batch 21 while the tree
still matches the state upstream's commit expected.

eth/70 lets a peer answer a receipt request with part of a block when the
response would otherwise exceed the packet limit. The requester buffers the
partial lists and resumes from the next index using the same request ID; nothing
reaches the downloader until the block is whole again.

The receipt-list encoding is unchanged from eth/69, so eth/70 reuses
ReceiptList69 and only the packet envelope grows a truncation flag. Bor keeps
eth/68, so ProtocolVersions becomes [ETH70, ETH69, ETH68] against upstream's
[ETH70, ETH69].

Three adaptations to Bor:

- The state-sync receipt is identified positionally, by absolute index, so the
  encoder keeps its loop counter absolute across a truncated response and
  advances the tx-type iterator in lockstep. A split therefore cannot displace
  it, which matters because the Madhugiri-gated receipt-root exclusion runs on
  the reassembled list in the downloader queue.
- Upstream gates the reduced minimum transaction gas on AmsterdamTime. Bor has
  no Amsterdam timestamp, so this is rewired to the block-based IsAmsterdam and
  RequestReceipts carries block numbers where upstream carries timestamps.
  AmsterdamBlock is nil, so the pre-fork bound applies today.
- A Bor block carries one receipt more than its gas can account for, because the
  state-sync transaction burns none, so the receipt-count bound applied to a
  truncated response gains a corresponding tolerance.

Rather than clone blockReceiptsToNetwork69 for the bounded case, it gains a
parameter whose zero value reproduces the previous whole-block behavior, which
matches upstream's single parameterised function. The pre-Madhugiri state-sync
merge is likewise extracted into gatherBlockReceipts and shared by both service
functions instead of being duplicated. One deliberate behavior change follows
from the first: a receipt with no corresponding body transaction is now an error
instead of being encoded as type 0, reachable only from locally corrupt data,
and the caller skips the block.

The truncation path only fires on blocks whose receipts exceed the packet limit,
which no devnet produces, so it is covered by unit tests over synthetic size
limits rather than end to end. Upstream's cmd/devp2p ethtest suite for eth/70 is
not ported; its regenerated testdata is specific to the geth test chain.
Brings the core/state goimports fix so lint passes on this branch too.
Handshake switches on the negotiated version to pick a status-packet format, and
eth/70 was never added to it, so every eth/70 peer fell through to the default
branch and was rejected with "unsupported protocol version". Since eth/70 is
first in ProtocolVersions it is what two Bor nodes negotiate with each other, so
in practice nothing peered at all: tests/bor panicked with "Node is not connected
to any peers".

EIP-7975 leaves the status message untouched, so eth/70 shares the eth/69
handshake. Upstream needs no branch here at all — it only has one because Bor
still carries eth/68's older status packet, which is also why adding a version
to ProtocolVersions does not fail to compile when this site is missed.

The handshake tests enumerated 68 and 69 explicitly, which is why the unit suite
stayed green; TestHandshake70 now covers the third.
params.TxGas is already a uint64, so the conversion added with the eth/70 partial
receipt bounds was flagged by unconvert.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 37.03704% with 221 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (ppatil-upstream-v1.17.3@d866cfd). Learn more about missing BASE report.

Files with missing lines Patch % Lines
eth/protocols/eth/peer.go 22.66% 114 Missing and 2 partials ⚠️
eth/protocols/eth/handlers.go 35.38% 68 Missing and 16 partials ⚠️
eth/protocols/eth/dispatcher.go 0.00% 12 Missing ⚠️
eth/protocols/eth/receipt.go 82.50% 5 Missing and 2 partials ⚠️
eth/protocols/eth/handler.go 71.42% 2 Missing ⚠️

❌ Your patch check has failed because the patch coverage (37.03%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@                    Coverage Diff                     @@
##             ppatil-upstream-v1.17.3    #2341   +/-   ##
==========================================================
  Coverage                           ?   54.31%           
==========================================================
  Files                              ?      923           
  Lines                              ?   166617           
  Branches                           ?        0           
==========================================================
  Hits                               ?    90490           
  Misses                             ?    70414           
  Partials                           ?     5713           
Files with missing lines Coverage Δ
eth/downloader/bor_fetchers_concurrent_receipts.go 83.33% <100.00%> (ø)
eth/downloader/peer.go 92.56% <ø> (ø)
eth/protocols/eth/handshake.go 64.40% <100.00%> (ø)
eth/protocols/eth/protocol.go 21.68% <ø> (ø)
eth/protocols/eth/handler.go 34.72% <71.42%> (ø)
eth/protocols/eth/receipt.go 79.38% <82.50%> (ø)
eth/protocols/eth/dispatcher.go 5.22% <0.00%> (ø)
eth/protocols/eth/handlers.go 47.94% <35.38%> (ø)
eth/protocols/eth/peer.go 26.95% <22.66%> (ø)
Files with missing lines Coverage Δ
eth/downloader/bor_fetchers_concurrent_receipts.go 83.33% <100.00%> (ø)
eth/downloader/peer.go 92.56% <ø> (ø)
eth/protocols/eth/handshake.go 64.40% <100.00%> (ø)
eth/protocols/eth/protocol.go 21.68% <ø> (ø)
eth/protocols/eth/handler.go 34.72% <71.42%> (ø)
eth/protocols/eth/receipt.go 79.38% <82.50%> (ø)
eth/protocols/eth/dispatcher.go 5.22% <0.00%> (ø)
eth/protocols/eth/handlers.go 47.94% <35.38%> (ø)
eth/protocols/eth/peer.go 26.95% <22.66%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Develop-drift cascade: carries develop's #2347 (Kurtosis e2e and
stateless-e2e on every PR base) and #2333 (complete witnesses under
BlockSTM v2) one hop further up the stack.

Clean at this hop; inherits the witness resolutions made in the merge into
ppatil-upstream-v1.17.2.
Ancestry only. The newTrieReader point-cache fix this carries was
already present here, so the merge records the relationship without
changing a byte.

That is the reason it exists. Without it this branch would not contain
its predecessor, and a stacked pull request whose head does not contain
its base misreports its own diff and turns an eventual merge into an
argument.

Deliberately not re-verified, because there is nothing new to verify:
the merge result's tree is identical to this branch's previous tree,
which is the tree that already passed build, full-tree vet, #2333's
prewalk and read-set tests, and CI. A merge with no tree delta cannot
break what that tree established.
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.

1 participant