Skip to content

LLMQ rotation: partial-quarter snapshot reconstruction can cause duplicate members and single-block PoSe bans #7688

Description

@gades

Rotating quorum snapshots do not preserve partially filled quarters. Quarter selection can produce fewer than size / 4 members, while snapshot reconstruction unconditionally restores size / 4 members.

This can introduce duplicate identities within a quorum. DKG associates contributions with the first occurrence of each identity, leaving subsequent occurrences without contributions. When commitments containing these invalid duplicate positions are mined together, participating masternodes can receive enough PoSe penalties to be banned in a single block.

This report focuses on the loss of quarter lengths/boundaries. It is based on Dash Core source analysis and an executable deterministic model included below.

Expected behavior

  • Build → Snapshot → Reconstruct should preserve each quarter's exact ordered membership, including partial or empty quarters.
  • An assembled quorum should contain unique proTxHash identities.
  • Insufficient eligible membership should follow an explicit, deterministic shortage policy.
  • Participating masternodes should not be penalized for additional positions representing their own identity.

Actual behavior

All source references below point to Dash Core v23.1.8.

Trigger conditions

Define:

  • E = sortedCombinedMnsList.size(): eligible candidates, not total registered masternodes.
  • U = usedMNsCount: distinct retained members for the current quorum index.
  • Q = quarterSize.

With a stable, confirmed candidate pool containing the retained identities, the selection loop can produce a partial quarter when:

E < U + Q

The inequality is strict: at E == U + Q, the final required member still fits.

For initially consistent LLMQ_60_75 membership:

Q = 15
U <= 3 * Q = 45
E < U + Q <= 60

The relevant shortage window in which successful DKG may still be possible is therefore:

minSize <= E < size
50 <= E < 60

For example, E = 52 and U = 45 produce a new quarter containing only seven members.

An eligible pool of exactly 60 is a useful negative control for this shortage trigger. The included fixture selects 480 quarter positions across 32 indices from 60 candidates, so the cursor necessarily wraps. Nevertheless, the snapshot round-trip succeeds. This demonstrates that cursor wrap-around alone does not necessarily break reconstruction; it does not establish correctness for every possible wrap pattern.

A sufficiently populated network need not encounter the partial-quarter trigger, which explains why testing with larger pools can miss it. This is not a claim about observed Dash mainnet or testnet incidents.

Upstream LLMQ_DEVNET_DIP0024 already has size = 8, minSize = 6, and two active quorum indices. Its corresponding window is:

6 <= E < 8
E ∈ {6, 7}

The included model reproduces the round-trip failure for both cases without changing those quorum parameters: an empty quarter is reconstructed as two members at E = 6, and a one-member quarter is reconstructed as two members at E = 7.

Membership reconstruction

  1. BuildNewQuorumQuarterMembers() permits partial quarters through this condition:

    while (quarterQuorumMembers[i].size() < quarterSize &&
           (usedMNsCount + quarterQuorumMembers[i].size() < sortedCombinedMnsList.size()))
  2. BuildQuorumSnapshot() saves the active-member bitmap and skip list. CQuorumSnapshot does not encode individual quarter lengths or boundaries.

  3. GetQuorumQuarterMembersBySnapshot() always fills every reconstructed quarter to quarterSize.

    Both MODE_NO_SKIPPING and MODE_SKIPPING_ENTRIES use this fixed-length assumption. The defect described here is the missing quarter-length information, not a particular skipping mode.

  4. ComputeQuorumMembersByQuarterRotation() concatenates the reconstructed previous quarters and the new quarter without enforcing identity uniqueness.

Reconstructing seven original members as fifteen changes the quarter contents and cursor position for subsequent quorum indices. The resulting assembled quorums can contain multiple positions with the same proTxHash.

DKG validation and the path to a ban

  • CDKGSession::Init() creates one member object per position. Its mns.size() < params.minSize check counts positions, including duplicates. Meanwhile, membersMap.emplace(proTxHash, i) retains only the first index for a repeated identity.
  • Contribution processing credits the sender through that map. Later duplicate positions receive no contribution.
  • VerifyAndComplain() marks those positions bad.
  • CFinalCommitment::Verify() checks CountValidMembers() and CountSigners(). These helpers count set bits, not unique identities. Neither check explicitly rejects duplicate member identities. Invalid duplicate positions do not themselves satisfy the valid-member threshold; enough valid first occurrences must remain.
  • HandleQuorumCommitment() independently applies CalcPenalty(66) for every invalid position.

Rotating quorum indices share a common mining window. GetMineableCommitments() gathers commitments across the indices, allowing ready commitments to be included together starting at:

cycleStart + dkgMiningWindowStart

This does not require every non-null commitment to be available in that first block; unavailable commitments use the null fallback.

The penalty calculation is:

maxPenalty = max(100, totalRegisteredMasternodes)
penaltyPerInvalidPosition = floor(maxPenalty * 66 / 100)

With at most 100 registered masternodes, at most two invalid duplicate positions for the same identity in one block are sufficient for a ban. Starting from zero penalty at commitment processing:

