fix: F-2026-18789 | [Dual Defense] First Fund Migration id=0 Is Unvotable - #345
Merged
Conversation
Ids came straight off collections.Sequence, whose first value is 0, while MsgVoteFundMigration.ValidateBasic rejects migration_id == 0 as "unset". The first migration on a fresh chain was therefore unvotable, never left PendingMigrations, and blocked every later migration for that chain. Allocate as sequence + 1: stored ids start at 1 and 0 stays reserved for "unset", so the ValidateBasic guard remains a real check. F-2026-18789
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.
Issue
InitiateFundMigrationallocated migration ids straight off acollections.Sequence, whose first value is0:while
MsgVoteFundMigration.ValidateBasictreats0as "unset" and rejects it (x/utss/types/msg_vote_fund_migration.go:18):So the first fund migration on any chain got
id=0and was unvotable — every universal validator's vote died inValidateBasicbefore the keeper was ever reached. The migration then:PendingMigrations(removal only happens after a finalized vote),InitiateFundMigrationrefuse every later migration for that chain ("pending migration already exists for chain ...").The fund-migration lane for that chain was bricked with no recovery short of an upgrade.
audit-fixesis a fresh-genesis branch, so this is deterministic on first use.Fix
Allocate ids as
sequence + 1(x/utss/keeper/msg_initiate_fund_migration.go):Stored ids now start at
1and0stays reserved for "unset", so theValidateBasicguard remains a real check rather than being loosened. This is a pure code fix — it holds regardless of genesis content or existing state, and needs no upgrade handler or state migration.Rejected alternatives: seeding the counter from genesis (depends on genesis being right), and allowing
id == 0throughValidateBasic(removes a real validation and makes0ambiguous with "unset").Genesis round-trips cleanly:
ExportGenesiswritesNextMigrationId.Peek(),InitGenesisrestores it, and the next allocation is still strictly greater than every id already in state.Tests
test/integration/utss/fund_migration_test.go:TestInitiateFundMigration_FirstMigrationIsVotable— three independent subtests over the very first migration a fresh chain allocates (the fixture asserts a virgin sequence first, so the case is genuinely the one that used to be unreachable):0, and the record really is stored under it;MsgVoteFundMigrationcarrying that id passesValidateBasic— the exact gate that bricked the lane;COMPLETED, leavesPendingMigrations, and a second migration for the same chain is then accepted under id2.TestVoteFundMigration_ZeroMigrationIdStaysRejected— pins the other half of the contract:0still means "unset" and is still rejected.TestFundMigrationIdsSurviveGenesisRoundTrip— export/import must not re-issue an id that is already taken.TestInitiateFundMigration/Successfully initiates fund migrationupdated fromuint64(0)touint64(1), and the "nothing left behind" check in the fee-rejection subtest changed fromGet(ctx, 0)(vacuous once0is never used) to a walk asserting the collection is empty.Mutation check
With the fix reverted and the tests kept, four of them fail — including on the real bug surface:
Restoring the fix returns all of them to PASS.
Full suite
go test -mod=readonly -p 1 -count=1 -tags="ledger test_ledger_mock test" ./x/... ./test/integration/...— 15ok, 0FAIL, exit 0: