fix: F-2026-18795 | [Dual Defense] Unbounded Active-Ballot Expiry Scan on Create + Disabled 100M Expiry - #337
Merged
Merged
Conversation
…m in EndBlock Removes the O(active-set) IAVL scan CreateBallot ran on every ballot creation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
F-2026-18795 — Unbounded Active-Ballot Expiry Scan on Create + Disabled 100M Expiry
Severity: Low (Impact 1 / Likelihood 1) · Base:
audit-fixesThe bug
CreateBallotunconditionally calledExpireBallotsBeforeHeightbefore creating anything(
x/uvalidator/keeper/ballot.go:37). That walked the entireActiveBallotIDsset and did aBallots.Getper entry.The cost was not an in-memory walk — it was n IAVL reads + n protobuf unmarshals, gas-metered, on
a consensus path, once per ballot creation. The reason it had to open every ballot is that
ActiveBallotIDsis keyed by ballot id (a hash), so the expiry height is not in the key; the onlyway to decide whether a ballot was due was to load it.
And it was guaranteed to find nothing. Expiry is deliberately disabled:
DefaultExpiryAfterBlocks = 100_000_000(~19 years at 6s blocks,x/uexecutor/types/constants.go). So every create paid O(|Active|) reads to expire exactly zeroballots. Cumulative cost across the set's lifetime is ~O(n²).
Live donut carries ~556 active ballots (decoded
AllActiveBallotIDsat height 22061950 — treatas "hundreds, unbounded", not an exact figure). The set only shrinks on quorum finalization, so
never-quorum stragglers are permanent residue while expiry stays disabled.
The fix (four parts)
1.
PendingByExpiryindex. Newcollections.KeySet[collections.Pair[int64, string]]keyed by(expiryHeight, ballotID), on the next free prefix in the module (7).collections.Pairorders by the first component, so the sweep ranges[0, currentHeight]viaNewPrefixUntilPairRangeand stops at the first row beyond it. Two wins: only due ballots arevisited (zero today), and
Ballots.Getis no longer needed to decide — the height is the key,so only ballots actually being expired are ever loaded.
2. Mirrored at every
ActiveBallotIDswriter. All six, verified by grep:ballot.goCreateBallotindexPendingballot.goDeleteBallotunindexPendingballot.goMarkBallotExpiredunindexPendingballot.goMarkBallotFinalizedunindexPendingkeeper.goInitGenesisvoting.goVoteOnBallot(new-ballot path)indexPendingDeleteBallotnow reads the record before removing it, because the index row is keyed by theballot's expiry height. It stays idempotent on absent IDs.
3. Sweep moved from
CreateBallotinto a newx/uvalidatorEndBlocker. Expiry should notdepend on inbound traffic arriving. The module had no EndBlock; it now has one, mirroring the
existing
BeginBlock/BeginBlockersplit.x/uvalidatorwas already listed inapp.ModuleManager.SetOrderEndBlockers(app/app.go:1108) — listing alone is not enough, themodule manager only calls modules that satisfy
appmodule.HasEndBlocker, so the interfaceassertion is declared in
module.goand an integration test drives the app's realEndBlockerandobserves the sweep to guard against a silently-never-called hook.
A failed sweep is logged, not returned: expiry must never halt the chain, and the work is
idempotent since anything missed stays in the index for the next block.
4.
MaxExpiriesPerBlock = 50. Bounds per-block work; leftovers stay in the index and are pickedup by the next block's sweep.
ExpireBallotsBeforeHeightkeeps its two-phase shape — collect while iterating, mutate only afterthe iterator is closed. Mutating a collection mid-iteration skips entries at best and panics at
worst.
Deliberately NOT changed
DefaultExpiryAfterBlocksstays at 100M. The constant's own comment says it waits on anescape hatch for stuck pending items (see F-2026-18801, F-2026-18796); re-enabling expiry without
one would strand inbounds. This change makes that future flip a one-line constant change rather
than a performance cliff — the index is what removes the cost of having expiry on at all.
ActiveBallotIDssize (Hacken rec 3). Rejecting creates past a threshold makeshonest inbounds unobservable once full, trading a gas-cost problem for a liveness problem.
consensus state for a Low.
No migration / upgrade handler
audit-fixestargets mainnet, which starts from a fresh genesis — the index is built as ballotsare created, and
InitGenesisrebuilds it on import (covered by a test). Same precedent as PR #317dropping its remove-group handler.
Tests
x/uvalidator/keeper/ballot_test.go,x/uvalidator/keeper/genesis_test.go,test/integration/uvalidator/ballot_voting_test.go:TestPendingByExpiryIndex_MirrorsEveryActiveSetWriter— index correctness at each writer;the finalize subtest also asserts the behavioural consequence of a leaked row (the sweep would
overwrite
PASSEDwithEXPIRED).TestExpireBallotsBeforeHeight_OnlyDueRowsAreTouched— past/boundary/future mix; not-yet-due rowsstay indexed and pending.
TestExpireBallotsBeforeHeight_CapsAtMaxExpiriesPerBlock— exactly 50 in one call, remaindersurvives, second call drains it.
TestExpireBallotsBeforeHeight_OrphanedIndexRow— a row whose ballot record is gone is dropped,not retried forever, and does not block the ballot queued behind it.
TestCreateBallot_DoesNotExpireOnCreate/_DoesNotExpireMultipleOldOnCreate— the two existing"expires on create" tests, rewritten for the new design: create expires nothing; the sweep does.
TestInitGenesisRebuildsPendingByExpiry/TestInitGenesisRejectsDanglingActiveBallotID— why nomigration is needed.
TestIntegration_UvalidatorEndBlockerRuns— driveschainApp.EndBlocker(the real module-managerchain) and observes the sweep; also asserts the module is in
OrderEndBlockersand implementsappmodule.HasEndBlocker.Assertions that catch a regression are placed first in each subtest:
requireaborts onfailure, so an ordering that checked something else first would never reach them.
Mutation-verified. Three mutations, each caught by the test that is supposed to catch it:
unindexPendingremoved from theMarkBallotFinalizedremoval site:MaxExpiriesPerBlockbound removed from the sweep:AppModule.EndBlockand theHasEndBlockerassertion removed (thenever-called-EndBlock regression this change is most exposed to):
Full suite green with all mutations reverted:
go test -tags="ledger test_ledger_mock test" ./x/... ./app/... ./test/integration/...-> 20 ok, 0 FAIL.