diff --git a/app/ante/authz_integration_test.go b/app/ante/authz_integration_test.go new file mode 100644 index 0000000000..42d84df98f --- /dev/null +++ b/app/ante/authz_integration_test.go @@ -0,0 +1,93 @@ +//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 ( + "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 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() + _, 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 new file mode 100644 index 0000000000..e7abad46b4 --- /dev/null +++ b/app/ante/authz_test.go @@ -0,0 +1,201 @@ +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" + govv1 "github.com/cosmos/cosmos-sdk/x/gov/types/v1" + "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" +) + +// 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 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 + + testPrivKey, testAddress := sample.PrivKeyAddressPair() + _, testAddress2 := sample.PrivKeyAddressPair() + _, policyAddress := sample.PrivKeyAddressPair() + + decorator := ante.NewAuthzLimiterDecorator(app.DisabledAuthzMsgs()...) + + 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, + ) + 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)), + ) + + 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 + } + 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}, + sdk.NewCoins(sdk.NewInt64Coin("azeta", 100_000_000)), + testAddress.String(), + "", + "title", + "summary", + false, + ) + require.NoError(t, err) + 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, 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(createVestingMsg), + 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)), + 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)", + 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, + "", + }, + } + + 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/app/app.go b/app/app.go index ddef31546d..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" @@ -259,6 +260,36 @@ 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{}), + } +} + +// 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, @@ -778,24 +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: []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, - } + options := AnteHandlerOptions(app, encodingConfig.TxConfig.SignModeHandler()) anteHandler, err := ante.NewAnteHandler(options) if err != nil { 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 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..2a75533c9a --- /dev/null +++ b/e2e/e2etests/test_disallow_vesting_via_group_proposal.go @@ -0,0 +1,51 @@ +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. 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) {