fix(consensus): don't propose at historical heights after a bad block-sync handover - #1416
Conversation
The applier saved a block to the block store and only then handed it to the application. An application that refuses the block - and one holding a stale execution context for that height does refuse it - left the store one height ahead of a block it never processed, and every later start had the handshake re-process that block through the same application, so the node could not restart its way out (#1413). ApplyBlock is now run as its two halves with the store advanced between them: ProcessProposal, where an application refuses a block and nothing has been persisted yet, then SaveBlock, then FinalizeBlock, where the application commits. Saving before the commit is what keeps the store from falling behind the application or the state, a case the handshake rejects outright, while a store one ahead is one it recovers from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p's reach The wall-clock backstop handed a node over to consensus after ten minutes without progress however far behind it was. What follows that handover is consensus catch-up, which moves about one block per gossip cycle: it closes a gap of a few blocks in seconds and a gap of thousands never, and in the meantime the node sits in the validator set at heights the network committed long ago (#1413). The backstop now applies only within maxCatchupGap of the highest height any peer claims. Further back, block sync keeps retrying and logs the distance every interval - the only route to the tip that exists. A stall on a block no peer can serve still ends block sync as before, so a node that genuinely has nowhere to fetch from is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… behind Block sync hands over to consensus even when it never reached the tip, and nothing told consensus about it. A long-lived validator handed a historical height is very likely the genuine proposer of the next height too, so it proposed a block built from present-day application state; the real block for that height then collided with it, and the node could not restart its way out (#1413). A handover that leaves the node provably behind - block sync stopped while a peer claimed a height above ours - now holds back proposals until something says the node reached the network: a peer reporting a height no higher than its own, or a block committed through consensus while no peer reports one. Voting and following consensus are untouched, and a suppressed round times out and rolls on like any other silent proposer. Only a handover can start the window, and it closes for good, so a peer lying about its height can neither open one nor reopen one - it can only hold a window the node already entered open while it stays connected. A node no peer claims to be ahead of, a solo validator or a fresh network, never enters it at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🕓 Queued for automated review — 12th in line, estimated start in ~2.2 h (commit d3bd400)
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The PR improves block-sync handover and application rejection ordering, but three in-scope fail-open paths still permit the historical-height/store-ahead failure modes it is intended to prevent. Proposal suppression is neither armed for the normal one-block-behind handover nor tied to the block-sync high-water mark, and an incoherent accepted ProcessProposal response is still detected only after persistence. Focused tests for the newly added catch-up and block-sync cases pass; the full consensus package run also encountered an unrelated timing-sensitive load test failure.
Source: Codex general and consensus-security reviewers (exact backend model IDs were not present in the supplied evidence); Claude Agent SDK final verifier (exact backend model ID was not exposed to this runtime). openclaw-agent/cliproxy/gpt-5.6-sol was orchestration-only and is not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— tenderdash-consensus-security (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `internal/blocksync/reactor.go`:
- [BLOCKING] internal/blocksync/reactor.go:311: Arm proposal suppression whenever the observed peer tip is above persisted state
`Synchronizer.height` is the next unapplied height, while `state.LastBlockHeight` is the last applied height. Therefore, when a peer advertises `state.LastBlockHeight+1`, `IsCaughtUp` returns true even though that peer's tip block has not been applied, and the `!caughtUp` condition forces `behind=false`. An ordinary peer can advertise exactly that range, cause the handover, and delay its consensus height/commit traffic until the validator enters propose at that already-committed historical height. This bypasses the new suppression gate despite positive evidence that the peer is ahead. Whether block sync considers the remaining gap suitable for consensus catch-up and whether proposals must remain suppressed are separate predicates; arm the tracker whenever the observed block-sync tip exceeds the persisted state height.
In `internal/consensus/catchup.go`:
- [BLOCKING] internal/consensus/catchup.go:77-81: Do not discard the block-sync high-water mark based on mutable peer state
The tracker retains only a callback to current consensus `PeerState` heights, not the block-sync tip that proved the node was behind. Those heights are untrusted and do not exclusively represent an advertised tip: `handleVoteMessage` calls `SetHasCommit`, whose `setHasCommit` raises `PRS.Height` to the catch-up commit's own historical height, and an unsigned `NewRoundStep` can establish a similarly low initial height. If that value is at or below the local proposal height, this code permanently clears the tracker—even before any block is committed—and a later report of the peer's actual far-higher tip cannot re-arm it. Retain the block-sync target height as a monotonic high-water mark and suppress proposals until committed local progress reaches that target; lower claims, inferred commit heights, and peer removal must not reduce or clear it.
In `internal/blocksync/applier.go`:
- [BLOCKING] internal/blocksync/applier.go:96-103: Validate ProcessProposal coherence before saving the block
With `verify=false`, `ProcessProposal` checks the application's status, response structure, transaction-result shape, and app-hash length, but it does not verify that the returned app hash, results hash, validator updates, or consensus parameters match the committed block. `SaveBlock` persists the block before `FinalizeBlock` performs that coherence check through `ValidateBlockWithRoundState`. If the application returns ACCEPT with a stale but structurally valid response, finalization returns `ErrInvalidBlock` and the applier panics after advancing the block store while neither Tenderdash state nor the application was committed. Handshake replay deterministically encounters the same mismatch, preserving the restart loop this PR is intended to close. Perform the coherence validation before `SaveBlock`; if duplicate last-commit signature verification is too expensive, split that verification from the response/header coherence checks rather than delaying the first coherence gate until after persistence.
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 2 only (queue backlog)
Four blocking issues remain: unsigned peer heights can indefinitely suppress proposals, the handover loses a post-stop state synchronization barrier, and two paths bypass the intended historical-proposal protection. The consensus and blocksync package suites pass with the race detector and deadlock tag, but their existing tests do not cover these failure scenarios. The worktree is unchanged.
Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: tenderdash-consensus-security); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)
Review provenance
- Triage:
criticalbygpt-6-astra(effort low) — The changes affect consensus proposal eligibility, block-sync handover, and block persistence ordering, where incorrect height tracking or recovery behavior could stall validators, produce invalid historical proposals, or leave nodes in restart-proof crash loops. - Phase 1 reviewers: not run (skipped for throughput: 55 PRs queued, above the 10 limit)
- Fresh verifier:
gpt-6-astra— final-verifier; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— tenderdash-consensus-security (completed, effort xhigh); agentphase2-reviewer
🔴 4 blocking
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `internal/consensus/catchup.go`:
- [BLOCKING] internal/consensus/catchup.go:77-79: Do not give unsigned height claims an indefinite veto over proposals
A peer without validator keys can both arm and indefinitely hold this gate, even when the node is at the real tip. During a multi-validator restart at committed height H, the peer can advertise StatusResponse{Base: H + 2, Height: 1 << 60}; this passes blocksync validation. Honest peers hold blocks only through H, so nobody advertises a usable block at H+1. After syncTimeout, stopNothingToFetch hands over with behind=true. An unsigned NewRoundStep advertising height 1<<60 also passes consensus validation and makes this condition reject every proposal attempt. Honest peer reports and blockCommitted do not override that maximum, and there is no expiry while the attacker remains connected. The does-not-rearm protection does not help because the attacker controls the evidence used for initial arming. Repeating this against restarting validators can leave every proposer suppressed without any Byzantine voting power. Require verified or corroborated catch-up evidence, or a bounded mechanism for discarding unproven ahead-height claims, rather than granting one peer a permanent proposal veto.
- [BLOCKING] internal/consensus/catchup.go:77-81: Preserve the known handover target before accepting lower peer heights
The handover transfers only a boolean and discards the blocksync height that established the node was behind. This gate then permanently disarms on any nonzero consensus maximum at or below the local proposal height. For example, a node can hand over after height 99 knowing a blocksync peer advertises height 5000, while that peer has not announced a consensus height; peers still syncing defer their consensus gossip until ready. If a seed reports consensus height 1, mayPropose(100) immediately clears the tracker despite the known gap. A later announcement of 5000 cannot re-arm it. A historical catch-up commit can similarly populate PeerState.Height through SetHasCommit without reporting the sender's actual tip. Preserve the handover target until catch-up evidence establishes that it has been reached or legitimately superseded; a lower consensus height from the currently reporting peers is not sufficient proof.
In `internal/blocksync/reactor.go`:
- [BLOCKING] internal/blocksync/reactor.go:306: Restore the post-stop applier-state read before consensus handover
Caching executor.State() before Stop introduces a stale-state window and removes an existing synchronization barrier. State() and Apply() use the same applier mutex. Previously, the post-Stop State() call waited for an Apply already holding that mutex; now a valid H+1 application can start after this read and consensus receives H without waiting for it. If application finalization has completed but the state-store write has not, State.OnStart can reload H and resume ABCI processing at a height already processed by the application. There is also a completed-apply failure: if H+1 finishes during Stop and requests RetainHeight=H+1, LoadSeenCommit contains H+1 and LoadBlockCommit(H) has been pruned. SwitchToConsensus then panics loading the stale H commit before its startup reload can help. Capture peer-height evidence before shutdown if needed, but read the state passed to consensus after Stop. This restores the barrier removed by this PR without requiring a fix for the separate, pre-existing lack of an apply-loop join.
- [BLOCKING] internal/blocksync/reactor.go:311: Determine proposal suppression independently of the sync verdict
IsCaughtUp compares the synchronizer's next block height against peers' committed block heights, so caughtUp=true does not establish that the actual handover state has applied their tip. With LastBlockHeight=99, synchronizer.height=100, and a peer advertising committed height 100, WaitForSync returns true and this expression forces behind=false. Consensus starts at height 100 without arming proposal suppression, although the network has already committed that height. A selected proposer can therefore still build the historical proposal this PR is intended to prevent. The same short-circuit ignores a higher peer learned between WaitForSync and this read. Keep caughtUp for the existing skipWAL decision, but determine behind independently by comparing peer-height evidence with the actual post-stop handover state's LastBlockHeight.
Preserve processing before block persistence and finalization afterward, while recording both execution phases in the upstream exec histogram. Retain handover safeguards and both sets of applier regression tests. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…tence Carry the observed target into consensus independently of the sync verdict, read state after stopping block sync, and bound proposal suppression based on unverified peer heights. Validate application responses before saving. Keep commit verification intact; its memoization is handled in PR #1427. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Keep the highest observed peer height as the handover target, but calculate the stall gap only from peers able to serve the required block. Cover range eligibility, receive-rate filtering, busy peers, and concurrent snapshots. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
The reviewed changes correctly address the historical-height proposal bug and the block-sync persistence-order issue. Handover targets are retained monotonically, proposal suppression is independent of the synchronization verdict and bounded by ten minutes, final applier state is read after synchronizer shutdown, and application responses are validated before block persistence. No in-scope blocking issues or actionable suggestions remain.
Source: reviewer 1: gemini-3.8-flash-high (agent: phase1-reviewer, role: general); reviewer 2: gemini-3.8-flash-high (agent: phase1-reviewer, role: tenderdash-consensus-security); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: tenderdash-consensus-security); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)
Review provenance
- Triage:
criticalbygpt-6-astra(effort low) — The large, intricate diff changes consensus proposal eligibility and block-sync handover behavior in internal/consensus/state_enter_propose.go and related consensus/blocksync state transitions, directly affecting a critical consensus surface. - Phase 1 reviewers:
gemini-3.8-flash-high— general (completed, effort high); agentphase1-reviewer,gemini-3.8-flash-high— tenderdash-consensus-security (completed, effort high); agentphase1-reviewer - Phase 1 model:
gemini-3.8-flash-high— antigravity quota: weekly 85% left, 5h 100% left - Fresh verifier:
gpt-6-astra— final-verifier; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— tenderdash-consensus-security (completed, effort xhigh); agentphase2-reviewer
Out-of-scope follow-up suggestions (1)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Synchronizer shutdown does not explicitly join block-application routines — Synchronizer.Stop() does not formally wait for all in-flight block-application handlers to exit. The post-stop executor.State() read serializes with active Apply() calls, but a handler may still continue after shutdown returns, so explicit lifecycle joining would improve the handover boundary. This is a pre-existing concurrency concern explicitly excluded from this PR's scope and does not block approval.
- Follow-up: Track block-application handlers with a wait group and make synchronizer shutdown return only after all handlers have exited.
Integrate committed-block recovery from #1415 and retain both changelog entries. No manual Go changes were required. Document in AGENTS.md and CLAUDE.md that changelog generation belongs to the release script, not routine development changes. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Integrate #1453 and resolve the changelog conflict by retaining the base version. Remove PR1416's manual changelog entry in accordance with the repository's release-script policy. Co-Authored-By: Codex <noreply@openai.com> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
TL;DR: A validator returning to the network delays creating blocks after an incomplete synchronization handover. The delay lasts until it reaches the recorded target or ten minutes elapse. Blocks rejected during application-response validation are not persisted.
User story
As a node operator restoring a validator, I want it to keep catching up before creating new blocks, while retaining supported restart recovery if block processing is interrupted.
Scenario
Base flow
A restored node downloads committed blocks, then switches to consensus to finish catching up and participate in the network.
Actual behavior
An early handover could allow the validator to create blocks at historical heights. During block synchronization, a block could also be stored before the application response had been validated.
Expected behavior
After a handover with a recorded target ahead of the node, local block creation pauses until that target is reached or ten minutes elapse. Voting and following the network continue. The node keeps downloading while usable peers report a large remaining gap. Application-response validation precedes persistence, with the existing recoverable save-before-commit ordering retained.
Detailed discussion
Issue being fixed or feature implemented
Fixes #1413. Related commit-handling work is in #1414. Application-side stale execution context handling belongs to dashpay/platform#4462. BLS verification optimization remains separate in #1427.
What was done?
catchupTrackergates local proposal building using a fixed committed-height target.WaitForSyncretains the highest peer height observed while waiting, even if that peer disappears or reports a lower height.poolRoutinereads applied state after stopping the synchronizer and passes the target toSwitchToConsensus.targetHeight > state.LastBlockHeight. It releases when the target has been applied or an absolute ten-minute deadline expires. Later peer reports cannot clear, extend, or re-arm it; a new handover can re-arm it. Voting and normal consensus transitions remain enabled.maxCatchupGap(10 blocks) of the highest height reported by a servable peer. Servability requires an overlapping advertised range and an acceptable receive rate; a temporarily full pending-request queue does not disqualify a peer. Heights from other peers remain part of the handover target but do not determine this stall gap. These heights remain peer claims, not verified network-tip evidence.ProcessProposal(..., true) -> SaveBlock -> FinalizeBlock. Invalid application responses fail before persistence. Saving before finalization preserves the replay-supported crash window where the block store can be one block ahead of application/state. Errors processing an already committed block still panic; this change does not promise recovery from a persistent application failure.Scope limits: with zero or one initial peer, handover can occur before a useful network target is known. The proposal gate can expire while the node is still behind. The pre-existing absence of a complete join of block-application handlers at synchronizer shutdown remains outside this PR; the post-stop state read does not establish that all handlers have exited.
How Has This Been Tested?
-race -tags=deadlock -p 1passed at649659c0. After the servable-height change, the affected blocksync suite passed again with the same flags (16.999s).git diff --checkpassed for the follow-up change. See the current commit's CI checks for full-suite and e2e results; no new e2e scenario is introduced.Breaking Changes
The internal
SwitchToConsensusinterface gainstargetHeight int64, andWaitForSyncreturns both caught-up status and the retained target. No public API change.Checklist
🤖 Co-authored by Claudius the Magnificent AI Agent