[pallets/exodus] Round-5 to 6 bitmap intersection desyncs the proof gate for dense validator sets; DKG restart loop on ordinary dropouts

Component: ghost-node @ 0162da46, pallets/exodus/src/lib.rs (round-5 to 6 transition, around line 2526; proof gate at lines 3039-3045).

Description: After the Merkle trees are built, the round-5 to 6 transition intersects the qualification bitmap with the largest agreeing consensus subset. The proof gate is then sized by count_ones() on that intersected bitmap (estimated_proof_count = state.count_ones::<usize>().next_power_of_two().trailing_zeros(), compared against dkg_package.bytes_len()). Any dropout that crosses a power-of-two boundary, for example 9 authorities dropping to 8 when one misses round 5, desyncs the gate from the already-built tree. The ceremony bricks into a restart loop. Crucially, this needs no sparsity: it fires on fully dense validator sets with an ordinary single dropout.

Distinction from Sands #93: that report covered only sparse production indexes (for example {0,150,300}). This variant reproduces on dense sets, which is the normal operating case, and was never covered. Not a re-report.

Impact: Deterministic DKG liveness break and restart loop on ordinary validator sets whenever one dropout crosses a power-of-two boundary. No fund impact; the threshold-signing pipeline cannot complete.

Reproduction: Executed against the actual repo primitives (temporary cargo test, passed, then reverted to keep the tree clean).

Severity: High. Reason: deterministic liveness kill of the threshold-signing pipeline, no attacker needed, triggered by routine operational conditions.

Recommendation: Size the gate by highest_bit() (the tree’s actual height input) instead of count_ones(); one line fixes both this variant and #93.

Verification: VERIFIED (executed PoC against repo primitives).

Thank you for your report!

Actually, I can see how it can happen:

  1. Round 5 is generated based on N participants.
  2. For some reason, one of them voted for a malicious hash - let’s say 0x000..000.
  3. The participants bitmap used by the intersect function doesn’t contain all participants.
  4. During Round 6, honest participants will try to push a proof for N participants, while there are only N-1.

Am I reading your report correctly? Just double-checking that I haven’t missed something.

In my opinion, using highest_bit could lead to more mistakes down the road. For example, if T-1 excluded participants are at the head of the bitmap, they’ll still be included in the computation, which isn’t accurate. Available options:

  1. Omit this check completely.
  2. Verify it against the latest VerifyingKeyConsensus participants (which should be the valid set).

Both options have pros and cons. For example, the second one will reject all participants who voted for another hash, which actually looks like it makes sense. Any ideas on this?

Yes, your reading is correct, with one addition: no malicious hash is needed. A plain dropout works too. If one authority is simply offline for round 5 (missed its submission, no malice), the intersected bitmap still shrinks from N to N-1, and if that crosses a power-of-two boundary the honest proofs built for N are rejected. Your malicious-vote scenario is a special case of the same desync; the mildest trigger is one node being down.

On highest_bit: agreed, your objection stands. Exclusions at the head of the bitmap would inflate the size estimate, so it is not a correct fix. Withdrawing that recommendation.

Of your two options, I prefer the second, understood as cryptographic verification of the submitted proof against the Merkle root in the latest VerifyingKeyConsensus, replacing the length pre-check rather than supplementing it. That eliminates the whole desync class (both the sparse-index variant and this dense one), because a proof either verifies against the committed root or it does not; no size estimate can drift.

Omitting the check entirely (option 1) also fixes liveness, but it widens the DoS surface: the length gate currently runs before signature verification in validate_unsigned, so removing it lets anyone submit arbitrary-length proofs that burn verification resources before failing. A middle path keeps a cheap constant sanity bound (proof length within the maximum tree depth) as spam protection while the consensus-root verification does the real gating. That keeps the DoS cost roughly where it is today and removes the drift.

One note on your option 2 as stated: rejecting participants who voted for another hash is indeed the sensible behavior here, since the intersected set is by construction the set that agreed. That matches what the gate was trying to approximate.

Hey @Rusdiwireng - it looks like the dev team has fixed the issue here you reported and then fixed it even more better. Could you confirm whether it’s resolved on your end now, or continue the thread if it still persists?

At a high level, all honest participants should now reach the same set of members - namely, those they consider to have passed all prior rounds successfully. That set is then part of the hash used as the key for voting.

Read d488bf9 and 1dc61fb. Round5 packages now carry the key_members bitmask, it feeds into the voting hash, and Round5 validation rejects non-qualified members, so the proof-size gate operates on the agreed member set instead of per-node intersects. That closes the desync root cause, including the plain-dropout case I added. This matches the direction I supported in my last reply. Confirmed resolved on my end.