0 → 66 → 100 → PoSe ban

A single invalid position is sufficient if the remaining penalty is already at least 34.

These positions may occur in one commitment or in different commitments. Score decay happens once before processing the block's transactions, so there is no intervening decay between commitments.

Consensus considerations

A fix changes quorum membership and/or the deterministic masternode state. It therefore needs an explicit consensus activation, such as a height-based or EHF-gated deployment, with historical behavior preserved.

Possible gated components include:

  1. Preserve actual quarter lengths or boundaries using a versioned snapshot encoding, with compatible handling of historical snapshots.
  2. Enforce unique identities when assembling new-rule quorums in ComputeQuorumMembersByQuarterRotation(), for example through deterministic deduplication with a defined shortage policy. Member order and the interpretation of signed bitsets must remain consistent.
  3. As defense in depth, under the new consensus rules, do not apply a penalty for an invalid position when the same identity has a valid position in that commitment.

All three behavioral changes must be gated. They are not safe retroactive modifications to already-mined commitments. The transition also needs to account for the three retained historical quarters and associated snapshot/member caches.

To reproduce

Run the Python 3 script below. It uses only the standard library.

The main fixture uses:

  • 52 distinct, confirmed, non-banned eligible identities.
  • 32 quorum indices and quarter size 15.
  • A fixed mocked score order for every cycle.
  • The implementation's unused-first/used-second ordering.
  • The shared cursor across quorum indices and existing skip-list encoding/decoding behavior.
  • Initially empty previous snapshots.

Actual candidate ordering changes with the hash modifier. The fixed ordering is a controlled fixture: exact duplicate counts and affected identities depend on ordering. However, once a seven-member quarter is produced, the 7 → 15 round-trip violation follows directly from the differing length conditions, regardless of which identities occupy those positions.

The fixture keeps membership unchanged. It does not simulate BLS, DKG message exchange, or feed inferred bans back into later selection.

Membership results:

Cycle, starting at 0 New-quarter lengths Assembled quorum lengths Quorums with duplicates Quarter round-trip failures
0 15 15 0 0
1 15 30 0 0
2 15 45 0 0
3 7 52 0 32
4 8, 9, 10, 14, 15 53, 54, 55, 59, 60 32 32

At cycle 3, quarter 0 changes from:

Selected:      [45, 46, 47, 48, 49, 50, 51]
Reconstructed: [45, 46, 47, 48, 49, 50, 51, 8, 9, 10, 11, 12, 13, 14, 23]

For the PoSe calculation, a repeated position means an occurrence after the first occurrence of that identity within the same quorum. Merely participating in multiple quorums is not counted as a duplicate.

Cycle 4 produces these per-identity totals:

Quorum subset Quorums Repeated positions Maximum repeated positions for one identity Identities with at least two repeated positions
All assembled quorums 32 330 9 52
Quorums meeting minSize, assuming first occurrences are valid 10 67 3 17

The second row is the relevant subset for the ban prediction. If those ten quorums produce valid commitments and the commitments are included together, the model predicts 17 identities reaching the PoSe ban threshold in one block, even starting from zero penalties.

This is a conditional prediction from the modeled membership and upstream penalty rules, not a claim that the script performs BLS signing or mines a Dash block.

The additional devnet-sized fixtures produce:

Eligible identities New-quarter length at cycle 3 Reconstructed length
6 0 2
7 1 2

These establish the snapshot invariant failure for both devnet shortage cases; they are not full-node PoSe-ban reproductions.

Executable Python model
from collections import Counter


def build(previous, eligible, quorum_count, quarter_size):
    used_by_quorum = [
        set().union(*(set(p[i]) for p in previous))
        for i in range(quorum_count)
    ]
    used_all = set().union(*used_by_quorum)

    # Fixed mocked score order; unused candidates precede used candidates.
    order = sorted(set(range(eligible)) - used_all) + sorted(used_all)
    quarters = [[] for _ in range(quorum_count)]
    idx = first_skipped = 0
    skips = []

    for i in range(quorum_count):
        used = set(used_by_quorum[i])
        original_used_count = len(used)
        initial_idx, updated = idx, False

        while (len(quarters[i]) < quarter_size and
               original_used_count + len(quarters[i]) < len(order)):
            candidate = order[idx]

            if candidate not in used:
                used.add(candidate)
                quarters[i].append(candidate)
                updated = True
            else:
                if first_skipped == 0:
                    first_skipped = idx
                    skips.append(idx)
                else:
                    skips.append(idx - first_skipped)

            idx = (idx + 1) % len(order)
            if idx == initial_idx:
                if not updated:
                    return [[] for _ in range(quorum_count)], None
                updated = False

    snapshot = ([i in used_all for i in range(eligible)], skips)
    return quarters, snapshot


