Skip to content

fix: F-2026-18793 | [Dual Defense] RecomputeBallotQuorum Applies 2/3+1 Even to TSS Ballots - #336

Merged
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18793
Aug 26, 2026
Merged

fix: F-2026-18793 | [Dual Defense] RecomputeBallotQuorum Applies 2/3+1 Even to TSS Ballots#336
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18793

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

F-2026-18793 — Low (Impact 1 / Likelihood 1)

Keeper.RecomputeBallotQuorum is the admin escape hatch for ballots stuck after a
validator-set change. It did two things unconditionally, with no branch on BallotType:

eligibleUVs, err := k.GetEligibleVoters(ctx)   // rebuild eligibles from the LIVE UV set
newThreshold = (2*newEligibleCount)/3 + 1      // always 2/3+1

That is only correct for ballots that were created that way. TSS key ballots are not
2/3 ballots — x/utss/keeper/voting.go:52:

// votesNeeded = number of participants in the tss process
// 100% quorum needed
votesNeeded := int64(len(existing.Participants))

with EligibleVoters = existing.Participants. So recompute on a TSS ballot dropped the
threshold from N to ⌊2N/3⌋+1 and replaced the participant list with whoever is a live
UV now — which can make validators who never took part in that DKLS run eligible to vote
on observing its key.

The eligible-set rewrite is the worse half. A wrong threshold is a counting error; a wrong
eligible set means the ballot no longer attests what it was created to attest.

Admin-gated (params.Admin != msg.Signer → reject), requires an explicit ballot_id, is
never called from any join/leave/status path, and does not auto-finalize — hence the Low
rating.

Type audit — only TSS_KEY diverges

type threshold at creation eligible source recompute correct?
INBOUND_TX (2N)/3+1 GetEligibleVoters
OUTBOUND_TX (2N)/3+1 GetEligibleVoters
FUND_MIGRATION (2N)/3+1 (voting.go:113-119) GetEligibleVoters (voting.go:113) ✅ reproduces creation exactly
TSS_KEY len(Participants) = 100% existing.Participants ❌ threshold and eligibles

The fix — default-deny allow-list

allow:  INBOUND_TX, OUTBOUND_TX, FUND_MIGRATION
refuse: TSS_KEY, UNSPECIFIED, and anything unrecognised

Structured as an explicit switch with a default: refusal, not as
if ballotType == TSS_KEY { refuse }.

Why default-deny rather than a TSS-specific check. READ_RESULT is not on this branch
— it lands with read-state. Under an allow-list it inherits a refusal rather than
silently inheriting a formula that may not apply to it. That is precisely how this bug
arose: the function was written for inbound/outbound and TSS quietly inherited it. Adding
a type to the allow-list is now a deliberate act that forces someone to check how that
type is created.

Why not Hacken's alternative (votesNeeded = len(preservedTSSParticipants)): it keeps
the eligible-set rewrite in play. And a TSS ballot whose participants changed is not a
quorum problem — the DKLS run itself is invalid. A recomputed threshold would manufacture
an attestation nobody made. The fix there is a fresh keygen round, not a lower bar.

Why FUND_MIGRATION is deliberately still allowed

Refusing it was considered and rejected. Two facts, both re-verified against this branch:

  1. Recompute is exact for it. VoteOnFundMigrationBallot (x/utss/keeper/voting.go:113-119)
    calls k.uvalidatorKeeper.GetEligibleVoters(ctx) and computes
    votesNeeded := (fundMigrationVotesNumerator*totalValidators)/fundMigrationVotesDenominator + 1
    with numerator = 2, denominator = 3 — identical to what recompute produces. The
    in-repo comment says so: "FundMigration uses 2/3 quorum like outbound observations".
  2. There is nothing behind it. InitiateFundMigration
    (x/utss/keeper/msg_initiate_fund_migration.go:56) blocks a replacement while one is
    PENDING — "pending migration already exists for chain %s (migration_id: %d, old_key: %s)"
    and grep -rn FailFundMigration over the repo returns no hits; Hacken rec 4 on
    F-2026-18142 is unimplemented.

Refusing recompute would therefore remove a hatch that currently works correctly and leave
a stuck fund migration with no way out. That is a regression, not a fix.

Tests

test/integration/uvalidator/recompute_ballot_quorum_test.go:

  • TestRecomputeBallotQuorum_TSSKeyBallot_Refused_StateUnchanged — Hacken rec 3. A TSS
    ballot with 4 of 5 votes and threshold 5; admin recompute must error, must not finalize,
    and must leave VotingThreshold and EligibleVoters byte-for-byte unchanged. Two
    validators are unbonded first so the live UV set genuinely differs (3 ≠ 5) — otherwise an
    unguarded recompute would be a no-op and the test would pass vacuously.
  • TestRecomputeBallotQuorum_AllowedTypes_StillRecompute — all three allow-listed types
    still recompute (5→2 eligible, threshold 4→2), asserted on the persisted ballot.
  • TestRecomputeBallotQuorum_UnrecognisedType_Refused — pins the default-deny for both
    UNSPECIFIED and an unmapped enum value standing in for a future type.
  • TestRecomputeBallotQuorum_StatusGuardRunsBeforeTypeGuard — the existing PENDING-only
    guard still runs first.
  • TestRecomputeBallotQuorum_ZeroEligible_AllowedExpires_RefusedDoesNot — the
    zero-eligible → EXPIRED path is unchanged for allowed types, and a refused type is not
    auto-expired as a side effect.

The refusal tests assert state before the error, deliberately: require.Error aborts
the subtest, so an error-first ordering would never reach the assertions that catch a
refusal which had already mutated state.

Mutation-verified. With the default: refusal changed to fall through to the allow
path, the TSS test fails on the state assertion, not on a missing error:

    recompute_ballot_quorum_test.go:372
    Error:      Not equal:
                expected: 5
                actual  : 3
    Messages:   TSS threshold must stay at 100% of participants; a recompute would have set it to 3
--- FAIL: TestRecomputeBallotQuorum_TSSKeyBallot_Refused_StateUnchanged

The other two refusal tests fail the same way (threshold 7→3; status PENDING→EXPIRED).

No existing test recomputed a TSS ballot, so nothing needed weakening — the one other
caller, test/integration/uexecutor/revert_stuck_inbound_test.go:282, uses INBOUND_TX.

Scope

Confined to RecomputeBallotQuorum and its doc comment — purely additive, no lines
removed. Does not touch ballot creation, GetEligibleVoters, the 2/3+1 formula, or the
expiry/ActiveBallotIDs machinery.

TSS_KEY ballots use 100% of the DKLS participant set, not 2/3+1; recompute
would rewrite both the threshold and the eligible voters. Gate on an explicit
default-deny allow-list (INBOUND_TX, OUTBOUND_TX, FUND_MIGRATION).
@0xNilesh
0xNilesh merged commit a8a997e into audit-fixes Aug 26, 2026
7 checks passed
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