Skip to content

consensus: after a block-sync handover while still behind, a validator proposes at historical heights and wedges itself permanently #1413

Description

@QuantumExplorer

Expected Behavior

A node that hands over from block sync to consensus while it is still far behind the network should keep catching up. It should not act as a proposer at heights the network committed long ago, and nothing it does while behind should leave it unable to start.

Current Behavior

A mainnet evonode re-syncing from scratch stalled in block sync at height 24174 (5 peers, all seeds, none serving blocks), handed over to consensus, and as a member of that era's quorum was selected proposer at h=24175 r=1. It built a block for a 2024 height from its present-day Core state (chain lock at core height 2,523,755; the real block 24175 carries 2,157,967), prevoted it, and then — as soon as a peer started catching it up over consensus gossip — panicked, and kept panicking on every restart until the Tenderdash data (WAL + block store) was discarded. A second full re-sync with ~260 peers (block sync never stalled) went through.

Drive log at the moment of the stall:

2026-08-18T06:16:15.362578Z  PrepareProposal  h=24175 r=1  received ABCI request
2026-08-18T06:16:15.362708Z  PrepareProposal  h=24175 r=1  JSON-RPC request: getbestchainlock []
2026-08-18T06:16:15.364823Z  PrepareProposal  h=24175 r=1  propose chain lock update to height 2523755 at block 24175

Mainnet facts (platform explorer): block 24175 — app hash 402A8119…, core-locked height 2,157,967, proposer EE22F5BC…; block 24176 — proposer F1616A59…. Proposers at 24171..24180 are strictly increasing proTxHashes (round-robin, one step per height).

Mechanism

  1. Handover while behind. In 1.6.0 WaitForSync returns after a 60 s stall regardless of distance to the tip (v1.6.0 synchronizer.go#L290). 1.7.0 (fix(blocksync): survive transient peer failures and stalls #1396) only hands over when no peer can serve the next block or after maxSyncStall (10 min) (synchronizer.go#L414-L470) — it still hands over, and SwitchToConsensus (reactor.go#L446) only uses the caughtUp flag to decide whether to replay the WAL. Nothing tells consensus "you are behind".

  2. Proposing at a historical height. EnterProposeAction proposes whenever isProposer (state_enter_propose.go#L53-L114). The validator set at 24175 is the October-2024 quorum; a long-lived evonode is in it. With the consensus-version-0 selector, proposer(H, r) is proposer(H, 0) advanced by r, not persisted (height_proposer.go#L25, #L182-L195) — so proposer(H, r1) == proposer(H+1, r0). The node that proposed at (24175, r1) is therefore F1616A59…, the genuine round-0 proposer of 24176. That guarantees what follows.

  3. The block it builds. The ABCI app (Drive) answers PrepareProposal with whatever Core's current best chain lock is, if it is above the last committed core height (prepare_proposal.rs#L85-L127); Tenderdash has no "chain lock is absurdly far ahead of this block's position" check either (validateCoreChainLock only requires it to be higher). The proposal is a 2024 block carrying a 2026 chain lock; the node prevotes it and rolls rounds alone.

  4. Collision at the next height. A real peer eventually arrives. Once the stale round-1 proposal has expired, the genuine commit for 24175 (round 0) is accepted and applied (different round → the app re-executes → fine). The node then enters 24176 round 0 as proposer and calls PrepareProposal immediately. The genuine 24176 commit — also round 0, proposed in 2024 by this very node — arrives after that round expires → ApplyCommitmustEnsureProcessProcessProposal(genuine 24176, round 0) (state_apply_commit.go#L49-L54). The app already holds an execution context for (24176, round 0) — its own block — and (until the Drive fix referenced below) either answered with the cached app hash of its own block (process_proposal.rs#L43mustValidate panics with wrong Block.Header.AppHash at state height 24175, block 24176. Expected <ours>, got <genuine>execution.go#L439, block_executor.go#L111-L114), or refused with an ABCI error (process_proposal.rs#L74-L81mustEnsureProcess panics — block_executor.go#L77-L80).

  5. Why restarts do not help.

    • catchupReplay re-dispatches the node's own proposal and block parts first (→ ProcessProposal rebuilds the stale round-0 context in the app), then the peer's commit and the genuine block parts → the same panic, deterministically, on every start (replay.go#L105, #L89). This survives app restarts and app upgrades; with 1.6.0 the 60 s block-sync window before the handover is usually too short for sparse peers, so the WAL is replayed every time.
    • If only Tenderdash restarts and block sync does get blocks, the applier saves the block before ApplyBlock (applier.go#L84-L91); the app (still holding the stale context) fails it, the panic leaves the store one ahead, and every later start fails in the handshake (syncStateAterror on replay, replayer.go#L160-L178).

    Only discarding the WAL / block store gets the node out. While the stale proposal is live the node also rejects the real commit and (1.7.0) evicts the peer that sent it — filed separately as consensus: verifyCommit checks a peer's commit against our own proposal's BlockID — a lagging node rejects the real commit and (1.7.0) evicts the honest peer #1414.

The app-side half — answering a different block at the same height/round from a stale context — is a Drive defect, fixed in dashpay/platform#4462 (process_proposal now re-executes a different block instead of serving the cached hash or erroring; tests reproduce both behaviours). With that fix the collision becomes harmless: the genuine block executes and commits, and the node crawls forward over consensus gossip (or block-syncs on the next restart). But Tenderdash should not be putting a validator in that position in the first place, and on its own the Tenderdash side still turns the collision into a restart-proof panic loop.

Possible Solution

  1. Do not propose after a handover that was not caughtUp. SwitchToConsensus already receives the flag. Suppress EnterPropose's proposal creation (voting can stay) while the node is catching up — e.g. until it has committed a block through consensus and no connected peer reports a height above its own, or until maxPeerHeight <= LastBlockHeight. Whatever signal is used has to be robust against a single peer advertising a bogus height to silence a validator; tying it to the block-sync handover (a state the node only enters while provably behind) keeps that window narrow.
  2. Prefer staying in block sync while behind. The 10-minute stopStalledTooLong backstop hands over a node that is thousands of blocks behind; consensus catch-up from there is one block per gossip cycle (days), and every height where the node was the genuine round-0 proposer is a collision opportunity. Handing over only when maxPeerHeight <= height (and keeping the backstop only for the case where no peer reports a higher height) would avoid the whole class.
  3. Defensive: do not SaveBlock in the block-sync applier before ApplyBlock succeeds (or make the handshake re-process with a fresh app context), so that an app-side failure does not leave the store ahead of the app.
  4. Optional / app-side: the app could refuse to propose a chain-lock update that is absurdly far ahead of the last committed core height / block time — but a legitimate restart after a long halt also needs big jumps, so this is a policy decision rather than the fix.

Steps to Reproduce (for bugs)

  1. Take a validator whose proTxHash is in historical quorums (i.e. one that has been a member for a long time) and re-sync it from genesis with peers that do not serve blocks (e.g. seed nodes only), so block sync stalls and hands over to consensus at some height H far below the tip.
  2. Wait for the node to become proposer at H (it will, within at most one quorum's worth of rounds); observe PrepareProposal with a chain-lock update to the current Core tip.
  3. Give it a peer that serves blocks. Once the stale round expires, H is caught up; at H+1 (or the next height where the node was the genuine round-0 proposer) it proposes again and the genuine round-0 commit arrives: panic wrong Block.Header.AppHash … (or ProcessProposal abci method: … received a process proposal request twice with different hash with an app that refuses the second block).
  4. Restart: the WAL replay reproduces the same panic on every start (or, after a Tenderdash-only restart, error on replay in the handshake). Only a reset recovers.

Context

Operators see an app-hash mismatch that looks like a Drive/state problem ("did you reset Tendermint without resetting your application's data?") and reset everything. The actual cause — five seed-only peers at the moment block sync timed out — is invisible in the error. Any long-lived evonode re-syncing from scratch with a thin peer set can hit this.

Your Environment

  • Version used: observed on 1.6.0 (platform 4.1.0); 1.7.0 changes when the handover happens but not what follows
  • Operating System and version: Linux, dashmate
  • ABCI app: Drive (dashpay/platform, drive-abci 4.1.x)

Have you tried the latest version: yes (1.7.0 analysed; the consensus paths involved are unchanged apart from the peer-eviction addition, see #1414)

Logs

PrepareProposal  h=24175 r=1  propose chain lock update to height 2523755 at block 24175

(mainnet, 2026-08-18 06:16 UTC; real block 24175: core height 2157967, app hash 402A8119…, proposer EE22F5BC…; block 24176 proposer F1616A59…)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions