You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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.
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.
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 → ApplyCommit → mustEnsureProcess → ProcessProposal(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#L43 → mustValidate 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-L81 → mustEnsureProcess panics — block_executor.go#L77-L80).
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 beforeApplyBlock (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 (syncStateAt → error on replay, replayer.go#L160-L178).
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
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.
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.
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.
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)
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.
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.
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).
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
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:
Mainnet facts (platform explorer): block 24175 — app hash
402A8119…, core-locked height 2,157,967, proposerEE22F5BC…; block 24176 — proposerF1616A59…. Proposers at 24171..24180 are strictly increasing proTxHashes (round-robin, one step per height).Mechanism
Handover while behind. In 1.6.0
WaitForSyncreturns 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 aftermaxSyncStall(10 min) (synchronizer.go#L414-L470) — it still hands over, andSwitchToConsensus(reactor.go#L446) only uses thecaughtUpflag to decide whether to replay the WAL. Nothing tells consensus "you are behind".Proposing at a historical height.
EnterProposeActionproposes wheneverisProposer(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)isproposer(H, 0)advanced byr, not persisted (height_proposer.go#L25, #L182-L195) — soproposer(H, r1) == proposer(H+1, r0). The node that proposed at (24175, r1) is thereforeF1616A59…, the genuine round-0 proposer of 24176. That guarantees what follows.The block it builds. The ABCI app (Drive) answers
PrepareProposalwith 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 (validateCoreChainLockonly requires it to be higher). The proposal is a 2024 block carrying a 2026 chain lock; the node prevotes it and rolls rounds alone.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
PrepareProposalimmediately. The genuine 24176 commit — also round 0, proposed in 2024 by this very node — arrives after that round expires →ApplyCommit→mustEnsureProcess→ProcessProposal(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#L43 →mustValidatepanics withwrong 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-L81 →mustEnsureProcesspanics — block_executor.go#L77-L80).Why restarts do not help.
catchupReplayre-dispatches the node's own proposal and block parts first (→ProcessProposalrebuilds 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.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 (syncStateAt→error 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_proposalnow 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
caughtUp.SwitchToConsensusalready receives the flag. SuppressEnterPropose'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 untilmaxPeerHeight <= 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.stopStalledTooLongbackstop 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 whenmaxPeerHeight <= height(and keeping the backstop only for the case where no peer reports a higher height) would avoid the whole class.SaveBlockin the block-sync applier beforeApplyBlocksucceeds (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.Steps to Reproduce (for bugs)
Hfar below the tip.H(it will, within at most one quorum's worth of rounds); observePrepareProposalwith a chain-lock update to the current Core tip.His caught up; atH+1(or the next height where the node was the genuine round-0 proposer) it proposes again and the genuine round-0 commit arrives: panicwrong Block.Header.AppHash …(orProcessProposal abci method: … received a process proposal request twice with different hashwith an app that refuses the second block).error on replayin 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
drive-abci4.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
(mainnet, 2026-08-18 06:16 UTC; real block 24175: core height 2157967, app hash 402A8119…, proposer EE22F5BC…; block 24176 proposer F1616A59…)