Skip to content

fix: F-2026-18148 | [Dual Defense] Removed Universal Validator Retains ChainMeta Vote Authority - #348

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

fix: F-2026-18148 | [Dual Defense] Removed Universal Validator Retains ChainMeta Vote Authority#348
0xNilesh merged 2 commits into
audit-fixesfrom
F-2026-18148

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

F-2026-18148 — Removed Universal Validator Retains ChainMeta Vote Authority

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

Implements Recommendation 1 only. Recs 2 (revoke/expire voting AuthZ on removal), 3 (bound price/height), and 4 (admin reset for LastAppliedChainHeight) are deliberately not in scope — see Residual below.

The vector

MsgVoteChainMeta admitted via IsBondedUniversalValidator (x/uexecutor/keeper/msg_server.go:156), which is lifecycle-blind: any bonded, registered universal validator was accepted regardless of ACTIVE / PENDING_LEAVE / INACTIVE.

Admin removal moves a universal validator to PENDING_LEAVE while its stake stays bonded. AfterValidatorRemoved prunes that validator's ChainMeta rows, but it revokes neither its AuthZ grant nor its membership in the universal validator set. So the removed hotkey could turn straight around and reinsert a vote after the prune — and, because the median is recomputed over whoever is currently fresh, that reinserted vote immediately moved the oracle.

Fix

VoteChainMeta now gates on the same eligibility predicate that uvalidator uses when it snapshots a ballot's voters — lifecycle ACTIVE / PENDING_JOIN and bonded and not tombstoned — by calling GetEligibleVoters directly rather than re-deriving the checks, so ChainMeta admission cannot drift away from that definition.

The IsTombstonedUniversalValidator check is kept and moved ahead of the new gate so it retains its specific error message (and its "not present in the registered universal validators set" error for unregistered signers) instead of being shadowed into dead code.

Why tightening admission is safe here — and only here

ChainMeta is median-based, not ballot-based (x/uexecutor/keeper/chain_meta.go):

if age <= chainMetaVoteStalenessSeconds { fresh = append(fresh, ...) }   // 300s
medianPrice       := upperMedianUint64(fresh, ...)   // index len/2
medianChainHeight := upperMedianUint64(fresh, ...)

There is no CreateBallot, no snapshotted EligibleVoters, and no frozen VotingThreshold. Every vote recomputes the median over whichever votes are currently fresh, so a narrower admitted set cannot strand anything in flight.

Scope is deliberately narrow. VoteInbound (:83) and VoteOutbound (:123) are untouched, as are the utss vote entry points. Those are ballot-based with frozen thresholds — tightening their admission would strand in-flight ballots and reintroduce the F-2026-16991 deadlock. They belong to the parked F-2026-18192 / RC-13 work.

Residual after Rec 1 — accepted knowingly

From Hacken's own text: "The same MaxUint64 median outcome is already reachable by an ACTIVE UV casting the identical vote on the current two-UV topology; the incremental defect addressed here is incomplete revocation after removal."

Rec 1 closes only the removed-validator vector. An ACTIVE universal validator can still push a MaxUint64 price/height and stick LastAppliedChainHeight, leaving revert-outbound gas quotes unfunded. Rec 3 is what closes that, and it is deliberately not taken here.

chainMetaMinVotesForFirstWrite = 3 — bootstrap interaction

Asked and answered, with a test: yes, tightening admission can keep a small set below the bootstrap minimum — but it does not introduce a new class of problem.

len(fresh) counts fresh vote rows, and there is at most one row per validator. Bootstrap therefore needs three distinct validators voting within the 300s window. Tightening admission can only shrink the pool that can produce a row, so a topology with fewer than three eligible universal validators can never bootstrap the oracle. That was already true of any topology with fewer than three bonded universal validators; this gate simply makes lifecycle state count toward the same threshold.

Practically: a 3-UV set where one validator is removed (PENDING_LEAVE) or INACTIVE drops to 2 eligible voters and can no longer perform a cold-start bootstrap for a new observed chain. Already-bootstrapped chains (LastAppliedChainHeight > 0) are unaffected — they take the normal median-on-each-fresh-vote path, which has no minimum. The constant is not changed in this PR; the last subtest documents the behaviour so it is visible rather than surprising.

Tests

test/integration/uexecutor/vote_chain_meta_eligibility_test.go:

  • removed PENDING_LEAVE validator cannot reinsert a vote after the prune — the headline vector, end-to-end through AuthZ + the msg server. Five ACTIVE UVs vote (prices 100–500), the admin removes one via RemoveUniversalValidator, the test asserts the validator is in PENDING_LEAVE, still bonded, and pruned from ChainMeta — then the removed hotkey tries to reinsert a price of 250, chosen to sit between the surviving 200 and 300 so it would drag the recorded upper median from 300 down to 250.
  • INACTIVE but still-bonded validator is rejected
  • ACTIVE validator is still accepted
  • PENDING_JOIN validator is still accepted
  • fewer than three eligible validators cannot reach the bootstrap minimum

State assertions are ordered before the require.Error / require.Contains in every rejection subtest, so an aborting error assertion cannot mask a vote that actually landed.

Mutation check

With IsBondedUniversalValidator restored in place of the eligibility gate and the tests unchanged, 3 of the 5 subtests fail:

--- FAIL: TestVoteChainMeta_EligibilityGate/removed_PENDING_LEAVE_validator_cannot_reinsert_a_vote_after_the_prune
        Error: Not equal:
                expected: 0x12c
                actual  : 0xfa
        Messages: the median must still be computed over the four surviving votes only

--- FAIL: TestVoteChainMeta_EligibilityGate/INACTIVE_but_still-bonded_validator_is_rejected
        Error: Should be false
        Messages: an INACTIVE validator's vote must not create a ChainMeta entry

--- FAIL: TestVoteChainMeta_EligibilityGate/fewer_than_three_eligible_validators_cannot_reach_the_bootstrap_minimum
        Error: "[...3 valopers...]" should have 2 item(s), but has 3
        Messages: only the two eligible validators may hold a vote row

0x12c is 300 and 0xfa is 250 — under mutation the removed validator's vote lands and moves the median, which is precisely the finding.

The two positive-path subtests (ACTIVE / PENDING_JOIN still accepted) pass under mutation by design — they exist to prove the gate does not lock out legitimate voters.

Interaction with F-2026-18133

F-2026-18133 (separate PR) adds a Jailed exclusion inside GetEligibleVoters; this PR makes VoteChainMeta use GetEligibleVoters. Composed, a jailed validator also loses ChainMeta vote authority. Different files, no merge conflict — each change is correct without the other.

…s ChainMeta Vote Authority

Gate MsgVoteChainMeta on GetEligibleVoters (ACTIVE/PENDING_JOIN + bonded +
not tombstoned) so a removed, still-bonded validator cannot reinsert votes.
@0xNilesh
0xNilesh merged commit bd3c2d8 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