fix: F-2026-18186 | [Dual Defense] Gasless New-Account Ante Path Persists Failed-Message Accounts - #351
Merged
Merged
Conversation
…dator for vote msgs F-2026-18186 rec 3: a fresh key could send a gasless vote, get its account committed by the ante cache, and have the message fail afterwards, leaving the row behind. Reject the five validator-only vote msgs before any account is written.
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.
Scope: remediation 3 only
Remediation 2 (the DoS half) was already fixed in
6a1403ef/ #316 (F-2026-18200), as collateral ofthat work.
AccountInitDecorator.verifySignatureForNewAccountalready enforces the signature count capvia
ante.CountSubKeys+params.TxSigLimitbefore any verification work, so the quadratic-multisigvector is closed. That code is untouched here; its regression test
(
TestAccountInitDecorator_EnforcesSignatureLimit) still passes.This PR implements remediation 3 only — the remaining state-bloat half.
The gap
AccountInitDecoratorwrites the account row and then short-circuits the rest of the ante chain:Nothing gated that on the signer being able to succeed. A fresh key sends a gasless
MsgVoteInbound, theante cache commits the account, and the msg server then rejects the vote because the signer is not a
bonded universal validator — but the account row persists. Repeatable with fresh keys, so state grows
unboundedly at zero cost.
The fix
Gate account creation on the signer already being a bonded universal validator, but only for the five
validator-only gasless message types:
uexecutortypes.MsgVoteInbound,MsgVoteOutbound,MsgVoteChainMetautsstypes.MsgVoteTssKeyProcess,MsgVoteFundMigrationEvery one of those msg servers already gates on
IsBondedUniversalValidator(VoteChainMetagates on thestrictly narrower eligible-voter set, of which bonded is a component), so a universal validator that can
legitimately vote is bonded and therefore already has an account. This path should never legitimately
create one for a vote, and rejecting costs nothing real.
The check runs before the signature verification, so a rejected tx costs the node one UV-set lookup and
one staking lookup instead of a full signature verification plus a state write.
Deliberately NOT gated
MsgExecutePayload/MsgMigrateUEA— permissionless by design. Creating an account for afirst-time universal user is the intended behaviour of this decorator; gating them would break real
users.
authz.MsgExecis not unwrapped. A universal validator submits its votes wrapped inauthz.MsgExec(
universalClient/pushsignerwrapWithAuthZ), and there the tx signer is the grantee hotkey while thevote's own signer — the address the msg server checks — is the granter. That hotkey is legitimately not a
universal validator itself, so unwrapping here would reject the real voting path. Only a top-level vote
message declares the universal validator as the tx signer, which is exactly the case this gate covers.
Wiring
HandlerOptionsgains aUValidatorKeeperfield (minimal local interface, matching howAccountKeeper/BankKeeperare declared in that file) plus itsValidate()nil-guard, wired fromapp.UvalidatorKeeperin
app/app.go.IsBondedUniversalValidatortakes the bech32 account address (it derives the operator address fromthose bytes itself) — the same string the vote msg servers hand it as
msg.Signer. Note it returns anerror, not
(false, nil), when the signer is absent from the universal validator set, and(false, nil)only for a registered-but-unbonded one; both branches are treated as a rejection.No proto changes, no state migration, no upgrade handler (fresh-genesis branch).
Recommendations considered and declined
nextafter account creationSigVerificationDecoratorwould then reject every legitimate gasless new-account txTests
app/ante/account_init_validator_gate_test.go(new):TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount— the finding itself, for all five votetypes. Asserts
ak.HasAccount == falsebefore asserting the error, so an aborting error assertioncannot mask a vacuous pass.
TestAccountInitDecorator_VoteFromRegisteredButUnbondedSigner— the(false, nil)branch.TestAccountInitDecorator_GateRunsBeforeSignatureVerification— pins the ordering.TestAccountInitDecorator_BondedValidatorVoteStillWorks— no regression, all five types, with andwithout a pre-existing account row.
TestAccountInitDecorator_PermissionlessGaslessMsgsUngated—MsgExecutePayload/MsgMigrateUEAstillcreate the account for a fresh signer, against a uvalidator keeper that rejects every address.
TestAccountInitDecorator_AuthzWrappedVoteUngated— the real UV hotkey voting path still works.The existing signer-binding and
TxSigLimittests are unchanged apart from the constructor signature.Mutation check
With the gate removed and the tests unchanged, exactly the three gate tests fail, and the account row
reappears:
Restoring the gate returns the package to green.