def reconstruct(snapshot, quorum_count, quarter_size):
    active, skips = snapshot
    order = ([i for i, used in enumerate(active) if not used] +
             [i for i, used in enumerate(active) if used])

    decoded, first_skipped = [], 0
    for offset in skips:
        if first_skipped == 0:
            first_skipped = offset
            decoded.append(offset)
        else:
            decoded.append(first_skipped + offset)

    quarters = [[] for _ in range(quorum_count)]
    idx = skip_cursor = 0

    # An empty skip list also reproduces MODE_NO_SKIPPING.
    for quarter in quarters:
        while len(quarter) < quarter_size:
            if skip_cursor < len(decoded) and idx == decoded[skip_cursor]:
                skip_cursor += 1
            else:
                quarter.append(order[idx])
            idx = (idx + 1) % len(order)

    return quarters


def repeated_positions(quorums):
    counts = Counter()
    for members in quorums:
        seen = set()
        for identity in members:
            if identity in seen:
                counts[identity] += 1
            seen.add(identity)
    return counts


def simulate(eligible, quorum_count, quarter_size, cycles=5):
    snapshots, results = [], []

    for cycle in range(cycles):
        previous = [
            reconstruct(s, quorum_count, quarter_size)
            for s in snapshots[-3:]
        ]
        new, snapshot = build(previous, eligible, quorum_count, quarter_size)
        assert snapshot is not None

        members = [
            [m for p in previous for m in p[i]] + new[i]
            for i in range(quorum_count)
        ]
        replayed = reconstruct(snapshot, quorum_count, quarter_size)
        results.append((new, members, replayed))
        snapshots.append(snapshot)

    return results


results = simulate(52, 32, 15)

for cycle, (new, members, replayed) in enumerate(results):
    print(
        "cycle", cycle,
        "new", sorted(set(map(len, new))),
        "members", sorted(set(map(len, members))),
        "duplicate_quorums",
        sum(len(q) != len(set(q)) for q in members),
        "roundtrip_failures",
        sum(a != b for a, b in zip(new, replayed)),
    )

new, members, replayed = results[3]
print("cycle 3 original quarter 0:", new[0])
print("cycle 3 replayed quarter 0:", replayed[0])
assert all(len(q) == 7 for q in new)
assert all(a != b for a, b in zip(new, replayed))

members = results[4][1]

# Only the first position per identity is assumed valid; no BLS is simulated.
qualified = [q for q in members if len(set(q)) >= 50]
max_penalty = max(100, 52)  # 52 registered MNs in this fixture.
penalty = max_penalty * 66 // 100

for label, selected in [("all", members), ("minSize-qualified", qualified)]:
    counts = repeated_positions(selected)
    print(
        label,
        "quorums", len(selected),
        "repeated_positions", sum(counts.values()),
        "max_per_identity", max(counts.values(), default=0),
        "identities_with_2plus", sum(n >= 2 for n in counts.values()),
    )

counts = repeated_positions(qualified)
predicted_bans = sum(
    min(max_penalty, n * penalty) >= max_penalty
    for n in counts.values()
)
print("conditional same-block bans:", predicted_bans)
assert len(qualified) == 10 and predicted_bans == 17

# Negative control: exactly 60 eligible members can fill all quarters.
control = simulate(60, 32, 15)
assert all(
    new == replayed and all(len(q) == len(set(q)) for q in members)
    for new, members, replayed in control
)
print("eligible=60 negative control: passed")

# Upstream LLMQ_DEVNET_DIP0024: size=8, quarter=2, two quorum indices.
for eligible, selected_length in ((6, 0), (7, 1)):
    devnet = simulate(eligible, 2, 2)
    new, members, replayed = devnet[3]
    assert all(len(q) == selected_length for q in new)
    assert all(len(q) == 2 for q in replayed)
    assert new != replayed
    print(
        "devnet eligible=", eligible,
        "selected length=", selected_length,
        "reconstructed length=2",
    )

Suggested C++ regression test:

llmq_utils_tests/rotating_quorum_partial_quarter_snapshot_roundtrip

It should exercise the actual Build → Snapshot → Reconstruct path with 52 eligible members and assert exact ordered equality and unique assembled identities. Include 60 eligible members as a boundary control, both snapshot skipping modes, and the six- and seven-member devnet-sized fixtures.

Relevant existing test locations:

The existing functional test uses LLMQ_TEST_DIP0024 with size = minSize = 4. It would need a dedicated configuration or additional scenario to exercise the devnet 8/6 dimensions; changing only its masternode count to six or seven is not the same fixture.

A companion test should check duplicate-position penalty accumulation across multiple commitments in one block, both from zero and from a remaining nonzero penalty, with the corrected behavior gated by the consensus activation.

No reorganization, network outage, or rapid block production is required for the model reproduction.

System information

Source versions inspected:

  • Dash Core v23.1.8, commit 728f5055836c6d29806412fc7223ac8fe05af991.
  • Dash Core develop at commit 3a0c9383525ab16f12309989458deff9f533c8d9; the partial-quarter selection and fixed-length reconstruction behavior is still present.

Validation: source inspection and execution of the standalone Python 3 model above, including the 60-member negative control and both devnet-sized fixtures.

A Dash Core C++ regression test and full-node reproduction have not yet been run. This report does not claim an observed incident on Dash mainnet.

There is no GUI component or identified dependency on OS, CPU, or storage hardware.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions