From 70168e0f9327555bc012ec87b6e5e90441005359 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Wed, 26 Aug 2026 22:21:28 +0530 Subject: [PATCH] fix(ante): gate gasless new-account creation on bonded universal validator 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. --- app/ante/account_init_decorator.go | 89 +++++- app/ante/account_init_decorator_test.go | 6 +- app/ante/account_init_signer_binding_test.go | 10 +- app/ante/account_init_validator_gate_test.go | 319 +++++++++++++++++++ app/ante/ante_cosmos.go | 4 +- app/ante/handler_options.go | 11 + app/app.go | 1 + 7 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 app/ante/account_init_validator_gate_test.go diff --git a/app/ante/account_init_decorator.go b/app/ante/account_init_decorator.go index b117c938c..2149dccf7 100644 --- a/app/ante/account_init_decorator.go +++ b/app/ante/account_init_decorator.go @@ -16,16 +16,55 @@ import ( "github.com/cosmos/cosmos-sdk/x/auth/ante" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" txpolicy "github.com/pushchain/push-chain-node/app/txpolicy" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" ) +// validatorOnlyGaslessMsgTypes is the subset of the gasless allowlist that only +// a bonded universal validator can ever execute successfully: every one of these +// msg servers gates on IsBondedUniversalValidator (VoteChainMeta gates on the +// strictly narrower eligible-voter set, of which bonded is a component). +// +// The remaining gasless types - MsgExecutePayload and MsgMigrateUEA - are +// deliberately absent: they are permissionless by design and creating an account +// for a first-time universal user is the intended behaviour of this decorator. +var validatorOnlyGaslessMsgTypes = map[string]struct{}{ + sdk.MsgTypeURL(&uexecutortypes.MsgVoteInbound{}): {}, + sdk.MsgTypeURL(&uexecutortypes.MsgVoteOutbound{}): {}, + sdk.MsgTypeURL(&uexecutortypes.MsgVoteChainMeta{}): {}, + sdk.MsgTypeURL(&utsstypes.MsgVoteTssKeyProcess{}): {}, + sdk.MsgTypeURL(&utsstypes.MsgVoteFundMigration{}): {}, +} + +// isValidatorOnlyGaslessTx reports whether tx carries at least one message that +// only a bonded universal validator can execute. +// +// authz.MsgExec is deliberately 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 one +// 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, and that is exactly the case this gate covers. +func isValidatorOnlyGaslessTx(tx sdk.Tx) bool { + for _, msg := range tx.GetMsgs() { + if _, ok := validatorOnlyGaslessMsgTypes[sdk.MsgTypeURL(msg)]; ok { + return true + } + } + return false +} + type AccountInitDecorator struct { ak AccountKeeper + uvk UValidatorKeeper signModeHandler *txsigning.HandlerMap } -func NewAccountInitDecorator(ak AccountKeeper, signModeHandler *txsigning.HandlerMap) AccountInitDecorator { +func NewAccountInitDecorator(ak AccountKeeper, uvk UValidatorKeeper, signModeHandler *txsigning.HandlerMap) AccountInitDecorator { return AccountInitDecorator{ ak: ak, + uvk: uvk, signModeHandler: signModeHandler, } } @@ -57,6 +96,27 @@ func (aid AccountInitDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate "address", sdk.AccAddress(newAccAddr).String(), "simulate", simulate, ) + // F-2026-18186: this decorator writes the account row and then returns + // without running the message, so the row survives even when the message + // later fails. For the validator-only vote messages that is a free, + // repeatable state-bloat primitive: a fresh key sends a gasless vote, the + // account is committed by the ante cache, and the msg server then rejects + // it for not being a bonded universal validator. + // + // Reject those before any account is created - and before the expensive + // signature verification below. A universal validator that can legitimately + // vote is bonded and therefore already has an account, so this path should + // never legitimately create one for a vote. + if isValidatorOnlyGaslessTx(tx) { + if err := aid.requireBondedUniversalValidator(ctx, newAccAddr); err != nil { + ctx.Logger().Debug("account init decorator: rejecting validator-only gasless tx from non-validator signer", + "address", sdk.AccAddress(newAccAddr).String(), + "error", err, + ) + return ctx, err + } + } + // if account does not exist on chain, bypass rest of ante chain here. // Perform signature verification on account number e and sequence number e instead. if err := aid.verifySignatureForNewAccount(ctx, tx, simulate); err != nil { @@ -82,6 +142,33 @@ func (aid AccountInitDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate return next(ctx, tx, simulate) } +// requireBondedUniversalValidator returns nil only when signer is a bonded +// universal validator. +// +// IsBondedUniversalValidator takes the bech32 ACCOUNT address (it derives the +// operator address from those bytes itself), which is the same string the vote +// msg servers hand it as msg.Signer. It returns an error - not (false, nil) - +// when the signer is absent from the universal validator set, so both branches +// have to be treated as a rejection; failing closed is correct here because the +// only thing being denied is the creation of an account row for a message that +// cannot succeed. +func (aid AccountInitDecorator) requireBondedUniversalValidator(ctx sdk.Context, signer sdk.AccAddress) error { + if aid.uvk == nil { + return errorsmod.Wrap(sdkerrors.ErrLogic, "uvalidator keeper not configured on account init decorator") + } + + bonded, err := aid.uvk.IsBondedUniversalValidator(ctx, signer.String()) + if err != nil { + return errorsmod.Wrapf(sdkerrors.ErrUnauthorized, + "signer %s may not create an account with a validator-only gasless message: %s", signer.String(), err.Error()) + } + if !bonded { + return errorsmod.Wrapf(sdkerrors.ErrUnauthorized, + "signer %s may not create an account with a validator-only gasless message: not a bonded universal validator", signer.String()) + } + return nil +} + func (aid AccountInitDecorator) verifySignatureForNewAccount(ctx sdk.Context, tx sdk.Tx, simulate bool) error { sigTx, ok := tx.(authsigning.Tx) if !ok { diff --git a/app/ante/account_init_decorator_test.go b/app/ante/account_init_decorator_test.go index 8b128431c..1d6715f7b 100644 --- a/app/ante/account_init_decorator_test.go +++ b/app/ante/account_init_decorator_test.go @@ -18,7 +18,7 @@ import ( // gasless message type list). func TestAccountInitDecorator_NonGaslessTxPassesThrough(t *testing.T) { ak := newMockAccountKeeperAnte(sdk.AccAddress([]byte("feeCollector"))) - aid := ante.NewAccountInitDecorator(ak, nil /*signModeHandler not needed for non-gasless*/) + aid := ante.NewAccountInitDecorator(ak, newMockUValidatorKeeperAnte(), nil /*signModeHandler not needed for non-gasless*/) // banktypes.MsgSend is not gasless. tx := mockFeeTx{ @@ -45,7 +45,7 @@ func TestAccountInitDecorator_GaslessTxExistingAccountPassesThrough(t *testing.T // Pre-register the account. ak.SetAccount(context.Background(), authtypes.NewBaseAccountWithAddress(existingAddr)) - aid := ante.NewAccountInitDecorator(ak, nil) + aid := ante.NewAccountInitDecorator(ak, newMockUValidatorKeeperAnte(), nil) // Use a non-authsigning tx — the decorator skips signature verification // for existing accounts only when it can parse signers. Since mockFeeTx doesn't @@ -76,7 +76,7 @@ func TestAccountInitDecorator_GaslessTxExistingAccountPassesThrough(t *testing.T // tx that does not implement authsigning.Tx is rejected with ErrTxDecode. func TestAccountInitDecorator_NonAuthSigningTxReturnsError(t *testing.T) { ak := newMockAccountKeeperAnte(sdk.AccAddress([]byte("feeCollector"))) - aid := ante.NewAccountInitDecorator(ak, nil) + aid := ante.NewAccountInitDecorator(ak, newMockUValidatorKeeperAnte(), nil) // MsgVoteInbound is gasless. tx := mockFeeTx{ diff --git a/app/ante/account_init_signer_binding_test.go b/app/ante/account_init_signer_binding_test.go index 4173e17f8..4ab2d7458 100644 --- a/app/ante/account_init_signer_binding_test.go +++ b/app/ante/account_init_signer_binding_test.go @@ -18,10 +18,12 @@ import ( "github.com/cosmos/cosmos-sdk/types/tx/signing" authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/cosmos/cosmos-sdk/x/authz" "github.com/pushchain/push-chain-node/app/ante" appparams "github.com/pushchain/push-chain-node/app/params" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" ) // uexecutorModuleEVMAddr is the EVM address of the uexecutor module account - @@ -39,6 +41,8 @@ func newSignerBindingEncodingConfig(t *testing.T) appparams.EncodingConfig { std.RegisterInterfaces(encCfg.InterfaceRegistry) authtypes.RegisterInterfaces(encCfg.InterfaceRegistry) uexecutortypes.RegisterInterfaces(encCfg.InterfaceRegistry) + utsstypes.RegisterInterfaces(encCfg.InterfaceRegistry) + authz.RegisterInterfaces(encCfg.InterfaceRegistry) return encCfg } @@ -141,7 +145,11 @@ func buildSignedTx(t *testing.T, encCfg appparams.EncodingConfig, msg sdk.Msg, d func newSignerBindingDecorator(t *testing.T, encCfg appparams.EncodingConfig) (ante.AccountInitDecorator, *mockAccountKeeperAnte) { t.Helper() ak := newMockAccountKeeperAnte(sdk.AccAddress([]byte("feeCollector"))) - return ante.NewAccountInitDecorator(ak, encCfg.TxConfig.SignModeHandler()), ak + // The uvalidator mock knows about nobody, so it rejects every address it is + // asked about. Every test in this file uses MsgExecutePayload / MsgMigrateUEA, + // which are deliberately NOT gated on validator status (F-2026-18186), so they + // must keep working against it. + return ante.NewAccountInitDecorator(ak, newMockUValidatorKeeperAnte(), encCfg.TxConfig.SignModeHandler()), ak } // TestAccountInitDecorator_RejectsAliasedModuleSigner is the regression test for diff --git a/app/ante/account_init_validator_gate_test.go b/app/ante/account_init_validator_gate_test.go new file mode 100644 index 000000000..cc13b1ae4 --- /dev/null +++ b/app/ante/account_init_validator_gate_test.go @@ -0,0 +1,319 @@ +package ante_test + +import ( + "context" + "fmt" + "testing" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/cosmos/cosmos-sdk/x/authz" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/app/ante" + appparams "github.com/pushchain/push-chain-node/app/params" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + utsstypes "github.com/pushchain/push-chain-node/x/utss/types" +) + +// --------------------------------------------------------------------------- +// mock uvalidator keeper +// --------------------------------------------------------------------------- + +// mockUValidatorKeeperAnte satisfies ante.UValidatorKeeper and mirrors the real +// keeper's return shape, which matters: x/uvalidator's +// IsBondedUniversalValidator returns an ERROR (not (false, nil)) for an address +// that is absent from the universal validator set, and (false, nil) only for a +// registered-but-unbonded one. Both have to be treated as a rejection. +type mockUValidatorKeeperAnte struct { + // registered maps bech32 account address -> bonded. + registered map[string]bool +} + +func newMockUValidatorKeeperAnte(bonded ...sdk.AccAddress) *mockUValidatorKeeperAnte { + m := &mockUValidatorKeeperAnte{registered: map[string]bool{}} + for _, addr := range bonded { + m.registered[addr.String()] = true + } + return m +} + +// withUnbonded registers an address that is in the universal validator set but +// whose stake is not bonded - the (false, nil) branch of the real keeper. +func (m *mockUValidatorKeeperAnte) withUnbonded(addr sdk.AccAddress) *mockUValidatorKeeperAnte { + m.registered[addr.String()] = false + return m +} + +func (m *mockUValidatorKeeperAnte) IsBondedUniversalValidator(_ context.Context, universalValidator string) (bool, error) { + bonded, ok := m.registered[universalValidator] + if !ok { + return false, fmt.Errorf("validator %s not present in the registered universal validators set", universalValidator) + } + return bonded, nil +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// validatorOnlyMsgTypes are the five gasless message types that only a bonded +// universal validator can ever execute successfully. +var validatorOnlyMsgTypes = []string{ + "MsgVoteInbound", + "MsgVoteOutbound", + "MsgVoteChainMeta", + "MsgVoteTssKeyProcess", + "MsgVoteFundMigration", +} + +// voteMsgFor builds one of the five validator-only gasless messages with the +// given declared signer. +func voteMsgFor(t *testing.T, msgType string, signer sdk.AccAddress) sdk.Msg { + t.Helper() + switch msgType { + case "MsgVoteInbound": + return &uexecutortypes.MsgVoteInbound{Signer: signer.String()} + case "MsgVoteOutbound": + return &uexecutortypes.MsgVoteOutbound{Signer: signer.String(), TxId: "0xdead", UtxId: "0xbeef"} + case "MsgVoteChainMeta": + return &uexecutortypes.MsgVoteChainMeta{ + Signer: signer.String(), + ObservedChainId: "eip155:11155111", + Price: 1, + ChainHeight: 2, + } + case "MsgVoteTssKeyProcess": + return &utsstypes.MsgVoteTssKeyProcess{Signer: signer.String(), TssPubkey: "0xpub", KeyId: "key-1", ProcessId: 1} + case "MsgVoteFundMigration": + return &utsstypes.MsgVoteFundMigration{Signer: signer.String(), MigrationId: 1, TxHash: "0xdead", Success: true} + default: + t.Fatalf("unknown vote msg type %q", msgType) + return nil + } +} + +// newGateDecorator builds the decorator under test with a uvalidator mock that +// knows only about `bondedUVs`. +func newGateDecorator(t *testing.T, encCfg appparams.EncodingConfig, uvk *mockUValidatorKeeperAnte) (ante.AccountInitDecorator, *mockAccountKeeperAnte) { + t.Helper() + ak := newMockAccountKeeperAnte(sdk.AccAddress([]byte("feeCollector"))) + return ante.NewAccountInitDecorator(ak, uvk, encCfg.TxConfig.SignModeHandler()), ak +} + +// --------------------------------------------------------------------------- +// F-2026-18186 - the finding itself +// --------------------------------------------------------------------------- + +// TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount is the regression +// test for F-2026-18186 (remediation 3). +// +// AccountInitDecorator writes the account row and then returns WITHOUT running +// the message, so the row survives even though the message subsequently fails. +// For the five validator-only vote messages that is a free, repeatable +// state-bloat primitive: a fresh key sends a gasless vote, the ante cache +// commits the account, and the msg server then rejects the vote because the +// signer is not a bonded universal validator. +// +// The load-bearing assertion is HasAccount == false; it is asserted BEFORE the +// error assertion on purpose, because require.Error aborts the subtest and would +// otherwise mask a vacuous pass. +func TestAccountInitDecorator_VoteFromFreshSignerCreatesNoAccount(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + for _, msgType := range validatorOnlyMsgTypes { + t.Run(msgType, func(t *testing.T) { + key := secp256k1.GenPrivKey() + signer := sdk.AccAddress(key.PubKey().Address()) + + // Correctly signed by its own key: the tx is valid in every respect + // except that the signer is not a universal validator. + tx := buildSignedTx(t, encCfg, voteMsgFor(t, msgType, signer), signer, key) + + // The uvalidator mock knows about nobody: this signer is a fresh key. + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte()) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + nextCalled := false + _, err := aid.AnteHandle(ctx, tx, false, func(ctx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return ctx, nil + }) + + // THE finding: no account row may be written for a message that + // cannot succeed. Asserted first so a vacuous test cannot hide. + require.False(t, ak.HasAccount(context.Background(), signer), + "F-2026-18186: no account row may be persisted for a gasless vote from a non-validator signer") + + require.Error(t, err, "a gasless vote from a non-validator signer must be rejected") + require.True(t, sdkerrors.ErrUnauthorized.Is(err), "expected ErrUnauthorized, got: %v", err) + require.False(t, nextCalled, "the message must never reach execution") + }) + } +} + +// TestAccountInitDecorator_VoteFromRegisteredButUnbondedSigner covers the other +// rejection branch of the real keeper: an address that IS in the universal +// validator set but whose stake is not bonded returns (false, nil) rather than +// an error, and must be rejected just the same. +func TestAccountInitDecorator_VoteFromRegisteredButUnbondedSigner(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + key := secp256k1.GenPrivKey() + signer := sdk.AccAddress(key.PubKey().Address()) + tx := buildSignedTx(t, encCfg, voteMsgFor(t, "MsgVoteInbound", signer), signer, key) + + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte().withUnbonded(signer)) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + _, err := aid.AnteHandle(ctx, tx, false, emptyNext) + + require.False(t, ak.HasAccount(context.Background(), signer), + "a registered-but-unbonded signer must not get an account row either") + require.Error(t, err) + require.True(t, sdkerrors.ErrUnauthorized.Is(err), "expected ErrUnauthorized, got: %v", err) + require.Contains(t, err.Error(), "not a bonded universal validator") +} + +// TestAccountInitDecorator_GateRunsBeforeSignatureVerification pins the ordering. +// The gate is meant to reject before the expensive signature verification, so a +// vote tx that is BOTH signed by an unrelated key AND sent from a non-validator +// signer must come back with the validator rejection, not ErrInvalidPubKey. +func TestAccountInitDecorator_GateRunsBeforeSignatureVerification(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + attackerKey := secp256k1.GenPrivKey() + victimKey := secp256k1.GenPrivKey() + declaredSigner := sdk.AccAddress(victimKey.PubKey().Address()) + + tx := buildSignedTx(t, encCfg, voteMsgFor(t, "MsgVoteInbound", declaredSigner), declaredSigner, attackerKey) + + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte()) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + _, err := aid.AnteHandle(ctx, tx, false, emptyNext) + + require.False(t, ak.HasAccount(context.Background(), declaredSigner)) + require.Error(t, err) + require.True(t, sdkerrors.ErrUnauthorized.Is(err), + "the validator gate must fire before signature verification, got: %v", err) + require.False(t, sdkerrors.ErrInvalidPubKey.Is(err)) +} + +// --------------------------------------------------------------------------- +// no regression: the legitimate paths +// --------------------------------------------------------------------------- + +// TestAccountInitDecorator_BondedValidatorVoteStillWorks is the positive control +// for the gate: a bonded universal validator's vote passes it, for all five +// message types. +// +// Both sub-cases matter. In practice a bonded universal validator already has an +// account, so it takes the "existing account" branch and reaches next(); the +// no-account variant proves the gate itself is not what would reject it if it +// somehow did not. +func TestAccountInitDecorator_BondedValidatorVoteStillWorks(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + for _, msgType := range validatorOnlyMsgTypes { + t.Run(msgType+"/no_account_yet", func(t *testing.T) { + key := secp256k1.GenPrivKey() + signer := sdk.AccAddress(key.PubKey().Address()) + tx := buildSignedTx(t, encCfg, voteMsgFor(t, msgType, signer), signer, key) + + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte(signer)) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + _, err := aid.AnteHandle(ctx, tx, false, emptyNext) + require.NoError(t, err, "a bonded universal validator must not be rejected by the gate") + + acc := ak.GetAccount(context.Background(), signer) + require.NotNil(t, acc, "the bonded validator's account is still created") + require.Equal(t, uint64(1), acc.GetSequence()) + }) + + t.Run(msgType+"/existing_account", func(t *testing.T) { + key := secp256k1.GenPrivKey() + signer := sdk.AccAddress(key.PubKey().Address()) + tx := buildSignedTx(t, encCfg, voteMsgFor(t, msgType, signer), signer, key) + + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte(signer)) + ak.SetAccount(context.Background(), authtypes.NewBaseAccountWithAddress(signer)) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + nextCalled := false + _, err := aid.AnteHandle(ctx, tx, false, func(ctx sdk.Context, tx sdk.Tx, simulate bool) (sdk.Context, error) { + nextCalled = true + return ctx, nil + }) + require.NoError(t, err) + require.True(t, nextCalled, "an existing account must still fall through to the rest of the ante chain") + }) + } +} + +// TestAccountInitDecorator_PermissionlessGaslessMsgsUngated proves the scoping. +// MsgExecutePayload and MsgMigrateUEA are permissionless by design: a first-time +// universal user has no account and no validator status, and creating the account +// for them is the intended behaviour of this decorator. Gating them would break +// real users, so they must still work against a uvalidator keeper that rejects +// every address. +func TestAccountInitDecorator_PermissionlessGaslessMsgsUngated(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + for _, msgType := range []string{"MsgExecutePayload", "MsgMigrateUEA"} { + t.Run(msgType, func(t *testing.T) { + key := secp256k1.GenPrivKey() + signer := sdk.AccAddress(key.PubKey().Address()) + tx := buildSignedTx(t, encCfg, gaslessMsgFor(t, msgType, signer), signer, key) + + // Knows about nobody: it would reject the signer if it were consulted. + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte()) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + _, err := aid.AnteHandle(ctx, tx, false, emptyNext) + require.NoError(t, err, "%s is permissionless and must not be gated on validator status", msgType) + + acc := ak.GetAccount(context.Background(), signer) + require.NotNil(t, acc, "a first-time universal user must still get an account") + require.Equal(t, uint64(1), acc.GetSequence()) + }) + } +} + +// TestAccountInitDecorator_AuthzWrappedVoteUngated pins the deliberate decision +// not to unwrap authz.MsgExec. +// +// A universal validator submits its votes wrapped in authz.MsgExec +// (universalClient/pushsigner wrapWithAuthZ). There the TX signer is the grantee +// hotkey while the vote's own signer - the address the msg server checks - is the +// granter. The hotkey is legitimately not a universal validator, so unwrapping +// here would reject the real voting path. +func TestAccountInitDecorator_AuthzWrappedVoteUngated(t *testing.T) { + encCfg := newSignerBindingEncodingConfig(t) + + hotKey := secp256k1.GenPrivKey() + grantee := sdk.AccAddress(hotKey.PubKey().Address()) + granter := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + + inner, err := codectypes.NewAnyWithValue(voteMsgFor(t, "MsgVoteInbound", granter)) + require.NoError(t, err) + execMsg := &authz.MsgExec{Grantee: grantee.String(), Msgs: []*codectypes.Any{inner}} + + tx := buildSignedTx(t, encCfg, execMsg, grantee, hotKey) + + // Neither the hotkey nor the granter is known to the mock; only the absence of + // the gate can let this through. + aid, ak := newGateDecorator(t, encCfg, newMockUValidatorKeeperAnte()) + ctx := newAnteTestCtx(t, false).WithChainID(anteTestChainID) + + _, err = aid.AnteHandle(ctx, tx, false, emptyNext) + require.NoError(t, err, "the authz-wrapped voting path must keep working for a fresh grantee hotkey") + + acc := ak.GetAccount(context.Background(), grantee) + require.NotNil(t, acc, "the grantee hotkey must still get its account created") + require.Equal(t, uint64(1), acc.GetSequence()) +} diff --git a/app/ante/ante_cosmos.go b/app/ante/ante_cosmos.go index 509d6e9ca..86d1914b0 100755 --- a/app/ante/ante_cosmos.go +++ b/app/ante/ante_cosmos.go @@ -53,10 +53,12 @@ func NewCosmosAnteHandler(ctx sdk.Context, options HandlerOptions) sdk.AnteHandl // NewAccountInitDecorator must be called before all signature verification decorators and SetPubKeyDecorator // - this // 1. generates the account for the new accounts only for gasless transactions, + // refusing to do so for the validator-only vote messages, whose signer + // must already be a bonded universal validator (F-2026-18186), // 2. binds the declared signer to the signing key, enforces the signature // count limit and verifies the sig, and // 3. bypasses the rest of the ante chain - NewAccountInitDecorator(options.AccountKeeper, options.SignModeHandler), + NewAccountInitDecorator(options.AccountKeeper, options.UValidatorKeeper, options.SignModeHandler), // SetPubKeyDecorator must be called before all signature verification decorators ante.NewSetPubKeyDecorator(options.AccountKeeper), ante.NewValidateSigCountDecorator(options.AccountKeeper), diff --git a/app/ante/handler_options.go b/app/ante/handler_options.go index dd12c51fc..ed777dbe7 100755 --- a/app/ante/handler_options.go +++ b/app/ante/handler_options.go @@ -43,12 +43,20 @@ type AccountKeeper interface { // UnorderedTransactionsEnabled() bool } +// UValidatorKeeper is the minimal slice of the uvalidator keeper the ante chain +// needs. Declared locally, like AccountKeeper/BankKeeper above, so the ante +// package does not depend on a concrete keeper. +type UValidatorKeeper interface { + IsBondedUniversalValidator(ctx context.Context, universalValidator string) (bool, error) +} + // HandlerOptions defines the list of module keepers required to run the EVM // AnteHandler decorators. type HandlerOptions struct { Cdc codec.BinaryCodec AccountKeeper AccountKeeper BankKeeper BankKeeper + UValidatorKeeper UValidatorKeeper FeegrantKeeper ante.FeegrantKeeper ExtensionOptionChecker ante.ExtensionOptionChecker SignModeHandler *txsigning.HandlerMap @@ -77,6 +85,9 @@ func (options HandlerOptions) Validate() error { if options.BankKeeper == nil { return errorsmod.Wrap(errortypes.ErrLogic, "bank keeper is required for AnteHandler") } + if options.UValidatorKeeper == nil { + return errorsmod.Wrap(errortypes.ErrLogic, "uvalidator keeper is required for AnteHandler") + } if options.SigGasConsumer == nil { return errorsmod.Wrap(errortypes.ErrLogic, "signature gas consumer is required for AnteHandler") } diff --git a/app/app.go b/app/app.go index 298da10a9..cae98bf6d 100644 --- a/app/app.go +++ b/app/app.go @@ -1233,6 +1233,7 @@ func NewChainApp( Cdc: app.appCodec, AccountKeeper: app.AccountKeeper, BankKeeper: app.BankKeeper, + UValidatorKeeper: app.UvalidatorKeeper, FeegrantKeeper: app.FeeGrantKeeper, FeeMarketKeeper: app.FeeMarketKeeper, SignModeHandler: txConfig.SignModeHandler(),