Summary
During recovery of a wedged XDPoS v2 (HotStuff BFT) devnet (chainId 5151, 6-node masternode set), we found that orphaned/follower nodes still running an abandoned chain segment can indefinitely re-poison a freshly-recovered validator set by gossiping a stale, higher-round QuorumCert whose block/state no longer exists on the canonical chain. The recovered validators adopt that QC as highestQC (HotStuff picks the highest round), then attempt to propose on top of a block whose world state has been pruned/never re-executed, and wedge permanently with:
ERROR Failed to create mining context err="missing trie node <root> (path ) state <root> is not available, not found"
This turned an otherwise-mechanical rollback recovery into a multi-hour firefight: every time we rebuilt the validators from a clean snapshot, they re-acquired the stale QC from a follower we hadn't yet found, and re-wedged.
Environment
- Client:
XDC v2.7.0-devnet (XDC-v270, 48c4c4be, go1.26.4)
- Consensus: XDPoS v2 (round-based BFT), epoch 900, gap 450, V2 switch block 1800, period 2s
- Topology: 6 enrolled masternodes (4× v2.7.0 + 1× xone + 1× geth-fork), plus read-only follower nodes (besu / erigon / reth) syncing the same networkId for cross-client parity work
- Trigger: a producer crash left an uncommitted tail (blocks ~15242–15444) that only some nodes retained
What happens
- Network wedges; committed head across the honest majority is block B (e.g. 15241/14835 after rollback). An abandoned segment up to block P (15443/15444) survives only on a few nodes, carrying a QC at round R_p where
R_p > R_b.
- Operators rebuild the validator set from a clean snapshot at B (correct committed head).
- A still-running follower on the abandoned segment dials in to a validator (inbound connections are accepted even with
--nodiscover) and gossips SyncInfo containing the QC at R_p.
- The validator adopts
highestQC = QC(P @ R_p) because R_p is the highest round it has seen — even though it does not have block P's world state (pruned under --gcmode full, or the block was never re-executed).
- On its proposing turn the validator tries to build
P+1 on P, calls state.New(P.Root) → missing trie node → cannot create mining context → never proposes → round times out forever. Quorum can never be reached. Permanent wedge.
Observed evidence
# validator a, after clean rebuild to committed head, re-poisoned by a follower:
[yourturn] Yes, it's my turn based on parent block round=14399 \
ParentHash=0x3e3fae264...785859f ParentBlockNumber=15443
ERROR Failed to create mining context err="missing trie node a290079b... is not available, not found"
# no node on the canonical chain even has block 15443 anymore:
eth_getBlockByNumber(0x3c53) => null # on all rebuilt validators
# yet highestQC still references it (round 14399 > canonical rounds ~13700)
# the source, found via admin_peers on the wedged validator:
"name":"erigon/v3.4.2-..." @ 95.217.112.125 # follower on the abandoned chain
"name":"besu/v26.7-develop-..." # follower on the abandoned chain
Killing all orphaned followers (besu/erigon/reth) fleet-wide was the only way to stop re-poisoning; after that the rebuilt validators converged and resumed producing immediately.
Why this is a bug (not just operator error)
HotStuff safety says never commit against a higher QC — but here the node adopts a highestQC for a block it cannot materialize (no state), and there is no fallback: it neither (a) requests or re-executes the missing state from the QC-advertising peer, nor (b) refuses a highestQC whose block body/state is unavailable, nor (c) falls back to proposing on the highest block it can build on. The result is a liveness deadlock that is invisible to the operator (the node logs "Yes, it's my turn" and looks healthy) and self-perpetuating across restarts because the QC is re-gossiped.
Suggested fixes (any one breaks the deadlock)
- Guard
highestQC adoption: when receiving a QC/SyncInfo for a block whose header/body/state is not locally retrievable and cannot be fetched from the sender, do not silently set it as highestQC; log loudly and either fetch-or-reject.
- Proposer fallback: if
createMiningContext(highestQC.block) fails with a missing-state error, fall back to proposing on the highest block with available state (and surface a WARN), rather than looping "Not my turn / my turn" forever.
- Reject QCs for pruned/orphaned blocks older than the last committed block or off the canonical chain during SyncInfo processing.
- Operational: document that read-only followers must be stopped or reset before/during a coordinated validator rollback, because they re-serve abandoned QCs to
--nodiscover validators via inbound connections.
Impact
High for any coordinated recovery of a v2 network that also runs follower/observer nodes (multi-client parity fleets, explorers, RPC followers). A single un-reset follower can prevent the entire validator set from recovering, with no obvious error pointing at the follower.
Summary
During recovery of a wedged XDPoS v2 (HotStuff BFT) devnet (chainId 5151, 6-node masternode set), we found that orphaned/follower nodes still running an abandoned chain segment can indefinitely re-poison a freshly-recovered validator set by gossiping a stale, higher-round QuorumCert whose block/state no longer exists on the canonical chain. The recovered validators adopt that QC as
highestQC(HotStuff picks the highest round), then attempt to propose on top of a block whose world state has been pruned/never re-executed, and wedge permanently with:This turned an otherwise-mechanical rollback recovery into a multi-hour firefight: every time we rebuilt the validators from a clean snapshot, they re-acquired the stale QC from a follower we hadn't yet found, and re-wedged.
Environment
XDCv2.7.0-devnet (XDC-v270,48c4c4be, go1.26.4)What happens
R_p > R_b.--nodiscover) and gossips SyncInfo containing the QC atR_p.highestQC = QC(P @ R_p)becauseR_pis the highest round it has seen — even though it does not have blockP's world state (pruned under--gcmode full, or the block was never re-executed).P+1onP, callsstate.New(P.Root)→ missing trie node → cannot create mining context → never proposes → round times out forever. Quorum can never be reached. Permanent wedge.Observed evidence
Killing all orphaned followers (besu/erigon/reth) fleet-wide was the only way to stop re-poisoning; after that the rebuilt validators converged and resumed producing immediately.
Why this is a bug (not just operator error)
HotStuff safety says never commit against a higher QC — but here the node adopts a
highestQCfor a block it cannot materialize (no state), and there is no fallback: it neither (a) requests or re-executes the missing state from the QC-advertising peer, nor (b) refuses ahighestQCwhose block body/state is unavailable, nor (c) falls back to proposing on the highest block it can build on. The result is a liveness deadlock that is invisible to the operator (the node logs "Yes, it's my turn" and looks healthy) and self-perpetuating across restarts because the QC is re-gossiped.Suggested fixes (any one breaks the deadlock)
highestQCadoption: when receiving a QC/SyncInfo for a block whose header/body/state is not locally retrievable and cannot be fetched from the sender, do not silently set it ashighestQC; log loudly and either fetch-or-reject.createMiningContext(highestQC.block)fails with a missing-state error, fall back to proposing on the highest block with available state (and surface a WARN), rather than looping "Not my turn / my turn" forever.--nodiscovervalidators via inbound connections.Impact
High for any coordinated recovery of a v2 network that also runs follower/observer nodes (multi-client parity fleets, explorers, RPC followers). A single un-reset follower can prevent the entire validator set from recovering, with no obvious error pointing at the follower.