From 375afcc79e6cb9c33745fcc84aa6806e3e988f53 Mon Sep 17 00:00:00 2001 From: Tanmay Date: Tue, 25 Aug 2026 02:04:42 -0400 Subject: [PATCH 1/4] test: assert vesting-account creation is rejected via group proposal Add a unit test for AuthzLimiterDecorator and an e2e test proving that MsgCreateVestingAccount cannot be smuggled onto the chain by wrapping it in a group proposal. The ante decorator inspects group.MsgSubmitProposal inner messages, so the tx is rejected before the group module runs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y9YpkFxXRRB1DES7ecggND --- app/ante/authz_test.go | 151 ++++++++++++++++++ cmd/zetae2e/local/local.go | 1 + e2e/e2etests/e2etests.go | 49 +++--- ...est_disallow_vesting_via_group_proposal.go | 49 ++++++ 4 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 app/ante/authz_test.go create mode 100644 e2e/e2etests/test_disallow_vesting_via_group_proposal.go diff --git a/app/ante/authz_test.go b/app/ante/authz_test.go new file mode 100644 index 0000000000..615b8a0a80 --- /dev/null +++ b/app/ante/authz_test.go @@ -0,0 +1,151 @@ +package ante_test + +import ( + "math/rand" + "testing" + "time" + + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + vesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/authz" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/cosmos/cosmos-sdk/x/group" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/app" + "github.com/zeta-chain/node/app/ante" + serverconfig "github.com/zeta-chain/node/server/config" + "github.com/zeta-chain/node/testutil/sample" +) + +// disabledMsgs mirrors app.go's DisabledAuthzMsgs for the vesting entries: the three +// vesting-account creation messages must not be executable indirectly. +func disabledVestingMsgs() []string { + return []string{ + sdk.MsgTypeURL(&vesting.MsgCreateVestingAccount{}), + sdk.MsgTypeURL(&vesting.MsgCreatePermanentLockedAccount{}), + sdk.MsgTypeURL(&vesting.MsgCreatePeriodicVestingAccount{}), + } +} + +// TestAuthzLimiter_AnteHandle verifies the decorator blocks disabled vesting msgs when they +// are wrapped in authz.MsgExec OR group.MsgSubmitProposal (including nested), and lets +// non-disabled messages through. +func TestAuthzLimiter_AnteHandle(t *testing.T) { + // ARRANGE + txConfig := app.MakeEncodingConfig(serverconfig.DefaultEVMChainID).TxConfig + + testPrivKey, testAddress := sample.PrivKeyAddressPair() + _, testAddress2 := sample.PrivKeyAddressPair() + _, policyAddress := sample.PrivKeyAddressPair() + + decorator := ante.NewAuthzLimiterDecorator(disabledVestingMsgs()...) + + createVestingMsg := vesting.NewMsgCreateVestingAccount( + testAddress, testAddress2, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + false, + ) + lockedAcctMsg := vesting.NewMsgCreatePermanentLockedAccount( + testAddress, testAddress2, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + ) + bankSend := banktypes.NewMsgSend( + testAddress, testAddress2, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + ) + + groupProposal := func(t *testing.T, inner sdk.Msg) sdk.Msg { + msg, err := group.NewMsgSubmitProposal( + policyAddress.String(), + []string{testAddress.String()}, + []sdk.Msg{inner}, + "", + group.Exec_EXEC_UNSPECIFIED, + "title", + "summary", + ) + require.NoError(t, err) + return msg + } + authzExec := func(inner sdk.Msg) sdk.Msg { + msg := authz.NewMsgExec(testAddress, []sdk.Msg{inner}) + return &msg + } + + tests := []struct { + name string + msg sdk.Msg + wantHasErr bool + wantErr string + }{ + { + "group proposal wrapping MsgCreateVestingAccount is blocked", + groupProposal(t, createVestingMsg), + true, + "found disabled msg type", + }, + { + "group proposal wrapping MsgCreatePermanentLockedAccount is blocked", + groupProposal(t, lockedAcctMsg), + true, + "found disabled msg type", + }, + { + "authz exec wrapping a vesting msg is blocked", + authzExec(lockedAcctMsg), + true, + "found disabled msg type", + }, + { + "authz exec wrapping a group proposal wrapping a vesting msg is blocked (nested)", + authzExec(groupProposal(t, lockedAcctMsg)), + true, + "found disabled msg type", + }, + { + "group proposal wrapping a non-disabled msg is allowed", + groupProposal(t, bankSend), + false, + "", + }, + { + "top-level vesting msg is not blocked here (VestingAccountDecorator owns that)", + lockedAcctMsg, + false, + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tx, err := simtestutil.GenSignedMockTx( + rand.New(rand.NewSource(time.Now().UnixNano())), + txConfig, + []sdk.Msg{tt.msg}, + sdk.NewCoins(), + simtestutil.DefaultGenTxGas, + "testing-chain-id", + []uint64{0}, + []uint64{0}, + testPrivKey, + ) + require.NoError(t, err) + + mmd := MockAnteHandler{} + ctx := sdk.Context{}.WithIsCheckTx(true) + + // ACT + _, err = decorator.AnteHandle(ctx, tx, false, mmd.AnteHandle) + + // ASSERT + if tt.wantHasErr { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index 3c7a393dbb..d73068ac0f 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -434,6 +434,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestCriticalAdminTransactionsName, e2etests.TestUpdateOperationalChainParamsName, e2etests.TestBurnFungibleModuleAssetName, + e2etests.TestDisallowVestingViaGroupProposalName, // Currently this test doesn't work with Anvil because pre-EIP1559 txs are not supported // See issue below for details diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index b6a403bb9b..c06159d5ab 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -206,27 +206,28 @@ const ( Admin tests Test admin functionalities */ - TestWhitelistERC20Name = "whitelist_erc20" - TestDepositEtherLiquidityCapName = "deposit_eth_liquidity_cap" - TestMigrateChainSupportName = "migrate_chain_support" - TestPauseZRC20Name = "pause_zrc20" - TestUpdateBytecodeZRC20Name = "update_bytecode_zrc20" - TestUpdateBytecodeConnectorName = "update_bytecode_connector" - TestRateLimiterName = "rate_limiter" - TestCriticalAdminTransactionsName = "critical_admin_transactions" - TestPauseERC20CustodyName = "pause_erc20_custody" - TestMigrateERC20CustodyFundsName = "migrate_erc20_custody_funds" - TestMigrateTSSName = "migrate_tss" - TestDrainTSSName = "drain_tss" - TestKeygenResetSigningName = "keygen_reset_signing" - TestSolanaWhitelistSPLName = "solana_whitelist_spl" - TestUpdateZRC20NameName = "update_zrc20" - TestZetaclientRestartHeightName = "zetaclient_restart_height" - TestZetaclientSignerOffsetName = "zetaclient_signer_offset" - TestZetaclientMinimumVersionName = "zetaclient_minimum_version" - TestUpdateOperationalChainParamsName = "update_operational_chain_params" - TestMigrateConnectorFundsName = "migrate_connector_funds" - TestBurnFungibleModuleAssetName = "burn_fungible_module_asset" + TestWhitelistERC20Name = "whitelist_erc20" + TestDepositEtherLiquidityCapName = "deposit_eth_liquidity_cap" + TestMigrateChainSupportName = "migrate_chain_support" + TestPauseZRC20Name = "pause_zrc20" + TestUpdateBytecodeZRC20Name = "update_bytecode_zrc20" + TestUpdateBytecodeConnectorName = "update_bytecode_connector" + TestRateLimiterName = "rate_limiter" + TestCriticalAdminTransactionsName = "critical_admin_transactions" + TestPauseERC20CustodyName = "pause_erc20_custody" + TestMigrateERC20CustodyFundsName = "migrate_erc20_custody_funds" + TestMigrateTSSName = "migrate_tss" + TestDrainTSSName = "drain_tss" + TestKeygenResetSigningName = "keygen_reset_signing" + TestSolanaWhitelistSPLName = "solana_whitelist_spl" + TestUpdateZRC20NameName = "update_zrc20" + TestZetaclientRestartHeightName = "zetaclient_restart_height" + TestZetaclientSignerOffsetName = "zetaclient_signer_offset" + TestZetaclientMinimumVersionName = "zetaclient_minimum_version" + TestUpdateOperationalChainParamsName = "update_operational_chain_params" + TestMigrateConnectorFundsName = "migrate_connector_funds" + TestBurnFungibleModuleAssetName = "burn_fungible_module_asset" + TestDisallowVestingViaGroupProposalName = "disallow_vesting_via_group_proposal" /* Operational tests @@ -1774,6 +1775,12 @@ var AllE2ETests = []runner.E2ETest{ TestBurnFungibleModuleAsset, runner.WithMinimumVersion("v33.0.0"), ), + runner.NewE2ETest( + TestDisallowVestingViaGroupProposalName, + "vesting account creation via a group proposal is rejected by ante", + []runner.ArgDefinition{}, + TestDisallowVestingViaGroupProposal, + ), /* Special tests */ diff --git a/e2e/e2etests/test_disallow_vesting_via_group_proposal.go b/e2e/e2etests/test_disallow_vesting_via_group_proposal.go new file mode 100644 index 0000000000..ca502304ae --- /dev/null +++ b/e2e/e2etests/test_disallow_vesting_via_group_proposal.go @@ -0,0 +1,49 @@ +package e2etests + +import ( + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + vesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/group" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + "github.com/zeta-chain/node/testutil/sample" +) + +// TestDisallowVestingViaGroupProposal verifies that a vesting-account creation message cannot be +// smuggled onto the chain by wrapping it in a group proposal. The AuthzLimiterDecorator inspects +// the inner messages of group.MsgSubmitProposal at ante time, so the tx is rejected before the +// group module ever runs (no real group needs to exist). +func TestDisallowVestingViaGroupProposal(r *runner.E2ERunner, _ []string) { + proposer := r.ZetaTxServer.MustGetAccountAddressFromName(utils.OperationalPolicyName) + + // a disabled message: create a vesting account + vestingMsg := vesting.NewMsgCreateVestingAccount( + sdk.MustAccAddressFromBech32(proposer), + sdk.MustAccAddressFromBech32(sample.AccAddress()), + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + false, + ) + + // wrap it in a group proposal; the group policy address is arbitrary since the tx is + // rejected at ante before the group module validates it exists + proposalMsg, err := group.NewMsgSubmitProposal( + sample.AccAddress(), + []string{proposer}, + []sdk.Msg{vestingMsg}, + "", + group.Exec_EXEC_UNSPECIFIED, + "create vesting account", + "attempt to create a vesting account via a group proposal", + ) + require.NoError(r, err) + + // broadcasting must fail: the ante decorator blocks the disabled inner message + _, err = r.ZetaTxServer.BroadcastTx(utils.OperationalPolicyName, proposalMsg) + require.Error(r, err) + require.ErrorContains(r, err, "found disabled msg type") +} From 9a93d0d4df83765bf742302b8ae032eb33262185 Mon Sep 17 00:00:00 2001 From: Tanmay Date: Tue, 25 Aug 2026 02:05:16 -0400 Subject: [PATCH 2/4] docs: add changelog entry for #4635 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y9YpkFxXRRB1DES7ecggND --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 03574c0ee6..9830e2acea 100644 --- a/changelog.md +++ b/changelog.md @@ -31,6 +31,7 @@ ### Tests * [4539](https://github.com/zeta-chain/node/pull/4539) - add support for `signet` name in the e2e config +* [4635](https://github.com/zeta-chain/node/pull/4635) - add unit and e2e tests asserting `MsgCreateVestingAccount` is rejected when wrapped in a group proposal ## Release ReForge - zetacored: v37.0.0 From dd63870afda40fb6f1b2eecfeaa4a79ef6fa7906 Mon Sep 17 00:00:00 2001 From: Tanmay Date: Tue, 25 Aug 2026 02:22:55 -0400 Subject: [PATCH 3/4] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20bind?= =?UTF-8?q?=20to=20real=20disabled-msg=20list,=20cover=20periodic=20+=20go?= =?UTF-8?q?v?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export app.DisabledAuthzMsgs() as the single source of truth for the ante HandlerOptions; the unit test now asserts against it so dropping a vesting entry from app.go fails the test (no more hardcoded mirror). - Add the MsgCreatePeriodicVestingAccount group-proposal case. - Document the gov gap with a case asserting gov.MsgSubmitProposal is not covered by AuthzLimiterDecorator (privileged, out of scope). - Add an in-process integration test running the real production ante handler end to end, proving the group-wrapped MsgCreateVestingAccount is rejected without needing localnet/Docker. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y9YpkFxXRRB1DES7ecggND --- app/ante/authz_integration_test.go | 94 ++++++++++++++++++++++++++++++ app/ante/authz_test.go | 69 ++++++++++++++++------ app/app.go | 38 +++++++----- 3 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 app/ante/authz_integration_test.go diff --git a/app/ante/authz_integration_test.go b/app/ante/authz_integration_test.go new file mode 100644 index 0000000000..a6a874ad61 --- /dev/null +++ b/app/ante/authz_integration_test.go @@ -0,0 +1,94 @@ +package ante_test + +import ( + "math/rand" + "testing" + "time" + + "cosmossdk.io/log" + dbm "github.com/cosmos/cosmos-db" + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + vesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + "github.com/cosmos/cosmos-sdk/x/group" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/app" + "github.com/zeta-chain/node/app/ante" + serverconfig "github.com/zeta-chain/node/server/config" + zetasimulation "github.com/zeta-chain/node/simulation" + "github.com/zeta-chain/node/testutil/sample" +) + +// TestProductionAnteHandler_RejectsVestingViaGroupProposal is an in-process integration check that +// wires the real app keepers into the full production ante chain (via app.DisabledAuthzMsgs) and +// runs a signed group.MsgSubmitProposal that wraps MsgCreateVestingAccount through it. It asserts +// the tx is rejected — the same outcome the localnet e2e test (disallow_vesting_via_group_proposal) +// verifies over the wire, but executable without Docker. +func TestProductionAnteHandler_RejectsVestingViaGroupProposal(t *testing.T) { + // ARRANGE + zetaApp, err := zetasimulation.NewSimApp( + log.NewNopLogger(), + dbm.NewMemDB(), + simtestutil.AppOptionsMap{}, + ) + require.NoError(t, err) + + encCfg := app.MakeEncodingConfig(serverconfig.DefaultEVMChainID) + + // build the ante handler exactly as app.New does, with the real disabled-msg list + anteHandler, err := ante.NewAnteHandler(ante.HandlerOptions{ + AccountKeeper: zetaApp.AccountKeeper, + BankKeeper: zetaApp.BankKeeper, + EvmKeeper: zetaApp.EvmKeeper, + FeeMarketKeeper: zetaApp.FeeMarketKeeper, + SignModeHandler: encCfg.TxConfig.SignModeHandler(), + SigGasConsumer: ante.DefaultSigVerificationGasConsumer, + MaxTxGasWanted: 0, + DisabledAuthzMsgs: app.DisabledAuthzMsgs(), + ObserverKeeper: zetaApp.ObserverKeeper, + }) + require.NoError(t, err) + + testPrivKey, testAddress := sample.PrivKeyAddressPair() + _, testAddress2 := sample.PrivKeyAddressPair() + + vestingMsg := vesting.NewMsgCreateVestingAccount( + testAddress, testAddress2, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + false, + ) + proposalMsg, err := group.NewMsgSubmitProposal( + sample.AccAddress(), + []string{testAddress.String()}, + []sdk.Msg{vestingMsg}, + "", + group.Exec_EXEC_UNSPECIFIED, + "title", + "summary", + ) + require.NoError(t, err) + + tx, err := simtestutil.GenSignedMockTx( + rand.New(rand.NewSource(1)), + encCfg.TxConfig, + []sdk.Msg{proposalMsg}, + sdk.NewCoins(), + simtestutil.DefaultGenTxGas, + "testing-chain-id", + []uint64{0}, + []uint64{0}, + testPrivKey, + ) + require.NoError(t, err) + + ctx := zetaApp.NewContext(true) + + // ACT + _, err = anteHandler(ctx, tx, false) + + // ASSERT: the ante decorator rejects the group-wrapped vesting-create before the group module runs + require.Error(t, err) + require.ErrorContains(t, err, "found disabled msg type") +} diff --git a/app/ante/authz_test.go b/app/ante/authz_test.go index 615b8a0a80..70eae54a03 100644 --- a/app/ante/authz_test.go +++ b/app/ante/authz_test.go @@ -10,6 +10,7 @@ import ( vesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" "github.com/cosmos/cosmos-sdk/x/authz" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" "github.com/cosmos/cosmos-sdk/x/group" "github.com/stretchr/testify/require" @@ -19,19 +20,20 @@ import ( "github.com/zeta-chain/node/testutil/sample" ) -// disabledMsgs mirrors app.go's DisabledAuthzMsgs for the vesting entries: the three -// vesting-account creation messages must not be executable indirectly. -func disabledVestingMsgs() []string { - return []string{ - sdk.MsgTypeURL(&vesting.MsgCreateVestingAccount{}), - sdk.MsgTypeURL(&vesting.MsgCreatePermanentLockedAccount{}), - sdk.MsgTypeURL(&vesting.MsgCreatePeriodicVestingAccount{}), - } +// TestDisabledAuthzMsgs_WiresVestingCreation asserts the production disabled-msg list actually +// contains the three vesting-account creation messages. This binds the ante test to app.go's real +// wiring: dropping any of them from app.DisabledAuthzMsgs would fail here. +func TestDisabledAuthzMsgs_WiresVestingCreation(t *testing.T) { + disabled := app.DisabledAuthzMsgs() + require.Contains(t, disabled, sdk.MsgTypeURL(&vesting.MsgCreateVestingAccount{})) + require.Contains(t, disabled, sdk.MsgTypeURL(&vesting.MsgCreatePermanentLockedAccount{})) + require.Contains(t, disabled, sdk.MsgTypeURL(&vesting.MsgCreatePeriodicVestingAccount{})) } -// TestAuthzLimiter_AnteHandle verifies the decorator blocks disabled vesting msgs when they -// are wrapped in authz.MsgExec OR group.MsgSubmitProposal (including nested), and lets -// non-disabled messages through. +// TestAuthzLimiter_AnteHandle verifies the decorator blocks the disabled vesting msgs when they are +// wrapped in authz.MsgExec OR group.MsgSubmitProposal (including nested), and lets non-disabled +// messages through. The decorator is built from app.DisabledAuthzMsgs() (the real production list) +// so the test cannot pass while the chain wiring regresses. func TestAuthzLimiter_AnteHandle(t *testing.T) { // ARRANGE txConfig := app.MakeEncodingConfig(serverconfig.DefaultEVMChainID).TxConfig @@ -40,7 +42,7 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { _, testAddress2 := sample.PrivKeyAddressPair() _, policyAddress := sample.PrivKeyAddressPair() - decorator := ante.NewAuthzLimiterDecorator(disabledVestingMsgs()...) + decorator := ante.NewAuthzLimiterDecorator(app.DisabledAuthzMsgs()...) createVestingMsg := vesting.NewMsgCreateVestingAccount( testAddress, testAddress2, @@ -48,10 +50,15 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), false, ) - lockedAcctMsg := vesting.NewMsgCreatePermanentLockedAccount( + permanentLockedMsg := vesting.NewMsgCreatePermanentLockedAccount( testAddress, testAddress2, sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), ) + periodicVestingMsg := vesting.NewMsgCreatePeriodicVestingAccount( + testAddress, testAddress2, + time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + nil, + ) bankSend := banktypes.NewMsgSend( testAddress, testAddress2, sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), @@ -74,6 +81,19 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { msg := authz.NewMsgExec(testAddress, []sdk.Msg{inner}) return &msg } + govProposal := func(t *testing.T, inner sdk.Msg) sdk.Msg { + msg, err := govv1.NewMsgSubmitProposal( + []sdk.Msg{inner}, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + testAddress.String(), + "", + "title", + "summary", + false, + ) + require.NoError(t, err) + return msg + } tests := []struct { name string @@ -89,19 +109,25 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { }, { "group proposal wrapping MsgCreatePermanentLockedAccount is blocked", - groupProposal(t, lockedAcctMsg), + groupProposal(t, permanentLockedMsg), + true, + "found disabled msg type", + }, + { + "group proposal wrapping MsgCreatePeriodicVestingAccount is blocked", + groupProposal(t, periodicVestingMsg), true, "found disabled msg type", }, { "authz exec wrapping a vesting msg is blocked", - authzExec(lockedAcctMsg), + authzExec(createVestingMsg), true, "found disabled msg type", }, { "authz exec wrapping a group proposal wrapping a vesting msg is blocked (nested)", - authzExec(groupProposal(t, lockedAcctMsg)), + authzExec(groupProposal(t, createVestingMsg)), true, "found disabled msg type", }, @@ -113,7 +139,16 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { }, { "top-level vesting msg is not blocked here (VestingAccountDecorator owns that)", - lockedAcctMsg, + createVestingMsg, + false, + "", + }, + { + // Documents a known gap: AuthzLimiterDecorator has no gov.MsgSubmitProposal case, so a + // governance proposal can still carry a vesting-create. That path is privileged (a passed + // gov vote, self-funded from the gov account), unlike the permissionless group path. + "gov proposal wrapping a vesting msg is NOT blocked by this decorator (gov is out of scope)", + govProposal(t, createVestingMsg), false, "", }, diff --git a/app/app.go b/app/app.go index ddef31546d..9f489ea351 100644 --- a/app/app.go +++ b/app/app.go @@ -259,6 +259,19 @@ type App struct { //transferModule transfer.AppModule } +// DisabledAuthzMsgs returns the message type URLs that may not be executed indirectly, i.e. wrapped +// inside an authz.MsgExec or a group.MsgSubmitProposal. This is the single source of truth consumed +// by the ante HandlerOptions; tests assert against it so the wiring cannot silently regress. +func DisabledAuthzMsgs() []string { + return []string{ + // MsgEthereumTx cannot be included on an authz.MsgExec msgs field + sdk.MsgTypeURL(&evmtypes.MsgEthereumTx{}), + sdk.MsgTypeURL(&vestingtypes.MsgCreateVestingAccount{}), + sdk.MsgTypeURL(&vestingtypes.MsgCreatePermanentLockedAccount{}), + sdk.MsgTypeURL(&vestingtypes.MsgCreatePeriodicVestingAccount{}), + } +} + // New returns a reference to an initialized ZetaApp. func New( logger log.Logger, @@ -779,22 +792,15 @@ func New( app.SetBeginBlocker(app.BeginBlocker) options := ante.HandlerOptions{ - AccountKeeper: app.AccountKeeper, - BankKeeper: app.BankKeeper, - EvmKeeper: app.EvmKeeper, - FeeMarketKeeper: app.FeeMarketKeeper, - SignModeHandler: encodingConfig.TxConfig.SignModeHandler(), - SigGasConsumer: ante.DefaultSigVerificationGasConsumer, - MaxTxGasWanted: TransactionGasLimit, - DisabledAuthzMsgs: []string{ - sdk.MsgTypeURL( - &evmtypes.MsgEthereumTx{}, - ), // disable the Msg types that cannot be included on an authz.MsgExec msgs field - sdk.MsgTypeURL(&vestingtypes.MsgCreateVestingAccount{}), - sdk.MsgTypeURL(&vestingtypes.MsgCreatePermanentLockedAccount{}), - sdk.MsgTypeURL(&vestingtypes.MsgCreatePeriodicVestingAccount{}), - }, - ObserverKeeper: app.ObserverKeeper, + AccountKeeper: app.AccountKeeper, + BankKeeper: app.BankKeeper, + EvmKeeper: app.EvmKeeper, + FeeMarketKeeper: app.FeeMarketKeeper, + SignModeHandler: encodingConfig.TxConfig.SignModeHandler(), + SigGasConsumer: ante.DefaultSigVerificationGasConsumer, + MaxTxGasWanted: TransactionGasLimit, + DisabledAuthzMsgs: DisabledAuthzMsgs(), + ObserverKeeper: app.ObserverKeeper, } anteHandler, err := ante.NewAnteHandler(options) From 49b8afe684ac30b7cb0d9e3e8b0c76aa9a05cd8e Mon Sep 17 00:00:00 2001 From: Tanmay Date: Tue, 25 Aug 2026 02:36:59 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(ante):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20share=20ante=20options,=20tag-gate,=20no-retry=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract AnteHandlerOptions so New and the integration test share one construction; dropping DisabledAuthzMsgs now fails the test - //go:build test on authz_integration_test.go so a bare `go test ./app/ante/...` no longer panics - add authz.MsgGrant coverage to the AuthzLimiter table - BroadcastTxWithoutRetry for deterministic-failure e2e paths; use it in the disallow-vesting-via-group-proposal test to skip the 25s retry loop Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y9YpkFxXRRB1DES7ecggND --- app/ante/authz_integration_test.go | 23 +++-- app/ante/authz_test.go | 15 ++++ app/app.go | 30 ++++--- ...est_disallow_vesting_via_group_proposal.go | 6 +- e2e/txserver/zeta_tx_server.go | 84 +++++++++++-------- 5 files changed, 98 insertions(+), 60 deletions(-) diff --git a/app/ante/authz_integration_test.go b/app/ante/authz_integration_test.go index a6a874ad61..42d84df98f 100644 --- a/app/ante/authz_integration_test.go +++ b/app/ante/authz_integration_test.go @@ -1,3 +1,9 @@ +//go:build test + +// This file builds a full in-process app via zetasimulation.NewSimApp, which requires the `test` +// build tag; without it the EVM configurator panics. The tag keeps a bare `go test ./app/ante/...` +// (no -tags) from taking the rest of the package down with a confusing panic. + package ante_test import ( @@ -36,18 +42,11 @@ func TestProductionAnteHandler_RejectsVestingViaGroupProposal(t *testing.T) { encCfg := app.MakeEncodingConfig(serverconfig.DefaultEVMChainID) - // build the ante handler exactly as app.New does, with the real disabled-msg list - anteHandler, err := ante.NewAnteHandler(ante.HandlerOptions{ - AccountKeeper: zetaApp.AccountKeeper, - BankKeeper: zetaApp.BankKeeper, - EvmKeeper: zetaApp.EvmKeeper, - FeeMarketKeeper: zetaApp.FeeMarketKeeper, - SignModeHandler: encCfg.TxConfig.SignModeHandler(), - SigGasConsumer: ante.DefaultSigVerificationGasConsumer, - MaxTxGasWanted: 0, - DisabledAuthzMsgs: app.DisabledAuthzMsgs(), - ObserverKeeper: zetaApp.ObserverKeeper, - }) + // build the ante handler from the exact options app.New installs, so dropping any field there + // (e.g. DisabledAuthzMsgs) fails this test rather than silently passing + anteHandler, err := ante.NewAnteHandler( + app.AnteHandlerOptions(zetaApp, encCfg.TxConfig.SignModeHandler()), + ) require.NoError(t, err) testPrivKey, testAddress := sample.PrivKeyAddressPair() diff --git a/app/ante/authz_test.go b/app/ante/authz_test.go index 70eae54a03..e7abad46b4 100644 --- a/app/ante/authz_test.go +++ b/app/ante/authz_test.go @@ -81,6 +81,15 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { msg := authz.NewMsgExec(testAddress, []sdk.Msg{inner}) return &msg } + authzGrant := func(t *testing.T, msgTypeURL string) sdk.Msg { + msg, err := authz.NewMsgGrant( + testAddress, testAddress2, + authz.NewGenericAuthorization(msgTypeURL), + nil, + ) + require.NoError(t, err) + return msg + } govProposal := func(t *testing.T, inner sdk.Msg) sdk.Msg { msg, err := govv1.NewMsgSubmitProposal( []sdk.Msg{inner}, @@ -125,6 +134,12 @@ func TestAuthzLimiter_AnteHandle(t *testing.T) { true, "found disabled msg type", }, + { + "authz grant of a vesting-create authorization is blocked", + authzGrant(t, sdk.MsgTypeURL(&vesting.MsgCreateVestingAccount{})), + true, + "found disabled msg type", + }, { "authz exec wrapping a group proposal wrapping a vesting msg is blocked (nested)", authzExec(groupProposal(t, createVestingMsg)), diff --git a/app/app.go b/app/app.go index 9f489ea351..70daadbc60 100644 --- a/app/app.go +++ b/app/app.go @@ -15,6 +15,7 @@ import ( "cosmossdk.io/x/evidence" evidencekeeper "cosmossdk.io/x/evidence/keeper" evidencetypes "cosmossdk.io/x/evidence/types" + txsigning "cosmossdk.io/x/tx/signing" "cosmossdk.io/x/upgrade" upgradekeeper "cosmossdk.io/x/upgrade/keeper" upgradetypes "cosmossdk.io/x/upgrade/types" @@ -272,6 +273,23 @@ func DisabledAuthzMsgs() []string { } } +// AnteHandlerOptions builds the ante HandlerOptions installed by New. It is exported so tests can +// exercise the exact options the app runs with, rather than rebuilding the struct by hand and +// drifting from the real wiring (e.g. dropping DisabledAuthzMsgs would then go unnoticed). +func AnteHandlerOptions(app *App, signModeHandler *txsigning.HandlerMap) ante.HandlerOptions { + return ante.HandlerOptions{ + AccountKeeper: app.AccountKeeper, + BankKeeper: app.BankKeeper, + EvmKeeper: app.EvmKeeper, + FeeMarketKeeper: app.FeeMarketKeeper, + SignModeHandler: signModeHandler, + SigGasConsumer: ante.DefaultSigVerificationGasConsumer, + MaxTxGasWanted: TransactionGasLimit, + DisabledAuthzMsgs: DisabledAuthzMsgs(), + ObserverKeeper: app.ObserverKeeper, + } +} + // New returns a reference to an initialized ZetaApp. func New( logger log.Logger, @@ -791,17 +809,7 @@ func New( app.SetPreBlocker(app.PreBlocker) app.SetBeginBlocker(app.BeginBlocker) - options := ante.HandlerOptions{ - AccountKeeper: app.AccountKeeper, - BankKeeper: app.BankKeeper, - EvmKeeper: app.EvmKeeper, - FeeMarketKeeper: app.FeeMarketKeeper, - SignModeHandler: encodingConfig.TxConfig.SignModeHandler(), - SigGasConsumer: ante.DefaultSigVerificationGasConsumer, - MaxTxGasWanted: TransactionGasLimit, - DisabledAuthzMsgs: DisabledAuthzMsgs(), - ObserverKeeper: app.ObserverKeeper, - } + options := AnteHandlerOptions(app, encodingConfig.TxConfig.SignModeHandler()) anteHandler, err := ante.NewAnteHandler(options) if err != nil { diff --git a/e2e/e2etests/test_disallow_vesting_via_group_proposal.go b/e2e/e2etests/test_disallow_vesting_via_group_proposal.go index ca502304ae..2a75533c9a 100644 --- a/e2e/e2etests/test_disallow_vesting_via_group_proposal.go +++ b/e2e/e2etests/test_disallow_vesting_via_group_proposal.go @@ -42,8 +42,10 @@ func TestDisallowVestingViaGroupProposal(r *runner.E2ERunner, _ []string) { ) require.NoError(r, err) - // broadcasting must fail: the ante decorator blocks the disabled inner message - _, err = r.ZetaTxServer.BroadcastTx(utils.OperationalPolicyName, proposalMsg) + // broadcasting must fail: the ante decorator blocks the disabled inner message. Use the + // no-retry variant since the rejection is deterministic — retrying only wastes ~25s and + // floods the log with the same error six times. + _, err = r.ZetaTxServer.BroadcastTxWithoutRetry(utils.OperationalPolicyName, proposalMsg) require.Error(r, err) require.ErrorContains(r, err, "found disabled msg type") } diff --git a/e2e/txserver/zeta_tx_server.go b/e2e/txserver/zeta_tx_server.go index 06d59e4479..e994bf32e6 100644 --- a/e2e/txserver/zeta_tx_server.go +++ b/e2e/txserver/zeta_tx_server.go @@ -275,50 +275,64 @@ func (zts *ZetaTxServer) BroadcastTx(account string, msgs ...sdktypes.Msg) (*sdk boWithMaxRetries := backoff.WithMaxRetries(bo, 5) return retry.DoTypedWithBackoff(func() (*sdktypes.TxResponse, error) { - // Find number and sequence and set it - acc, err := zts.clientCtx.Keyring.Key(account) - if err != nil { - return nil, err - } + return zts.broadcastOnce(account, msgs...) + }, boWithMaxRetries) +} - addr, err := acc.GetAddress() - if err != nil { - return nil, err - } +// BroadcastTxWithoutRetry broadcasts a tx exactly once, without the retry/backoff loop that +// BroadcastTx uses. Use it when the failure is deterministic by construction (e.g. a tx the ante +// handler is expected to reject): retrying only burns ~25s and floods the log with the same error. +func (zts *ZetaTxServer) BroadcastTxWithoutRetry(account string, msgs ...sdktypes.Msg) (*sdktypes.TxResponse, error) { + return zts.broadcastOnce(account, msgs...) +} - accountNumber, accountSeq, err := zts.clientCtx.AccountRetriever.GetAccountNumberSequence(zts.clientCtx, addr) - if err != nil { - return nil, retry.Retry(err) - } +// broadcastOnce performs a single build-sign-broadcast attempt. The retry.Retry wrapping on errors +// only matters when the result is fed to retry.DoTypedWithBackoff (as BroadcastTx does); callers +// that invoke this directly still get the underlying error via Error(). +func (zts *ZetaTxServer) broadcastOnce(account string, msgs ...sdktypes.Msg) (*sdktypes.TxResponse, error) { + // Find number and sequence and set it + acc, err := zts.clientCtx.Keyring.Key(account) + if err != nil { + return nil, err + } - zts.txFactory = zts.txFactory.WithAccountNumber(accountNumber).WithSequence(accountSeq) + addr, err := acc.GetAddress() + if err != nil { + return nil, err + } - txBuilder, err := zts.txFactory.BuildUnsignedTx(msgs...) - if err != nil { - return nil, retry.Retry(err) - } + accountNumber, accountSeq, err := zts.clientCtx.AccountRetriever.GetAccountNumberSequence(zts.clientCtx, addr) + if err != nil { + return nil, retry.Retry(err) + } - txBuilder.SetGasLimit(zts.txFactory.Gas()) - txBuilder.SetFeeAmount(zts.txFactory.Fees()) + zts.txFactory = zts.txFactory.WithAccountNumber(accountNumber).WithSequence(accountSeq) - // Sign tx - err = tx.Sign(zts.ctx, zts.txFactory, account, txBuilder, true) - if err != nil { - return nil, retry.Retry(err) - } + txBuilder, err := zts.txFactory.BuildUnsignedTx(msgs...) + if err != nil { + return nil, retry.Retry(err) + } - txBytes, err := zts.clientCtx.TxConfig.TxEncoder()(txBuilder.GetTx()) - if err != nil { - return nil, retry.Retry(err) - } + txBuilder.SetGasLimit(zts.txFactory.Gas()) + txBuilder.SetFeeAmount(zts.txFactory.Fees()) - result, err := broadcastWithBlockTimeout(zts, txBytes) - if err != nil { - return nil, retry.Retry(err) - } + // Sign tx + err = tx.Sign(zts.ctx, zts.txFactory, account, txBuilder, true) + if err != nil { + return nil, retry.Retry(err) + } - return result, nil - }, boWithMaxRetries) + txBytes, err := zts.clientCtx.TxConfig.TxEncoder()(txBuilder.GetTx()) + if err != nil { + return nil, retry.Retry(err) + } + + result, err := broadcastWithBlockTimeout(zts, txBytes) + if err != nil { + return nil, retry.Retry(err) + } + + return result, nil } func broadcastWithBlockTimeout(zts *ZetaTxServer, txBytes []byte) (*sdktypes.TxResponse, error) {