Skip to content

core, consensus, eth/sequencer, miner: keep preconfirmed order across a producer takeover - #2480

Open
cffls wants to merge 23 commits into
developfrom
fchen/verify-stalled-tail
Open

cffls wants to merge 23 commits into
developfrom
fchen/verify-stalled-tail

Conversation

@cffls

@cffls cffls commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a producer is cut off from its peers while it can still reach the sequence store, it keeps sealing blocks and publishing them. Consumers serve those blocks' transactions as preconfirmations. Once production rotates to another producer, the new producer rebuilt only the first of those heights from the store. Every later height went to its pool in a new order, so many preconfirmed transactions ended up with different results (index, logs) on the canonical chain. A second problem, in core, could then leave the old producer stuck on its own fork.

This PR has two parts.

1. eth/sequencer, miner: follow the old producer's sealed run across a takeover

  • A bor seal signs the header, so a rebuilt block never reproduces the original hash, and every later height names a parent this chain doesn't have. The new producer now keeps following the store's run across a parent that differs only in its seal. The store's parent and ours must agree on transactions, receipts, gas used, gas limit, timestamp and base fee, and on state apart from the EIP-2935 history slots. EIP-2935 records each parent hash in state, so after the first rebuilt height the roots legitimately differ there. The new producer writes the store chain's hashes into those slots of its own parent state and requires the store parent's root exactly. Any other difference ends the bridge, for example a transaction that stored a BLOCKHASH result, which leaves its receipt unchanged.
  • A recovered sealed generation is rebuilt exactly, with no pool fill (AdoptedWindow.Complete). A dead producer's unsealed last window on an equivalent parent is seeded in the store's order.
  • The seal barrier passes recovery builds. Recovery reads get their own 1 s budget once the probe shows our height already sealed in the store; a CPU-starved takeover producer missed the shared 250 ms deadline and fell back to the pool.
  • Read-breaker fixes: the frozen-tail rule is suspended while following a dead producer's run; its probe asks the store directly whether anything follows the frozen head; and a staleness hold lifts after a bound. Before this, a node could stay blind to the store indefinitely. A frozen tail sealed above our own chain head no longer counts either: that is a producer we haven't caught up with, and counting it tripped the breaker just before a late takeover.
  • A producer elected again after a takeover used to mute every build at or below its sealed tip, even when the chain had moved off the blocks it sealed there (for example after it rewound itself onto a milestone). The publisher now records the parent it sealed each height on; a build under the tip on a different parent drops the tip and reads the window from the store. A build on the parent we sealed on is our own seal racing the head update, and stays muted.
  • A build muted because our signer couldn't seal its height reads nothing from the store. If a span update authorizes the signer before the build seals, the seal barrier now refuses it, and the next build-start read follows the store.

2. core, consensus: rewind a stalled chain off blocks its span no longer authorizes

  • An exact rebuild shares the old producer's block's state root. On a node that has pruned its fork-point state, the side-chain import refuses the canonical chain, because a side block whose root matches the canonical block at its height can't be verified there.
  • Milestone handling defers because the node looks out of sync, and the background header check skipped every tick on which the milestone was at or past the head ("still syncing"). Nothing moved the node off its fork.
  • The background check now verifies the node's own trailing headers when the milestone is at or past the head. It rewinds to the parent of the first one sealed by an unauthorized signer, so the canonical chain imports as an ordinary extension with full execution.
  • The rewind rechecks its verdict under the chain lock: it goes ahead only if the rejected header is still canonical and its parent is still at or above the finality floor. A valid chain imported, or a milestone whitelisted, between the verification pass and the rewind makes the verdict stale, and the rewind is skipped. The existing rewind for pending headers above the milestone had the same race and now goes through the same check.
  • Only an unauthorized-signer error rewinds; other failures (Heimdall unreachable, span not available yet) leave the chain alone. Nothing at or below the higher of the whitelisted milestone and the finalized block is touched, and at most 256 headers are checked per tick.
  • consensus.ErrUnauthorizedSigner is a new sentinel that the bor unauthorized signer and proposer errors match through Is.

Executed tests

