Skip to content

dash(replay): early-era (pre-V20) non-rotated quorum members — resolve the h=1081595 LLMQ_50_60 fold poison - #1277

Closed
frstrtr wants to merge 1 commit into
integrator/dashd-cut-header-backfill-canservefrom
integrator/dashd-cut-early-quorum-memberset
Closed

dash(replay): early-era (pre-V20) non-rotated quorum members — resolve the h=1081595 LLMQ_50_60 fold poison#1277
frstrtr wants to merge 1 commit into
integrator/dashd-cut-header-backfill-canservefrom
integrator/dashd-cut-early-quorum-memberset

Conversation

@frstrtr

@frstrtr frstrtr commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Draft. Do not merge. Stacked on #1276 (integrator/dashd-cut-header-backfill-canserve).

The poison (live-proven)

The daemonless full-DIP3 self-derive fold (dash-selfderive-hdrfix.log) folded 1028161..1081594 byte-clean (folded=53434, roots_matched=53434, payees_verified=34395, DIVERGED=none) — #1274 payee-DIP3 enforcement proven — then failed CLOSED at h=1081595 on the FIRST non-null early LLMQ_50_60 (llmqType=1) qfcommit:

fold FAILED at h=1081595 tx[1]: quorum-member resolver has no member set
for llmqType=1 quorumHash=0000000000000001... — failing closed

quorum_lane_refusals=53435 (every block).

Root cause (NOT commitment_is_null)

commitment_is_null() is already dashd CFinalCommitment::IsNull-faithful and unchanged. The h=1081595 commitment is genuinely non-null (41/50 signers, real quorumPublicKey / quorumVvecHash / sigs) — mainnet's first mined LLMQ_50_60 DKG era (May 2019) with 9 invalid members that dashd's HandleQuorumCommitment PoSe-punishes. A member set is required for MN-list parity.

The gap is member production below the V20 floor. QuorumReplayEngine::observe_block() hard-refuses every block under v20_floor (mainnet 1'987'776) — the CL-modifier era, DIP-0024 rotation and the merkleRootQuorums self-check are all post-V20 / Phase-2 — so the W4 lane produced no member set the whole early-DIP3 run and the W1 fold's punish pass fell to the fail-closed site.

Fix — faithful dashd pre-V20 ComputeQuorumMembers (verified vs dashpay/dash develop)

For a non-rotated DKG base block B (B % dkgInterval == 0) below the floor:

  • member list = GetListForBlock(B) — the list AS OF the base block, NO -8 work-block offset (the offset lives only in GetHashModifier's ChainLock lookup; ComputeQuorumMembers feeds the base-block list). Sourced as the W1 fold's just-proven DML at B (post_fold) = GetListForBlock(B) by construction.
  • modifier = ::SerializeHash(make_pair(llmqType, blockHash(B)))GetHashModifier's pre-V20 branch (DeploymentActiveAfter(pWorkBlockIndex, V20) is false), no ChainLock term. Byte-exact compute_quorum_modifier's CL-absent branch fed the BASE hash.
  • selection = CalculateQuorum(size, modifier) over confirmed+valid MNs (compute_nonrotated_members / eligible() == dashd CalculateScores).

Keyed by (llmqType, blockHash(B)) via the same m_height_by_hash the forward path uses, so members_for(type, quorumHash) resolves a commitment whose quorumHash == its DKG base block hash. Iterates the chainparams llmq list (still carries LLMQ_50_60, mined in this era), not the runtime-enabled set. Strictly below the floor — at/above it observe_block() stays authoritative and is never shadowed. Wired into the bridge's post_fold hook (covers both the replay-fold and mn-diff-store drivers).

Reward-safe by construction: a wrong member set re-hashes to the wrong merkleRootMNList and the fold's per-block committed-cbTx-root self-check refuses forward — never a bad mint.

Tests (red→green)

test_dash_replay_quorum_seam:

  • EarlyNonRotatedGateRefusesWithoutTheProducer — the poison gate: below the floor observe_block refuses and members_for is nullopt.
  • EarlyNonRotatedProducerResolvesMembersFaithfully — GREEN: the producer resolves the LLMQ_50_60 set, byte-identical to dashd's pre-V20 ComputeQuorumMembers, banned MNs excluded, and the pre-V20 modifier is distinct from the post-V20 (work-block-hash) form.
  • EarlyProducerIsANoOpAtOrAboveTheV20Floor — the below-floor scope.

test_dash_mn_state suite: 126 passed, 1 data-driven skip. Build BLS=REAL.

Live validation

Binary rebuilt; the self-derive run resumed from the existing capture (re-folds DIP3→1081595 from cached bodies). Acceptance: fold PAST h=1081595 with DIVERGED=none continuing and members resolving. (Full run to tip is ~days; not awaited here.)

…so the fold resolves a REAL LLMQ_50_60 commitment's PoSe punishes

The self-derive full-DIP3 fold folded 1028161..1081594 byte-clean
(folded=53434, roots_matched=53434) and then failed CLOSED at h=1081595
on the FIRST non-null early LLMQ_50_60 (llmqType=1) qfcommit:

  fold FAILED at h=1081595 tx[1]: quorum-member resolver has no member
  set for llmqType=1 quorumHash=0000000000000001... — failing closed

Root cause is NOT commitment_is_null(): it is already dashd
CFinalCommitment::IsNull-faithful and the h=1081595 commitment is
genuinely non-null (41/50 signers, real pubkey/vvecHash/sigs) — a real
DKG era (mainnet's first mined LLMQ_50_60, May 2019) whose 9
invalid-marked members dashd's HandleQuorumCommitment PoSe-punishes.
Resolving its member set is required for MN-list parity.

The gap is member PRODUCTION below the V20 floor. QuorumReplayEngine::
observe_block() refuses every block under v20_floor (mainnet 1'987'776)
— the CL modifier era, quarter-rotation and the merkleRootQuorums
self-check are all post-V20 / Phase-2 — so the W4 lane produced NO
member set the whole early-DIP3 run (quorum_lane_refusals counted one
per block) and the W1 fold's punish pass fell to the fail-closed site.

Fix: wire dashd's pre-V20 non-rotated ComputeQuorumMembers as a
below-floor PRODUCER. For a non-rotated DKG base block B (B % dkgInterval
== 0) below the floor, the member set is (dashpay/dash llmq/utils.cpp,
verified against develop):

  * member list = GetListForBlock(B) — the list AS OF the base block,
    NO -8 work-block offset (the offset lives only in GetHashModifier's
    ChainLock lookup; ComputeQuorumMembers feeds the base-block list).
    Sourced as the W1 fold's just-proven DML at B (post_fold), which IS
    GetListForBlock(B) by construction.
  * modifier    = ::SerializeHash(make_pair(llmqType, blockHash(B))) —
    GetHashModifier's pre-V20 branch (DeploymentActiveAfter(..,V20) is
    false), no ChainLock term. Byte-exact compute_quorum_modifier's
    CL-absent branch fed the BASE hash.
  * selection   = CalculateQuorum(size, modifier) over confirmed+valid
    MNs (compute_nonrotated_members / eligible() == CalculateScores).

Keyed by (llmqType, blockHash(B)) via the same m_height_by_hash the
forward path uses, so members_for(type, quorumHash) resolves a
commitment whose quorumHash == its DKG base block hash. Iterates the
CHAINPARAMS llmq list (still carries LLMQ_50_60, mined in this era), not
the runtime-enabled set. STRICTLY below the floor — at/above it
observe_block() stays authoritative and is never shadowed. Wired into
the bridge's post_fold hook (both the replay-fold and mn-diff-store
drivers). commitment_is_null() is UNCHANGED: a genuinely null early
commitment still skips before any member set is consulted.

Reward-safe by construction: a WRONG member set re-hashes to the wrong
merkleRootMNList and the fold's per-block committed-cbTx-root self-check
refuses forward — never a bad mint.

KAT (test_dash_replay_quorum_seam, red->green):
  * EarlyNonRotatedGateRefusesWithoutTheProducer — the poison gate:
    below the floor observe_block refuses and members_for is nullopt.
  * EarlyNonRotatedProducerResolvesMembersFaithfully — GREEN: the
    producer resolves the LLMQ_50_60 set, byte-identical to dashd's
    pre-V20 ComputeQuorumMembers, banned MNs excluded, and the pre-V20
    modifier is distinct from the post-V20 (work-block-hash) form.
  * EarlyProducerIsANoOpAtOrAboveTheV20Floor — the below-floor scope.
127-test suite green (1 data-driven skip).
@frstrtr

frstrtr commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #1293 (combined fetch+fold onion). This branch's head commit is present in #1293 by exact SHA (verified via triage). Closing to reconcile the draft backlog; the branch is preserved (not deleted) — reopen if #1293 changes course.

@frstrtr frstrtr closed this Aug 20, 2026
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.

1 participant