Skip to content

fix: F-2026-18186 | [Dual Defense] Gasless New-Account Ante Path Persists Failed-Message Accounts - #351

Merged
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18186
Aug 26, 2026
Merged

fix: F-2026-18186 | [Dual Defense] Gasless New-Account Ante Path Persists Failed-Message Accounts#351
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18186

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Scope: remediation 3 only

Remediation 2 (the DoS half) was already fixed in 6a1403ef / #316 (F-2026-18200), as collateral of
that work. AccountInitDecorator.verifySignatureForNewAccount already enforces the signature count cap
via ante.CountSubKeys + params.TxSigLimit before any verification work, so the quadratic-multisig
vector 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

AccountInitDecorator writes the account row and then short-circuits the rest of the ante chain:

acc := aid.ak.NewAccountWithAddress(ctx, newAccAddr)
acc.SetSequence(1)
aid.ak.SetAccount(ctx, acc)   // committed by the ante cache
return ctx, nil               // ...even when the message later fails

Nothing gated that on the signer being able to succeed. A fresh key sends a gasless MsgVoteInbound, the
ante 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, MsgVoteChainMeta
  • utsstypes.MsgVoteTssKeyProcess, MsgVoteFundMigration

Every one of those msg servers already gates on IsBondedUniversalValidator (VoteChainMeta gates on the
strictly 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 a
    first-time universal user is the intended behaviour of this decorator; gating them would break real
    users.
  • authz.MsgExec is not unwrapped. A universal validator submits its votes wrapped in authz.MsgExec
    (universalClient/pushsigner wrapWithAuthZ), and there the tx signer is the grantee hotkey while the
    vote'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

HandlerOptions gains a UValidatorKeeper field (minimal local interface, matching how AccountKeeper /
BankKeeper are declared in that file) plus its Validate() nil-guard, wired from app.UvalidatorKeeper
in app/app.go.

IsBondedUniversalValidator takes the bech32 account address (it derives the operator address from
those bytes itself) — the same string the vote msg servers hand it as msg.Signer. Note it returns an
error, 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

# Rec Call
1 Call next after account creation No — the early return exists because the account is created at sequence 1 while the tx signed sequence 0; the downstream SigVerificationDecorator would then reject every legitimate gasless new-account tx
2 Sig-count cap + signature gas Count cap already DONE in #316. The gas half is declined on purpose: gasless txs skip fee deduction entirely, so charging gas costs an attacker nothing — the count cap is what actually bounds the work
3 Gate on validator status This PR
4 Defer account creation until after execution No — restructures ante-cache semantics for a bounded state-growth issue; rec 3 removes the abusable half
5, 6 Split ante policies / mempool quotas Ops, not a chain-code change

Tests

app/ante/account_init_validator_gate_test.go (new):

  • TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount — the finding itself, for all five vote
    types. Asserts ak.HasAccount == false before asserting the error, so an aborting error assertion
    cannot mask a vacuous pass.
  • TestAccountInitDecorator_VoteFromRegisteredButUnbondedSigner — the (false, nil) branch.
  • TestAccountInitDecorator_GateRunsBeforeSignatureVerification — pins the ordering.
  • TestAccountInitDecorator_BondedValidatorVoteStillWorks — no regression, all five types, with and
    without a pre-existing account row.
  • TestAccountInitDecorator_PermissionlessGaslessMsgsUngatedMsgExecutePayload / MsgMigrateUEA still
    create 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 TxSigLimit tests 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:

INF account init decorator: new account created via gasless tx address=cosmos1vl80tq2qk37dgwfy9jd2h9gau0v9uwkzmzyeac
account_init_validator_gate_test.go:148:
        Error:      Should be false
        Test:       TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount/MsgVoteChainMeta
        Messages:   F-2026-18186: no account row may be persisted for a gasless vote from a non-validator signer

--- FAIL: TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount/{MsgVoteInbound,MsgVoteOutbound,MsgVoteChainMeta,MsgVoteTssKeyProcess,MsgVoteFundMigration}
--- FAIL: TestAccountInitDecorator_VoteFromRegisteredButUnbondedSigner
--- FAIL: TestAccountInitDecorator_GateRunsBeforeSignatureVerification
FAIL    github.com/pushchain/push-chain-node/app/ante

Restoring the gate returns the package to green.

…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.
@0xNilesh
0xNilesh merged commit c2a8e3f into audit-fixes Aug 26, 2026
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