Unit

  • eth/sequencer (full suite and race detector), miner, consensus/bor and full core: pass.
  • New eth/sequencer tests: parent-equivalence bridge (sealed and live windows), the seal flush carrying a bridged window, the barrier passing recovery builds, recovery surviving slow store reads, read-breaker suspension, probing and the bounded hold, and the state check: TestHistoryOnlyDiff (8 cases on real state), TestRecoveryBridgeFollowsTheRunsHistory (heights 2 to 4 end to end, including a transaction that stored a block hash) and TestStoreLineage. Each guards a specific fix and fails without it.
  • Also new in eth/sequencer: TestReelectedProducerRecoversItsOwnRun (our run left the chain, was rewound away, races our own seal, or is extended) and the frozen-tail-above-head cases in readbreaker_stale_test.go.
  • New miner tests: TestCompleteWindowSkipsPoolFill, TestMutedBuildAuthorizedMidBuildRebuilds, TestSignerAuthorizedOutsideBor.
  • New core tests: TestStalledForkRecoversWithOneRetainedState reproduces the stuck node end to end with path scheme, TriesInMemory = 1 and a freezer, and recovers; it fails without the fix. TestVerifyStalledTail covers 12 cases. TestRewindRechecksStaleVerdict imports a valid chain, finalizes it, or whitelists a milestone in the middle of verification, on both rewind paths; without the recheck all six stale cases rewind to block 5.
  • The miner prefetcher goroutine and heap threshold tests (TestPrefetchMultiBlock, TestPrefetchGoroutineLifecycle, TestRapidBlockProduction_WithoutWait) are flaky under load. They fail the same way on develop, and pass in isolation.

Devnet (kurtosis: 3 producers, 1 witness-synced validator, 1 RPC consumer, sequence store). The in-turn producer was cut off from all other Bor nodes for 30 s, keeping store and Heimdall access, under two loads: 20 tx/s through the consumer and 10 tx/s sent straight to the cut-off producer. Each first-served receipt was compared with the canonical one (block number, index, status, gas, logs).

This PR Baseline
Plain takeover (8 s, 30 s, 75 s partitions) 0 mismatched, 0 lost 88–154 mismatched per run
Full fault matrix: 11 variants, two repeated 11 of 13 runs fully clean 0 of 13 clean; 4 runs lost 40–103 transactions
Mixed fixed and unfixed producers each takeover behaves per the new producer's version; no harm from mixing —

The matrix variants were short/long partitions, a store pause, a frozen gateway, a competing producer, the consumer on the old fork, killing the new producer, killing the consumer, and a contract reading BLOCKHASH.

Devnet fault matrix on this branch (same topology and load). On 5689ff86c, the partition-only and single-fault variants were clean apart from the documented store-outage and BLOCKHASH limits. Killing the new producer and keeping it dead found the two takeover shapes fixed by the last three commits. Reruns with them:

Shape Before With the fixes
New producer killed, restarted 15 s later 20/29 mismatched (frozen-tail breaker tripped) 0 in 2 runs
New producer killed and kept dead, old producer elected again 40/43 mismatched 0 in 3 runs (sealed run left the chain fired in each)
Same, with a span update authorizing a muted build mid-build 20/30 mismatched covered by a unit test; this timing didn't come up again

The seal barrier fix didn't fire on a devnet run. Runs where the span rotated away from the cut-off producer still mismatch; see Rollout notes.

pos-workflows sequencer suites (CI config: kurtosis-pos v1.4.3, heimdall-v2 b1f5a008, store 771c465; fresh devnet per image on a 16-core host)

This branch (932ffa7a2) 5689ff86c
kurtosis_sequencer_test.sh pass pass
sequencer_rpc_test.sh pass pass
sequencer_chaos_test.sh pass (seed 4080) pass (seed 23099)
chaos replay, seed 21403 fail fail, identically

Seed 21403 is an existing issue: after its large-calldata burst (120 × 61 KB entries), one publisher's store tail reads keep running out of time and it flips between live and resyncing.

Gates on the last three commits

  • go test -race ./miner ./eth/sequencer: pass.
  • golangci-lint (repo config): 0 issues.
  • diffguard against 5689ff86c with mutation testing: all checks pass, 47/47 mutants killed.

Not yet done

  • Mutation testing hasn't been re-scored over the whole PR, only over the last three commits.

Rollout notes

  • Block validity rules are unchanged. The miner's choice of transactions changes during a takeover (the rebuilt block is the store's sequence, with no pool fill), and the core change adds a node-local rewind. Both are backwards compatible, with no config changes.
  • Known limits:
    • A transaction whose receipt or stored state depends on a block hash (BLOCKHASH, or the EIP-2935 history contract) ends the bridge at the next height. Its own block still follows the store's order.
    • The bridge follows at most 1024 heights of one run, the distance its history check walks back. Later heights are built from the pool.
    • The state check relies on fees going to a fixed coinbase, as they do post-Rio on mainnet and Amoy (0x7Ee4…66B0) and on the devnet. On a chain with no coinbase configured, fees go to each block's signer, the two producers' blocks differ in balance, and the bridge stops after the first rebuilt height.
    • A store outage during a takeover still falls back to building from the pool.
    • A single store call slower than 250 ms still fails the first build-start probe.
    • In one of four runs on the combined build, a run that killed the new producer mid-recovery mismatched 198 and lost 31 (the same variant was clean in two earlier runs). Still under investigation.
    • A cut-off producer that keeps sealing after the span rotates away from it still publishes to the store, and the store accepts those writes, so consumers serve them as preconfirmations. No valid chain can include those blocks (the new producer ignores them as consensus-invalid), so those mismatches can only be prevented by the store refusing writes from a producer outside the current span.
  • Decisions for reviewers:
    • A store writer now decides transaction order for several canonical blocks during a takeover, with no check that it was an authorized producer.
    • Blocks reported by bor_getInvalidPreconfBlocks (and counted by the consumer audit) may have kept every result, because only the block hash changed.
    • Rebuilt blocks skip the pool, so pool transactions wait until catch-up ends.

cffls added 2 commits October 12, 2026 20:45
… authorizes

The background header check skipped every tick on which the latest milestone
was at or past the head, reading it as "still syncing". A rotated-out
producer can sit there for good: an exact rebuild of its block by the new
producer shares the block's state root, so on a node that has pruned the fork
point the side-chain import refuses the canonical chain (a side block whose
root matches the canonical block at its height cannot be verified there),
while milestone handling defers because the node looks out of sync. Nothing
else moves it.

When the milestone is at or past the head, check the chain's own trailing
headers (at most 256, above the higher of the whitelisted milestone and the
finalized block) against the span, and rewind to the parent of the first one
sealed by an unauthorized signer. The canonical chain then imports as an
ordinary extension with full execution. Only an unauthorized-signer error
rewinds; any other verification failure leaves the chain alone, since a node
that is merely syncing passes through here on every tick.

consensus gains an ErrUnauthorizedSigner sentinel that the bor unauthorized
signer and proposer errors match through Is, so core can tell a provably
invalid header from an inconclusive failure without importing consensus/bor.
… takeover

When a producer is cut off and another takes over, the store holds the old
producer's sealed run of heights, which consumers have already served as
preconfirmations. Recovery rebuilt only the first of them: a bor seal signs
the header, so the rebuilt block never reproduces the original hash, every
later height names a parent this chain does not have, and those heights went
to the pool in a new order, changing the results of preconfirmed
transactions.

Follow the run across a parent that differs only in its seal. The store's
sealed parent and ours must agree on transactions, receipts, gas used, gas
limit, timestamp and base fee. The state root is not compared: EIP-2935
records the parent hash in state, so roots differ after the first rebuilt
height even when every transaction executed identically.

Supporting changes found while testing takeovers on a devnet:
- A recovered sealed generation is rebuilt exactly (AdoptedWindow.Complete):
  pool transactions appended after it would change the state the store's
  next sealed height executed on.
- A dead producer's live window on an equivalent parent is seeded in the
  store's order, and the seal flush may displace it without the chain's
  proof when it carries the window in full, in order.
