Skip to content

fix: F-2026-18133 | [Dual Defense] Same-Block Validator Jailing Can Snapshot an Oversized Ballot Quorum - #347

Merged
0xNilesh merged 2 commits into
audit-fixesfrom
F-2026-18133
Aug 26, 2026
Merged

fix: F-2026-18133 | [Dual Defense] Same-Block Validator Jailing Can Snapshot an Oversized Ballot Quorum#347
0xNilesh merged 2 commits into
audit-fixesfrom
F-2026-18133

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

F-2026-18133 — Same-Block Validator Jailing Can Snapshot an Oversized Ballot Quorum

Severity: Info · Scope: Core · Base: audit-fixes (fresh-genesis — no upgrade handler / state migration needed)

Implements Recommendation 1 only. Recs 2 (auto-recompute on UV leave hooks) and 3 (ops monitoring) are deliberately not in scope here.

Root cause

GetEligibleVoters (x/uvalidator/keeper/validator.go) gated on UV lifecycle, IsBonded() and IsTombstoned() — but not on Jailed.

In the Cosmos SDK, jailing and unbonding are two different things that happen at two different times:

func (k Keeper) jailValidator(ctx context.Context, validator types.Validator) error {
	validator.Jailed = true
	k.SetValidator(ctx, validator)            // Status is NEVER touched
	return k.DeleteValidatorByPowerIndex(ctx, validator)
}

func (v Validator) IsBonded() bool { return v.GetStatus() == Bonded }

x/slashing jails in BeginBlock; the bonded → unbonding move happens in staking's EndBlocker. So for the entire tx-processing phase in between, a jailed validator is both Jailed == true and IsBonded() == true — and was therefore snapshotted into any ballot created during that block.

Reachability

The snapshot is frozen at ballot creation, and the threshold is (2*N)/3 + 1 (x/uvalidator/keeper/ballot.go:283) computed on the inflated N, while only N-1 signers can actually vote:

N threshold can still vote outcome
2 2 1 stranded
3 3 2 stranded
4+ fine

Recovery required an admin MsgRecomputeBallotQuorum; DefaultExpiryAfterBlocks = 100_000_000 means a stranded ballot effectively never ages out on its own.

Fix

One condition added to GetEligibleVoters, alongside the existing IsBonded() / IsTombstoned() gates:

if sv.IsJailed() {
	return false, nil
}

This tightens at ballot creation, so the threshold is computed on the smaller N — which improves liveness. It is the opposite direction from tightening vote admission against an already-frozen threshold, so it carries none of the F-2026-16991 deadlock coupling and needs no companion auto-recompute.

Existing ballots are unaffected: VoteOnBallotGetOrCreateBallot only consults the passed voter set when it creates a ballot, so already-frozen snapshots keep their membership and threshold. msgServer.VoteInbound / VoteOutbound admission is untouched, so a validator that was already inside a frozen snapshot before being jailed can still cast its vote there.

Blast radius — please review

Six call sites inherit the new predicate on this branch:

Call site Effect
x/uexecutor/keeper/voting.go:23 inbound ballots — threshold now computed on the reachable set
x/uexecutor/keeper/voting.go:87 outbound ballots — same
x/utss/keeper/voting.go:113 VoteOnFundMigrationBallot — same
x/utss/keeper/initiate_tss_key_process.go:145 TSS keygen / quorum-change participants — see below
x/utss/keeper/hooks.go:45 handleEligibleValidatorSetChange count gate
x/uvalidator/keeper/ballot.go:254 RecomputeBallotQuorum — admin escape hatch now also drops jailed validators

📌 For the TSS ownersGetTssParticipants uses GetEligibleVoters for KEYGEN and QUORUM_CHANGE, so a jailed validator will no longer receive a TSS key share. This is almost certainly the desired behaviour, but it is a change beyond ballots and you should be aware of it. Two notes that bound it:

  • TSS_PROCESS_REFRESH takes a different path (GetAllUniversalValidators filtered to ACTIVE / PENDING_LEAVE) and is not affected — a jailed validator still participates in a refresh. That asymmetry is pre-existing, not introduced here.
  • handleEligibleValidatorSetChange is only invoked from the uvalidator AfterValidatorAdded / AfterValidatorRemoved hooks, never from a staking jail event. Jailing on its own therefore does not trigger spontaneous TSS re-initiation; the smaller count is only observed the next time a UV is added or removed.

RecomputeBallotQuorum already marks a ballot EXPIRED when the recomputed eligible count reaches zero. Jailing every validator at once and then recomputing would now hit that branch, where previously the jailed set still counted. That matches how the same function already treats a fully unbonded / tombstoned set.

Correction to the finding text

The report lists seven inheriting call sites, including x/ucallback/keeper/voting.go:32. x/ucallback does not exist on this branch — it lives on the read-state line of development and will inherit the fix when it lands here. The report also cites Cosmos SDK v0.53.0, whereas this branch pins v0.50.10 (go.mod line 19); the mechanism is byte-for-byte identical in v0.50.10 (jailValidator sets Jailed and leaves Status alone; IsBonded() is GetStatus() == Bonded), so the finding stands as written.

Tests

test/integration/uvalidator/jailed_voter_quorum_test.go — jails a validator through staking's real Keeper.Jail (exactly what slashing calls in BeginBlock) without running the EndBlocker, then exercises the same block:

  • precondition — asserts the SDK behaviour the finding rests on: after Jail, the validator is IsJailed() and still Status == Bonded / IsBonded(). If this ever stops holding, the test says so.
  • jailed validator drops out of GetEligibleVoters (3 → 2)
  • the same holds for a PENDING_JOIN validator
  • a ballot created in that block through the real VoteOnInboundBallot path freezes VotingThreshold == 2 (on N-1) and does not contain the jailed validator
  • the two survivors can then actually finalize that ballot

Mutation check

With the IsJailed() gate disabled and the tests unchanged, 4 of the 5 subtests fail:

--- FAIL: TestGetEligibleVoters_ExcludesSameBlockJailedValidator/jailed_validator_is_excluded_from_the_eligible-voter_set
        Error: "[...3 UVs...]" should have 2 item(s), but has 3
        Messages: the jailed validator must drop out of the eligible set

--- FAIL: TestGetEligibleVoters_ExcludesSameBlockJailedValidator/PENDING_JOIN_validator_jailed_in_the_same_block_is_also_excluded
        Error: "[...3 UVs...]" should have 2 item(s), but has 3

--- FAIL: TestGetEligibleVoters_ExcludesSameBlockJailedValidator/ballot_created_in_the_same_block_freezes_a_threshold_computed_on_N-1
        Error: Not equal:
                expected: 2
                actual  : 3
        Messages: threshold must be (2*2)/3+1 = 2 on the N-1 survivors, not (2*3)/3+1 = 3 on the inflated N

--- FAIL: TestGetEligibleVoters_ExcludesSameBlockJailedValidator/the_surviving_validators_can_still_finalize_that_ballot
        Error: Should be true
        Messages: every non-jailed validator has now voted; if this is false the ballot is stranded
                  behind a threshold no reachable signer set can meet

The precondition subtest passes under mutation by design — it asserts SDK behaviour, not this fix.

…napshot an Oversized Ballot Quorum

Exclude jailed validators from GetEligibleVoters so a validator jailed in
BeginBlock is not snapshotted into ballots created later in the same block.
@0xNilesh
0xNilesh merged commit 861fca0 into audit-fixes Aug 26, 2026
4 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