Skip to content

core, txpool, miner, eth/gasprice, internal/ethapi: reserved blockspace follow-ups - #2376

Open
kamuikatsurgi wants to merge 23 commits into
reserved-blockspace-block-buildingfrom
kamuikatsurgi/reserved-blockspace-followups
Open

core, txpool, miner, eth/gasprice, internal/ethapi: reserved blockspace follow-ups#2376
kamuikatsurgi wants to merge 23 commits into
reserved-blockspace-block-buildingfrom
kamuikatsurgi/reserved-blockspace-followups

Conversation

@kamuikatsurgi

@kamuikatsurgi kamuikatsurgi commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up hardening on top of the core Reserved Blockspace protocol change (#2302): value-only-balance txpool admission and fallback-fee handling (POS-3671), an aggregate reserved-sender occupancy cap to prevent normal-sender starvation (POS-3681), gas-price-oracle/eth_feeHistory exclusion of fee-free reserved transactions plus a new normalGasUsedRatio field (POS-3675/3676), chain/reserved/* and worker/reserved/* observability metrics (POS-3679), and execution-path produce/import determinism coverage at the fork boundary across the serial and both BlockSTM processors (POS-3672). Also carries the wire-format reconciliation needed when the Austin hard fork changed the header extra-data shape mid-implementation, and two correctness fixes found via live devnet testing (see below).

A combined delivery-and-test-status primer for both this branch and the base protocol branch, written for the still-open production go/no-go decision:
Reserved Blockspace - Delivery & Test Status

Executed tests

  • Full unit + -race suites across every touched package, and all 9 tests/bor reserved integration tests green.
  • Two full live devnet trials on a 9-node Kurtosis topology covering every node kind (producers, witness producer, stateless validators/RPC with and without BlockSTM parallel import, archive) - full write-up: Reserved Blockspace Devnet Trial.
    • Round 1 reproduced the spec's core behaviors live, then found a critical issue while exercising registry governance for the first time on a live chain: growing the registry could permanently wedge stateless nodes, and with stateless validators in the mix this cascaded into a full network production halt via lost Heimdall span-rotation quorum (registryreader.BuildSnapshot read the registry against a state copy whose witness never reached the real per-block witness - cf9f3e82c). Also found feeMode=1 clients were mining fee-free identically to feeMode=0, contradicting the contract's own documented semantics (124087f55).
    • Round 2 rebuilt from both fixes and re-ran the identical, riskier topology (stateless validators included) through the same trigger, a rapid-mutation stress pass, and two full node cold-restarts, with zero divergence - plus a deeper pass on the registry contract's own access-control and boundary semantics (client-admin delegation, exact quota-cap boundaries, invalid-input reverts, a 21-client/67.5M-gas registry with no measurable timing impact).
  • Not yet covered: a naturally-occurring reorg around a reserved-tx block, and a standing automated e2e/upgrade CI harness (both scripted-but-manual so far, not checked into CI).

Rollout notes

Consensus-affecting (gated behind Bor.ReservedBlockspaceBlock, same as the base branch). No operator-facing config changes beyond the existing ReservedMaxOccupancyPercent txpool knob (default 50%, CLI-wired). Both devnet-found issues above are fixed on this branch; production readiness still depends on the smart-contract team's audited registry contract and genesis-contracts parity, neither of which is part of this PR - see the primer's "still outstanding" section.

…ool occupancy

Reserved-blockspace senders (POS-3674) are eviction-immune in the txpool with
no aggregate ceiling: a client with enough whitelisted addresses could occupy
unbounded pending+queued slots, starving normal fee-paying senders network-wide
since the reserved set is registry-derived and therefore consensus-uniform.

Adds a combined pending+queued reservedOccupancy counter on LegacyPool, capped
by config.ReservedMaxOccupancyPercent (default 50% of GlobalSlots+GlobalQueue).
Two-layered by design: an incremental O(1) counter updated at every mutation
site, plus a periodic from-scratch recompute in reset() that self-heals any
drift every reorg cycle. Admission is gated with a new
ErrReservedOccupancyExceeded; a reorg-time backstop trims the largest reserved
account via the same prque idiom truncatePending already uses. The new config
knob is wired through internal/cli/server like its sibling pool-size settings.

POS-3681.
… suggestions and expose normal-region gas-used ratio
…or a clean compare

# Conflicts:
#	consensus/bor/bor.go
#	consensus/bor/bor_test.go
#	core/reserved_validation_test.go
#	core/types/block.go
#	core/types/block_test.go
#	miner/worker.go
…or a clean compare

# Conflicts:
#	core/blockchain.go
#	core/blockchain_test.go
#	eth/api_debug.go
#	miner/worker.go
The still-in-pool invariant was judged against both nodes' pools while
mined-ness came only from node0's canonical chain. The producing node
drops a tx from its pool the moment its own head includes it, which can
be several hundred ms before node0 imports that block, so the assertion
raced with block propagation (and with tip-fork reinjection windows).

A tx now counts as healthy on a node if it is pending in that node's
pool or canonical on that node's own chain, and a drop only fails the
test after persisting across consecutive polls. A genuine balance
eviction is permanent, so it still trips the threshold.
…he reserved set

The registry defines feeMode 1 (routed: fee paid, credited to the producer)
as reserved for a future external-block-producer world; the spec's zero-fee
handling applies to feeMode 0 only. The reader carried FeeMode as metadata
but never consulted it, so a routed client's senders inherited the free-mode
waiver end to end and mined at effectiveGasPrice 0, identically to feeMode 0.

Resolve fee mode at snapshot build, the single choke point every consumer
derives from: non-free clients stay out of the effective set, so their
senders pay standard fees, their quotas leave EffectiveCapacity, and no
downstream surface (EVM waiver, txpool, sequencing, header stamping) needs
its own gate. The Snapshot's now-meaningless FeeMode plumbing is removed.

registrytest gains a CreateClient helper and caller-provided state (the
latter also serves the witness-completeness tests in the next commit) to pin
the exclusion against the real registry bytecode.
…d witnesses

registryreader.BuildSnapshot read the registry against a throwaway
statedb.Copy(), and Copy deep-clones the attached witness: every trie node
the read touched landed in the discarded clone and never reached the witness
shipped to peers. A block's witness only carried the registry's storage when
the block's own transactions happened to touch it, so the first
transaction-free block after a registry shape change (an ordinary
createClient) permanently wedged every stateless node with "missing trie
node" for the registry account; with stateless validators holding voting
power, span rotation lost quorum and the whole chain halted.

Extract the copy-read-collect protocol the span and state-sync reads already
inlined twice in consensus/bor into state.StateDB.ReadIsolated, and route
all three call sites through it: run the reads on a reset copy, record them
into the live witness (StartPrefetcher + IntermediateRoot), and re-register
them on the live state so they enter its FlatDiff read surface
(PropagateReadsTo). The shared helper also stops the copy's prefetcher on
error paths (previously leaked at the bor.go sites), skips the witness
machinery when none is being produced, and detaches the witness while
copying instead of cloning it just to replace the clone.

A registrytest regression test pins the contract end to end: a witness
produced during a snapshot build must let a consumer rebuild the identical
snapshot from the witness's node set alone, exactly as a stateless verifier
does.
Five pipeline tests reassigned engine, exitCh, or speculativeWorkCh on a
live worker whose background goroutines read those fields concurrently,
failing go test -race deterministically. Stop the worker's goroutines first
(idempotently, so the fixture cleanup can still run) or build a bare worker
directly with the wrapped engine where no goroutines are needed. The
closed-exitCh swaps become the real thing: a stopped worker's exitCh is
genuinely closed.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.52542% with 277 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.52%. Comparing base (06e502f) to head (7f9f819).

Files with missing lines Patch % Lines
core/txpool/legacypool/legacypool.go 75.51% 46 Missing and 2 partials ⚠️
core/rawdb/reserved_txs_freezer.go 61.34% 32 Missing and 14 partials ⚠️
core/blockchain.go 76.15% 28 Missing and 3 partials ⚠️
core/state/statedb.go 0.00% 27 Missing ⚠️
internal/ethapi/api.go 54.16% 19 Missing and 3 partials ⚠️
internal/ethapi/bor_api.go 14.28% 14 Missing and 4 partials ⚠️
core/rawdb/reserved_txs.go 78.87% 11 Missing and 4 partials ⚠️
consensus/bor/registryreader/reader.go 70.00% 9 Missing ⚠️
miner/worker.go 78.04% 5 Missing and 4 partials ⚠️
core/rawdb/accessors_chain.go 55.55% 4 Missing and 4 partials ⚠️
... and 12 more

❌ Your patch check has failed because the patch coverage (76.52%) 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                           @@
##           reserved-blockspace-block-building    #2376      +/-   ##
======================================================================
+ Coverage                               55.30%   55.52%   +0.21%     
======================================================================
  Files                                     917      919       +2     
  Lines                                  167053   167873     +820     
======================================================================
+ Hits                                    92396    93210     +814     
+ Misses                                  69136    69102      -34     
- Partials                                 5521     5561      +40     
Files with missing lines Coverage Δ
consensus/misc/eip1559/eip1559.go 94.70% <100.00%> (-1.92%) ⬇️
core/blockchain_reader.go 60.28% <100.00%> (+0.50%) ⬆️
core/parallel_state_processor.go 75.35% <100.00%> (+12.67%) ⬆️
core/rawdb/ancient_scheme.go 0.00% <ø> (ø)
core/rawdb/database.go 15.66% <100.00%> (ø)
core/rawdb/freezer.go 53.46% <100.00%> (+3.32%) ⬆️
core/state_processor.go 67.29% <100.00%> (+3.91%) ⬆️
core/txpool/legacypool/queue.go 94.97% <100.00%> (ø)
core/txpool/validation.go 18.64% <100.00%> (+14.01%) ⬆️
core/types/receipt.go 57.66% <100.00%> (+1.59%) ⬆️
... and 30 more

... and 27 files with indirect coverage changes

Files with missing lines Coverage Δ
consensus/misc/eip1559/eip1559.go 94.70% <100.00%> (-1.92%) ⬇️
core/blockchain_reader.go 60.28% <100.00%> (+0.50%) ⬆️
core/parallel_state_processor.go 75.35% <100.00%> (+12.67%) ⬆️
core/rawdb/ancient_scheme.go 0.00% <ø> (ø)
core/rawdb/database.go 15.66% <100.00%> (ø)
core/rawdb/freezer.go 53.46% <100.00%> (+3.32%) ⬆️
core/state_processor.go 67.29% <100.00%> (+3.91%) ⬆️
core/txpool/legacypool/queue.go 94.97% <100.00%> (ø)
core/txpool/validation.go 18.64% <100.00%> (+14.01%) ⬆️
core/types/receipt.go 57.66% <100.00%> (+1.59%) ⬆️
... and 30 more

... and 27 files with indirect coverage changes

🚀 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.

Comment on lines +2553 to +2557
func (pool *LegacyPool) effectiveCost(addr common.Address, tx *types.Transaction) *big.Int {
if pool.isReserved(addr) {
return tx.Value()
}
return tx.Cost()

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.

q on this one: effectiveCost gives value only pricing just because the sender is a registered reserved client, without checking if this specific tx's gas actually fits their quota.
I understand that the comment above says this is intentional, but doesn't that mean a reserved sender can get fee-free pricing AND the eviction protection on a tx that was never going to execute fee-free anyway? Feels like it opens the door to spamming the pool pretty cheaply. Might be worth a quota check at admission time too, wdyt?

Comment on lines +1056 to +1069
pendingList := pool.pending[from]

// Reserved-blockspace occupancy cap: reject outright, before any
// Discard/eviction attempt, if this sender is reserved and admitting the
// transaction would occupy a genuinely new slot (as opposed to a same-
// nonce replacement, which leaves combined occupancy unchanged) that
// pushes aggregate reserved occupancy over its cap. Runs unconditionally,
// independent of overall pool fullness — unlike the Underpriced exemption
// below, which only applies once the pool is already globally full.
if reserved && pool.isNewReservedSlot(pendingList, from, tx) {
if pool.reservedOccupancy+1 > pool.reservedOccupancyCap() {
stage0Duration = time.Since(stage0Time)
return false, ErrReservedOccupancyExceeded
}

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 reservedOccupancyCap() is computed against GlobalSlots+GlobalQueue, which is the same denominator the pool uses for its real slot based fullness check (with numSlots(tx)). However, every place that bumps reservedOccupancy just does +1/-1 per tx, never numSlots(tx). So a reserved sender sending big calldata txs (multiple slots each) would only count as "1" against a cap that's measured in slots? Keep me honest here, but could someone blow past the intended 50% pool share pretty easily, right? Maybe worth a check

return nil, err
}
if !c.Active || c.EffectiveFrom > effectiveAt {
if !c.Active || c.EffectiveFrom > effectiveAt || c.FeeMode != FeeModeFree {

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.

looks like non-free (feeMode!=0) clients still get read from the registry every single block before getting filtered out. Not a big deal today since it's gated behind whoever controls the whitelist, but if that list grows with a bunch of non-free addresses, seems like unnecessary per-block work for addresses that can never be reserved anyway. Maybe we should filter earlier in the read path? Not sure how much this matters in practice tho

Comment thread eth/gasprice/gasprice.go

var reserved map[common.Hash]struct{}
for _, receipt := range receipts {
if isReservedReceipt(receipt) {

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.

Here and in feehistory, I think this might hit on the state-sync txs, because it's inferring "reserved" from EffectiveGasPrice == 0, but the state-sync tx also reports 0. Could we end up skewing eth_feeHistory a bit on blocks that have both? Seems like internal/ethapi/api.go uses the actual index table instead, which seems safer. Shall we reuse that here too?

Comment thread core/types/block.go
payload := h.Extra[ExtraVanityLength : len(h.Extra)-ExtraSealLength]

if chainConfig.Bor != nil && chainConfig.Bor.IsAustin(h.Number) {
var blockExtraData BlockExtraDataPostAustin

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.

If we add the ReservedCapacity as a new field, does this mean an old node can't parse a header once that field shows up? Probably fine since nothing's live yet and we have a HF, but might be worth an addition in the PR description / docs reminding whoever runs the next devnet round that every node needs the new binary before any of them start producing.

Comment thread core/blockchain.go
Comment on lines 2607 to +2635
@@ -2612,9 +2627,12 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [
headers = append(headers, block.Header())
}

// Write all chain data to ancients.
// Write all chain data to ancients. These blocks arrive via receipt
// sync rather than local execution, so there is no reserved-tx
// classification to carry; the nil entries make writeAncientBlock
// record that explicitly.
td := bc.GetTd(first.Hash(), first.NumberU64())
writeSize, err := rawdb.WriteAncientBlocks(bc.db, blockChain, receiptChain, borReceipts, td)
writeSize, err := rawdb.WriteAncientBlocks(bc.db, blockChain, receiptChain, borReceipts, make([]rlp.RawValue, len(blockChain)), td)

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.

Do blocks that come in via receipt sync get reservedTxIndexes hardcoded to nil? I mean it would make sense, as no local execution is there to derive it from, but doesn't that mean EffectiveGasPrice for reserved txs on those blocks is just permanently wrong, with no way to backfill? Might be a non-issue if we never actually run that sync mode across the reserved-blockspace range, but wanted to double check.

@marcello33

Copy link
Copy Markdown
Collaborator

Shall we improve diffguard? Also worth merging develop back to unlock the kurtosis related tests

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.

2 participants