- A recovery build passes the seal barrier: it is the store's sealed
  generation, and the tail read could show a newer window of the dead
  producer as owed, looping the rebuild until the height went to the pool.
- Once the probe finds our height sealed in the store, the walk and the
  recovery fetches get their own budget instead of what is left of the
  250ms build-start deadline, which a CPU-starved takeover producer misses.
- The frozen-tail read breaker is suspended while following a dead
  producer's run, its probe asks the store directly whether anything
  follows the frozen head, and a staleness hold lifts after a bound, so a
  sole producer holding its own publishes cannot stay blind forever.
- Debug logs for every build-start classification and every reason a
  sealed height is not recovered.
@cffls cffls changed the title core, consensus: rewind a stalled chain off blocks its span no longer authorizes core, consensus, eth/sequencer, miner: keep preconfirmed order across a producer takeover Oct 13, 2026
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.37037% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.90%. Comparing base (60f790c) to head (e6ecc4a).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
core/blockchain.go 90.85% 12 Missing and 3 partials ⚠️
eth/sequencer/bridge.go 93.80% 9 Missing and 4 partials ⚠️
eth/sequencer/buildstart.go 90.90% 3 Missing ⚠️
eth/sequencer/readbreaker.go 96.00% 3 Missing ⚠️
consensus/bor/span_store.go 95.34% 2 Missing ⚠️
eth/sequencer/reader.go 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #2480      +/-   ##
===========================================
+ Coverage    57.78%   57.90%   +0.12%     
===========================================
  Files          966      967       +1     
  Lines       176613   177189     +576     
===========================================
+ Hits        102048   102606     +558     
- Misses       68907    68926      +19     
+ Partials      5658     5657       -1     
Files with missing lines Coverage Δ
consensus/bor/bor.go 85.68% <100.00%> (+0.05%) ⬆️
consensus/bor/errors.go 100.00% <100.00%> (ø)
consensus/bor/snapshot.go 87.79% <100.00%> (+0.14%) ⬆️
eth/sequencer/adoption.go 93.00% <100.00%> (+1.07%) ⬆️
eth/sequencer/barrier.go 94.39% <100.00%> (+0.76%) ⬆️
eth/sequencer/classify.go 84.45% <100.00%> (ø)
eth/sequencer/publish.go 87.65% <100.00%> (+1.40%) ⬆️
eth/sequencer/publisher.go 88.38% <100.00%> (+0.46%) ⬆️
miner/miner.go 71.65% <ø> (ø)
miner/sequencer.go 91.78% <100.00%> (+1.13%) ⬆️
... and 7 more

... and 24 files with indirect coverage changes

Files with missing lines Coverage Δ
consensus/bor/bor.go 85.68% <100.00%> (+0.05%) ⬆️
consensus/bor/errors.go 100.00% <100.00%> (ø)
consensus/bor/snapshot.go 87.79% <100.00%> (+0.14%) ⬆️
eth/sequencer/adoption.go 93.00% <100.00%> (+1.07%) ⬆️
eth/sequencer/barrier.go 94.39% <100.00%> (+0.76%) ⬆️
eth/sequencer/classify.go 84.45% <100.00%> (ø)
eth/sequencer/publish.go 87.65% <100.00%> (+1.40%) ⬆️
eth/sequencer/publisher.go 88.38% <100.00%> (+0.46%) ⬆️
miner/miner.go 71.65% <ø> (ø)
miner/sequencer.go 91.78% <100.00%> (+1.13%) ⬆️
... and 7 more

... and 24 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.

@cffls
cffls marked this pull request as ready for review October 13, 2026 20:08

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

@cffls
cffls requested a review from pratikspatil024 October 13, 2026 20:10
Comment thread core/blockchain.go Outdated
Comment thread eth/sequencer/bridge.go
cffls added 3 commits October 14, 2026 11:48
Background header verification rewinds the chain off the first header it
rejects, but reaches that verdict outside the chain lock. A valid chain
imported, or a milestone whitelisted, between the verification pass and
the rewind makes the verdict stale, and the rewind then drops blocks it
never judged, finalized ones included: a chain finalized at 9 was rewound
to 5 and lost its finalized marker.

Both rewind paths, the stalled tail and the pending headers above the
milestone, now go through rewindOff. It rewinds only if, once the chain
mutex is held, the rejected header is still canonical and its parent is
still at or above the finality floor, and it keeps the mutex until the
rewind is done. setHeadBeyondRoot takes optional guards for this, checked
right after it takes the lock; its existing callers pass none.
The takeover bridge followed the store's run across a parent that matched
ours in transactions, receipts and execution context, without comparing
state. Matching receipts do not prove matching state: a transaction can
store a BLOCKHASH result, which differs under another seal, and keep its
receipt identical, and a later transaction can diverge on the stored value
without reading a block hash itself.

The parents' states must now agree apart from the EIP-2935 history slots,
the one difference a rebuild under another seal cannot avoid. Our parent's
state gets the store chain's hash written into the history slot of each
height the two chains do not share, and its root must then equal the
store parent's exactly. The store's hashes for earlier heights come from
the sealed headers the bridge read at each step, kept for up to 1024
heights; a missing or mismatched link can only fail the check.

The state is opened with SealingStateAt: StateAt serves a pipelined import
still committing as an overlay, whose root leaves the block's own writes
out.
@cffls
cffls requested a review from 0xrukimedo October 15, 2026 22:14
@cffls

cffls commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread core/blockchain.go Outdated
Comment thread eth/sequencer/bridge.go
Comment thread eth/sequencer/bridge.go Outdated
Comment thread eth/sequencer/buildstart.go Outdated
cffls and others added 4 commits October 15, 2026 16:36
rewindOff's guard only checked the requested target against the finality
floor, but rewindHead keeps walking back past blocks without state. On a
hash-scheme node with pruned fork-point state, that is the case the
stalled-tail check targets, the head could land below the whitelisted
milestone or finalized block.

The guard now also requires state between the floor and the target, using
the same availability test rewindHead does, under the chain lock and before
any marker is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed it

p.bridged was set when a build seeded a dead producer's live window across
an equivalent parent, and never cleared. A later build at that height on a
parent that doesn't bridge could then match the stale record, and its flush
displace the window without proof from the chain.

A new build at or below the record's height now drops it. The record still
outlives the seal: the flush that relies on it reconciles after the next
height's build has started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each recovery read (the walk at our height, the sealed generation and the
bridged parent) started its own 1s deadline, so a slow store could hold a
takeover producer's build start for about 3s, past the block period.

AdoptWindow now attaches one budget that the first recovery read starts and
the rest share. It stays detached from the 250ms build-start deadline, so
a CPU-starved takeover still gets its recovery reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The history check followed the store's run only as far as the links the
bridge had read in this process. A takeover producer restarted mid-run came
back with none, so the check failed at the next height and every later one
was rebuilt from the pool.

The check now walks height by height, and reads a missing link's sealed
generation back from the store on the build start's recovery budget. The
header must have the hash its child names as parent; a missing or
mismatched read still ends the walk. Backfilled links keep the ones above
them, unlike a fresh read of the store's tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@pratikspatil024 pratikspatil024 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.

Two high-priority finality findings from static analysis; reproductions have not been executed.

Comment thread core/blockchain.go
Comment thread core/blockchain.go Outdated
cffls and others added 4 commits October 16, 2026 10:09
The finality precheck only asked whether a block between the target and
the floor had state. rewindHashHead checks its search limit (the pivot,
or head minus FullImmutabilityThreshold) before state, so a persisted
pivot above the target sent the head to genesis, below the floor. The
precheck now replays rewindHead's cutoffs in its order on each scheme.

Also correct rewindOff's comment on milestones whitelisted while the
rewind runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tail that stops moving after sealing past our own head is a producer
we haven't caught up with, not a wedged store. Counting it tripped the
read breaker just before a late takeover, so the new producer started
its span degraded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A producer elected again muted every build at or below its sealed tip,
even when the chain had moved off the blocks it sealed there. Record the
parent we sealed each height on, and when a build under the tip sits on
a different parent, drop the tip to that build's parent and read the
window from the store instead. A build on the parent we sealed on is our
own seal racing the head update, and stays muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build muted because our signer couldn't seal its height publishes
nothing to the sequencer. If a span update authorizes the signer before
the build finishes, sealing it would put a block on chain that never
read the store. Refuse it at the seal barrier so the next build-start
read follows the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cffls

cffls commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread core/blockchain.go
spanByBlockNumber falls back to the older of two overlapping spans when
the lookup for the newer one fails, and the latest-span cache keeps its
old value while Heimdall is unreachable. Right after a rotation, either
makes the new producer's valid blocks look unauthorized, and the
stalled-tail check would rewind them.

The span store now reports whether the newer-span search settled: it
reached a latest span fetched in the last 2s, or ran past every span
that could hold the block, with no failed lookup. A Rio snapshot built
from an unsettled span marks its unauthorized-signer and -proposer
errors so they no longer match consensus.ErrUnauthorizedSigner. Block
import and the miner see the same error types as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread eth/sequencer/publish.go Outdated
Comment on lines +337 to +340
func (p *Publisher) sealedRunLeftLocked(number uint64, parent common.Hash) bool {
ours, ok := p.ownRun.at(number)
if !ok || ours.parent == parent {
return false

@0xrukimedo 0xrukimedo Oct 19, 2026 •

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.

This misses the case where we rewind the whole run and start again on the original parent. The parent still matches, but the blocks we sealed are gone.

I tried sealing heights 1 and 2, rewinding to their parent, then rebuilding height 1. The store read finds the transactions, but AdoptWindow returns nil and keeps sealedTip=2, so we rebuild from the pool and lose their order.

Can we distinguish that rewind from a seal that's still waiting to be imported? Parent equality alone doesn't tell us which happened.

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.

Right, parent equality can't tell those apart. Fixed in 9ceb185, which tracks the height of the latest build start and raises it on any seal above it. Sealing a height needs the chain at that height's parent, so a build below it can only follow a rewind, and it now reads the run from the store. Your case (seal 1 and 2, rewind to their parent, rebuild 1) recovers both heights in the store's order. So does sealing 1 and 2, building 3 on our 2, rewinding, then rebuilding 2.

A build at our latest seal height on the parent we sealed on still stays muted. Until a build starts on our block, that's the same thing the chain sees when our seal is still waiting to be imported, so this one height is the only case still ambiguous.

Test: TestRewoundOwnRunIsRebuiltFromTheStore.

Comment thread eth/sequencer/readbreaker.go Outdated
Comment on lines 206 to 210
if !bytes.Equal(tail, b.lastTail) {
b.lastTail = append(b.lastTail[:0], tail...)
b.freeze = 0
if b.stale {
b.open, b.stale, b.failures = false, false, 0
readBreakerClosed.Inc(1)
log.Info("Sequencer store tail is advancing again")
if seal != 0 {
b.lastSeal = seal
}

@0xrukimedo 0xrukimedo Oct 19, 2026 •

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.

Could we update lastSeal outside the tail-change check? A read starting just after the seal can see the tail without learning the seal height. If a later read finds the seal but ends at the same tail, we still keep lastSeal=0.

With the store sealed through height 2 and our head at 0, this trips the breaker after six reads, even though we're just behind the producer. Moving the seal update before this check should let the new exemption work in that case too.

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.

Agreed, fixed in 49fb8b4: lastSeal is now updated before the tail-change check, so a later read of the same tail can still set it. Added your case (the first read sees the tail with no seal, a later one sees it sealed above our head) to TestReadBreakerFrozenRunAboveChain; it stays closed.

@0xrukimedo 0xrukimedo 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.

I’d fix the span lookup before merging, since it can make us rewind valid blocks.

Comment thread consensus/bor/span_store.go Outdated
Comment on lines +359 to +363
// Spans start in id order, so two starting past the block rule out every later span.
if blockNumber < span.StartBlock {
skippedSpans++
if skippedSpans > 1 {
if candidateSpan == nil {
return nil, fmt.Errorf("span not found for block %d", blockNumber)
}
return candidateSpan, nil
return spanOrNotFound(candidateSpan, true, blockNumber)

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.

I'm concerned about settled=true here. Newer spans can start earlier than older ones; Heimdall even has a test for that.

For block 400, I tried span 1 covering it, spans 2 and 3 starting at 7000 and 7100, and span 4 starting at 300. We stop at span 3 and return span 1 as settled. The actual span-4 producer then gets ErrUnauthorizedSigner, which can now trigger a rewind of valid blocks.

Can we leave this result unsettled unless we've ruled out the later spans? The old shortcut becomes risky now that a rewind depends on it.

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.

Agreed, thanks for the Heimdall reference. Fixed in 51ec0a7: the two-skipped-spans shortcut now always returns unsettled, so a settled verdict needs a walk all the way to a fresh latest span. The shortcut still serves the plain lookup for import as before. Your layout (span 1 holding 400, spans 2 and 3 at 7000 and 7100, span 4 at 300) is now a case in TestSettledSpanByBlockNumber. The lookup still returns span 1 there, a limit of the shortcut that predates this PR, but it's unsettled, so a signer it rejects doesn't match ErrUnauthorizedSigner and nothing rewinds.

cffls and others added 2 commits October 19, 2026 12:08
getFutureSpan stops once two spans in a row start past the block. A
newer span can still start before older ones, so a later span may hold
the block, and a rewind must not rest on that shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read can reach the tail before it learns the seal under it. Updating
the seal only when the tail changed kept it at 0, so a node just behind
the producer still tripped the frozen-tail rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rewind back to the parent our run started from leaves every parent
matching, so the sealed-run check kept the build muted and it was built
from the pool. Sealing a height needs the chain at its parent, so a
build below our latest build start or seal follows a rewind; it now
reads the run from the store. A build at our latest seal height on the
same parent is still treated as our seal racing the head update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread eth/sequencer/barrier.go
p.mu.Unlock()

switch {
case recovering:

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.

A small hardening idea on the recovery path:

When we rebuild a dead producer's sealed generation, recoverWindowLocked sets modeRecover and Complete = true. From there, nothing seems to check that the block we seal actually contains the whole window:

  1. seedAdopted skips any tx that fails to apply (continue), and with Complete the worker returns right after it.
  2. This case recovering: returns true before mirrorVerdict runs. That's the check that, for normal builds, makes sure "a dropped executable transaction must still refuse".
  3. SealBlock takes the recovered path: it publishes nothing and clears the gate, so the block broadcasts.

If a tx is dropped, we'd broadcast a block at N whose content differs from what the store says is sealed at N, with no supersede to fix it. The bridge would fail closed at N+1, so it's bounded, but the seedAdopted doc relies on "the publisher's expectation matching" to catch exactly this, and on this path that matching doesn't run. (A declined adoption in applyAdoption also leaves p.mode as modeRecover while the worker pool-fills, though that one at least logs a Warn.)

I agree this should be rare, since the bridge already proved the parents equivalent. But since the safety argument now leans on the rebuild being exact, what do you think about checking it here? For example, keep the window's tx hashes on p.mode and only short-circuit when txs matches them exactly, otherwise fall through to the normal mirror/gate path. A unit test with one dropped tx in a Complete build would pin it.

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.

Good catch, fixed in e9237aa. One thing differs from the suggestion: falling through to the mirror wouldn't catch it. A recovery publishes nothing, so sealMirror reads the tail, sees the height already sealed in the store, and passes ("height already sealed in the store").

So the check lives in the recovering case itself. The recovered generation's tx hashes now ride on p.mode, and the barrier passes a recovery only when the block's transactions equal them exactly. Anything else (a dropped tx, a pool fill after applyAdoption declined the window, a reordering) becomes the over-sealed fallback (modeOverSealed). That's the existing path for sealing a store-closed height with content the store's seal doesn't describe, so SealBlock now publishes the block's flush and arms the gate with tolerateSealed instead of clearing it. The mismatch logs a Warn and counts sequencer/publish/recovered/inexact. This also covers your parenthetical about a declined adoption left in modeRecover.

I didn't refuse the block. The drop is deterministic on our parent, so every rebuild would recover and drop the same tx again until the refusal streak sealed it anyway. The fallback keeps every other tx in the generation's order, and the bridge still fails closed at N+1 since our N's transactions differ.

Test: TestBarrierPassesRecoveryBuild is now table-driven (exact, a tx dropped, filled from the pool, reordered) and checks the mode and the gate SealBlock arms. I also updated the seedAdopted doc.

cffls and others added 3 commits October 20, 2026 12:35
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches #2504, which took the same snapshot for readTail, recoverSealed and the gate: equivalentParent runs before AdoptWindow takes the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A recovery build passed the seal barrier unchecked and sealed without publishing, so a block missing a transaction of the sealed generation (one failed to apply, or the worker declined the window and filled from the pool) broadcast as if it were the store's. The barrier now passes a recovery only when the block carries the generation exactly; anything else becomes the over-sealed fallback, whose seal flush and gate account for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
return nil, false, err
}

fresh := time.Since(time.Unix(0, s.latestSpanAt.Load())) < latestSpanFreshness

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.

A latest-span cache younger than two seconds can still be outdated immediately after an overlapping span rotation. Returning settled=true can then make a valid producer match ErrUnauthorizedSigner, allowing the stalled-tail check to rewind valid blocks. The canonical-hash and finality guards do not recheck authorization.

Could we require a newly fetched authorization verdict before a destructive rewind and add a test for rotation immediately after a cache refresh?

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.

Agreed: a cache refreshed within the last two seconds only proves the poll loop got an answer recently, not that Heimdall committed no span since. Fixed in 7394f94.

Before rewinding, verifyStalledTail now calls the engine's new RefreshAuthorization (Bor fetches the latest span from Heimdall, with a 5 s timeout) and verifies the rejected header again. It rewinds only if that header is still ErrUnauthorizedSigner. If the refresh fails, the chain is left alone. Import and the other verification paths don't change, so the extra Heimdall call happens only before a rewind.

Tests:

  • TestRefreshAuthorizationAfterRotation (consensus/bor): a rotation lands right after a fresh cache refresh. The new span's producer first reads as an unauthorized signer, and after RefreshAuthorization it's authorized.
  • TestVerifyStalledTailConfirmsWithFreshSpan (core): confirmed verdict rewinds, a refresh that reveals a rotation does not rewind, and a failed refresh does not rewind.

Comment thread eth/sequencer/barrier.go
p.mu.Unlock()

switch {
case recovering:

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.

The writeDown return bypasses recoveredExactly. An inexact recovery therefore remains modeRecover, and SealBlock silently broadcasts it without queuing the corrective flush or arming the fallback gate.

Could we check recovery exactness before this return, or defensively in SealBlock, and add a test for an inexact recovery during a write outage?

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.

Good catch. The writeDown return comes before the exactness check, so an inexact recovery stayed modeRecover and SealBlock took the sealWithoutPublishLocked path. Fixed in e5ac932.

SealBlock now runs the same check (recoveredExactlyLocked) before it reads the mode, so every path into the seal flush is covered, not just the barrier. An inexact recovery during a write outage becomes the over-sealed fallback: its seal flush is queued for when the write path comes back, and the gate is armed with tolerateSealed. TestBarrierPassesRecoveryBuild gains exact and inexact cases with the write path down. (e6ecc4a only moves the flush into publishSealLocked to keep SealBlock within the size limit.)

cffls and others added 3 commits October 21, 2026 20:57
The seal barrier returns early while the write path is down, before the
recovery exactness check, so an inexact recovery stayed in recovery mode
and SealBlock sealed it without publishing. SealBlock now applies the same
check: an inexact recovery leaves as the over-sealed fallback, with its
seal flush queued and its gate armed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A latest-span cache can be fresh yet predate a span rotation committed
right after its refresh, so a valid producer can read as unauthorized.
Before rewinding, the stalled-tail check now has the engine refetch the
latest span and re-verifies the rejected header, rewinding only if the
verdict holds. A failed refresh leaves the chain alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cffls

cffls commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

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

Code review found no issues

No high-confidence issues detected in this change.

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.

4 participants