diff --git a/app/ante/ante_cosmos.go b/app/ante/ante_cosmos.go index 08be3f01..1f4768e5 100755 --- a/app/ante/ante_cosmos.go +++ b/app/ante/ante_cosmos.go @@ -25,6 +25,16 @@ func NewCosmosAnteHandler(ctx sdk.Context, options HandlerOptions) sdk.AnteHandl sdk.MsgTypeURL(&evmtypes.MsgEthereumTx{}), sdk.MsgTypeURL(&sdkvesting.MsgCreateVestingAccount{}), ), + // Vesting accounts can delegate locked coins, but the EVM state view only + // tracks spendable balance. Delegating more than the spendable balance makes + // the StateDB subtract more than it holds, which reconciles back to bank as a + // mint (or a burn for the victim). Block vesting-account creation outright so + // the precondition cannot be created permissionlessly. + NewBlockedMsgsDecorator( + sdk.MsgTypeURL(&sdkvesting.MsgCreateVestingAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePermanentLockedAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePeriodicVestingAccount{}), + ), ante.NewSetUpContextDecorator(), wasmkeeper.NewLimitSimulationGasDecorator(options.WasmConfig.SimulationGasLimit), // after setup context to enforce limits early diff --git a/app/ante/blocked_msgs.go b/app/ante/blocked_msgs.go new file mode 100644 index 00000000..a1cae66b --- /dev/null +++ b/app/ante/blocked_msgs.go @@ -0,0 +1,91 @@ +package ante + +import ( + "fmt" + + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + errortypes "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/x/authz" +) + +// maxNestedBlockedMsgs caps how deep the decorator recurses into nested +// authz.MsgExec messages while looking for blocked msg types. +const maxNestedBlockedMsgs = 7 + +// BlockedMsgsDecorator rejects a fixed set of msg type URLs anywhere in a tx: +// at the top level, and nested inside authz.MsgExec (arbitrarily deep, up to +// maxNestedBlockedMsgs). +// +// It complements cosmosante.NewAuthzLimiterDecorator, which only blocks msgs +// carried *inside* an authz message and lets the same msg through when it is +// submitted directly. +type BlockedMsgsDecorator struct { + // blockedMsgTypes is the set of msg type URLs to reject. + blockedMsgTypes map[string]struct{} +} + +// NewBlockedMsgsDecorator creates a decorator that rejects the given msg type +// URLs regardless of where they appear in the tx. +func NewBlockedMsgsDecorator(blockedMsgTypes ...string) BlockedMsgsDecorator { + blocked := make(map[string]struct{}, len(blockedMsgTypes)) + for _, msgType := range blockedMsgTypes { + blocked[msgType] = struct{}{} + } + + return BlockedMsgsDecorator{blockedMsgTypes: blocked} +} + +func (bmd BlockedMsgsDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) { + if err := bmd.checkBlockedMsgs(tx.GetMsgs(), 1); err != nil { + return ctx, errorsmod.Wrapf(errortypes.ErrUnauthorized, "%s", err.Error()) + } + + return next(ctx, tx, simulate) +} + +// checkBlockedMsgs walks the msgs and returns an error on the first blocked msg +// type it finds. authz.MsgExec is unwrapped so a blocked msg cannot be smuggled +// through the authz module; authz.MsgGrant is checked so a grant for a blocked +// msg type cannot be created either. +func (bmd BlockedMsgsDecorator) checkBlockedMsgs(msgs []sdk.Msg, nestedLvl int) error { + if nestedLvl >= maxNestedBlockedMsgs { + return fmt.Errorf("found more nested msgs than permitted; got: %d, expected: <%d", nestedLvl, maxNestedBlockedMsgs) + } + + for _, msg := range msgs { + switch msg := msg.(type) { + case *authz.MsgExec: + innerMsgs, err := msg.GetMessages() + if err != nil { + return err + } + if err := bmd.checkBlockedMsgs(innerMsgs, nestedLvl+1); err != nil { + return err + } + case *authz.MsgGrant: + authorization, err := msg.GetAuthorization() + if err != nil { + return err + } + if err := bmd.rejectIfBlocked(authorization.MsgTypeURL()); err != nil { + return err + } + default: + if err := bmd.rejectIfBlocked(sdk.MsgTypeURL(msg)); err != nil { + return err + } + } + } + + return nil +} + +func (bmd BlockedMsgsDecorator) rejectIfBlocked(msgTypeURL string) error { + if _, blocked := bmd.blockedMsgTypes[msgTypeURL]; blocked { + return fmt.Errorf("found blocked msg type: %s", msgTypeURL) + } + + return nil +} diff --git a/app/ante/blocked_msgs_test.go b/app/ante/blocked_msgs_test.go new file mode 100644 index 00000000..b0f3af2b --- /dev/null +++ b/app/ante/blocked_msgs_test.go @@ -0,0 +1,147 @@ +package ante_test + +import ( + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + sdkvesting "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/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/app/ante" +) + +// blockedVestingMsgURLs mirrors the list wired into NewCosmosAnteHandler. +var blockedVestingMsgURLs = []string{ + sdk.MsgTypeURL(&sdkvesting.MsgCreateVestingAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePermanentLockedAccount{}), + sdk.MsgTypeURL(&sdkvesting.MsgCreatePeriodicVestingAccount{}), +} + +func vestingTestAddrs() (from, to sdk.AccAddress) { + return sdk.AccAddress([]byte("from________________")), sdk.AccAddress([]byte("to__________________")) +} + +// nestMsgExec wraps msgs in `depth` levels of authz.MsgExec. +func nestMsgExec(grantee sdk.AccAddress, depth int, msgs []sdk.Msg) sdk.Msg { + inner := msgs + var out sdk.Msg + for i := 0; i < depth; i++ { + exec := authz.NewMsgExec(grantee, inner) + out = &exec + inner = []sdk.Msg{out} + } + return out +} + +// TestBlockedMsgsDecorator_VestingMsgs asserts that all three vesting-account +// creation msgs are rejected at the TOP LEVEL of a tx (F-2026-18201). Before +// this decorator only MsgCreateVestingAccount was blocked, and only when nested +// inside an authz.MsgExec, so a plain top-level tx created the vesting account +// that the staking-precompile underflow attack needs. +func TestBlockedMsgsDecorator_VestingMsgs(t *testing.T) { + from, to := vestingTestAddrs() + amount := sdk.NewCoins(sdk.NewInt64Coin("upc", 1_000_000)) + future := time.Date(9000, 1, 1, 0, 0, 0, 0, time.UTC) + + createVesting := sdkvesting.NewMsgCreateVestingAccount(from, to, amount, future.Unix(), false) + createPermanentLocked := sdkvesting.NewMsgCreatePermanentLockedAccount(from, to, amount) + createPeriodicVesting := sdkvesting.NewMsgCreatePeriodicVestingAccount(from, to, 0, []sdkvesting.Period{ + {Length: 3600, Amount: amount}, + }) + send := banktypes.NewMsgSend(from, to, amount) + + decorator := ante.NewBlockedMsgsDecorator(blockedVestingMsgURLs...) + + testCases := []struct { + name string + msgs []sdk.Msg + expFail bool + }{ + {"allowed msg passes", []sdk.Msg{send}, false}, + {"top-level MsgCreateVestingAccount", []sdk.Msg{createVesting}, true}, + {"top-level MsgCreatePermanentLockedAccount", []sdk.Msg{createPermanentLocked}, true}, + {"top-level MsgCreatePeriodicVestingAccount", []sdk.Msg{createPeriodicVesting}, true}, + {"blocked msg alongside allowed msgs", []sdk.Msg{send, createPermanentLocked, send}, true}, + { + "blocked msg inside authz.MsgExec", + []sdk.Msg{nestMsgExec(from, 1, []sdk.Msg{createPermanentLocked})}, + true, + }, + { + "blocked msg inside deeply nested authz.MsgExec", + []sdk.Msg{nestMsgExec(from, 4, []sdk.Msg{createPeriodicVesting})}, + true, + }, + { + "allowed msg inside authz.MsgExec passes", + []sdk.Msg{nestMsgExec(from, 2, []sdk.Msg{send})}, + false, + }, + { + "nesting deeper than the cap is rejected", + []sdk.Msg{nestMsgExec(from, 8, []sdk.Msg{send})}, + true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tx := mockFeeTx{msgs: tc.msgs} + + called := false + next := func(ctx sdk.Context, _ sdk.Tx, _ bool) (sdk.Context, error) { + called = true + return ctx, nil + } + + _, err := decorator.AnteHandle(sdk.Context{}, tx, false, next) + if tc.expFail { + require.Error(t, err) + require.ErrorIs(t, err, sdkerrors.ErrUnauthorized) + require.False(t, called, "blocked tx must not reach the next decorator") + return + } + + require.NoError(t, err) + require.True(t, called, "allowed tx must reach the next decorator") + }) + } +} + +// TestBlockedMsgsDecorator_AuthzGrant asserts that an authz grant for a blocked +// vesting msg type cannot be created either, so the block cannot be sidestepped +// by pre-authorizing a grantee. +func TestBlockedMsgsDecorator_AuthzGrant(t *testing.T) { + from, to := vestingTestAddrs() + future := time.Date(9000, 1, 1, 0, 0, 0, 0, time.UTC) + + decorator := ante.NewBlockedMsgsDecorator(blockedVestingMsgURLs...) + + for _, msgURL := range blockedVestingMsgURLs { + t.Run(msgURL, func(t *testing.T) { + grant, err := authz.NewMsgGrant(from, to, authz.NewGenericAuthorization(msgURL), &future) + require.NoError(t, err) + + _, err = decorator.AnteHandle(sdk.Context{}, mockFeeTx{msgs: []sdk.Msg{grant}}, false, noopAnteNext) + require.Error(t, err) + require.ErrorIs(t, err, sdkerrors.ErrUnauthorized) + }) + } + + t.Run("grant for an allowed msg type passes", func(t *testing.T) { + grant, err := authz.NewMsgGrant(from, to, + authz.NewGenericAuthorization(sdk.MsgTypeURL(&banktypes.MsgSend{})), &future) + require.NoError(t, err) + + _, err = decorator.AnteHandle(sdk.Context{}, mockFeeTx{msgs: []sdk.Msg{grant}}, false, noopAnteNext) + require.NoError(t, err) + }) +} + +func noopAnteNext(ctx sdk.Context, _ sdk.Tx, _ bool) (sdk.Context, error) { + return ctx, nil +} diff --git a/test/integration/ante/vesting_blocked_test.go b/test/integration/ante/vesting_blocked_test.go new file mode 100644 index 00000000..92deba34 --- /dev/null +++ b/test/integration/ante/vesting_blocked_test.go @@ -0,0 +1,200 @@ +package ante_test + +import ( + "testing" + "time" + + abci "github.com/cometbft/cometbft/abci/types" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/stretchr/testify/require" + + sdkmath "cosmossdk.io/math" + + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + "github.com/cosmos/cosmos-sdk/testutil/mock" + simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + sdkvesting "github.com/cosmos/cosmos-sdk/x/auth/vesting/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + + "github.com/pushchain/push-chain-node/app" +) + +// testChainID must be a chain ID app.EVMAppOptions knows about, otherwise +// NewChainApp panics while configuring the EVM coin info. +var testChainID = app.ChainID + +// setupVestingAnteApp boots a chain app with a single validator and one funded +// genesis account whose private key we keep, so we can sign real txs and push +// them through the full baseapp -> ante pipeline. +func setupVestingAnteApp(t *testing.T) (*app.ChainApp, cryptotypes.PrivKey, sdk.AccAddress) { + t.Helper() + + privVal := mock.NewPV() + valPubKey, err := privVal.GetPubKey() + require.NoError(t, err) + + valSet := cmttypes.NewValidatorSet([]*cmttypes.Validator{cmttypes.NewValidator(valPubKey, 1)}) + + senderPrivKey := secp256k1.GenPrivKey() + senderAcc := authtypes.NewBaseAccount(senderPrivKey.PubKey().Address().Bytes(), senderPrivKey.PubKey(), 0, 0) + senderAddr := senderAcc.GetAddress() + + balance := banktypes.Balance{ + Address: senderAddr.String(), + Coins: sdk.NewCoins( + sdk.NewCoin(sdk.DefaultBondDenom, sdkmath.NewInt(100_000_000_000_000)), + // Enough of the EVM denom to actually pay the fee, so that the tx is + // only ever rejected because of the msg type and not because it is + // underfunded. + sdk.NewCoin(app.BaseDenom, sdkmath.NewInt(1).MulRaw(1e18).MulRaw(100)), + ), + } + + chainApp := app.SetupWithGenesisValSet( + t, valSet, []authtypes.GenesisAccount{senderAcc}, testChainID, nil, balance, + ) + + return chainApp, senderPrivKey, senderAddr +} + +// disableInflation zeroes out the mint module so that the only thing that can +// change total supply across the test block is the tx under test, not block +// inflation. Written through an uncached context so it survives into +// FinalizeBlock. +func disableInflation(t *testing.T, chainApp *app.ChainApp) { + t.Helper() + + ctx := chainApp.BaseApp.NewUncachedContext(false, cmtproto.Header{}) + + params, err := chainApp.MintKeeper.Params.Get(ctx) + require.NoError(t, err) + params.InflationMin = sdkmath.LegacyZeroDec() + params.InflationMax = sdkmath.LegacyZeroDec() + params.InflationRateChange = sdkmath.LegacyZeroDec() + require.NoError(t, chainApp.MintKeeper.Params.Set(ctx, params)) + + minter, err := chainApp.MintKeeper.Minter.Get(ctx) + require.NoError(t, err) + minter.Inflation = sdkmath.LegacyZeroDec() + minter.AnnualProvisions = sdkmath.LegacyZeroDec() + require.NoError(t, chainApp.MintKeeper.Minter.Set(ctx, minter)) +} + +func totalSupply(t *testing.T, chainApp *app.ChainApp, ctx sdk.Context) sdk.Coins { + t.Helper() + + supply := sdk.NewCoins() + chainApp.BankKeeper.IterateTotalSupply(ctx, func(coin sdk.Coin) bool { + supply = supply.Add(coin) + return false + }) + + return supply +} + +// TestVestingAccountCreationBlockedEndToEnd is the chain-level regression test +// for F-2026-18201. +// +// The staking-precompile underflow attack needs a vesting account: the EVM state +// view tracks only SPENDABLE balance, while Cosmos lets a vesting account +// DELEGATE locked coins. Delegating more than the spendable balance makes the +// StateDB subtract more than it holds; x/vm/keeper/statedb.go then reconciles +// that bogus view back into bank by MINTING the difference (or by BURNING a +// victim's real coins on the wrap-transfer variant). +// +// Vesting-account creation used to be permissionless: NewAuthzLimiterDecorator +// blocked MsgCreateVestingAccount only INSIDE an authz.MsgExec, so a plain +// top-level tx went straight through - and MsgCreatePermanentLockedAccount / +// MsgCreatePeriodicVestingAccount were not blocked anywhere at all. This test +// submits each of the three as a real signed tx and asserts that it is rejected, +// that no vesting account is created, and that neither the sender's balance nor +// total native supply moves. +func TestVestingAccountCreationBlockedEndToEnd(t *testing.T) { + // The vesting amount is denominated in the EVM/staking denom, which is what + // makes the account a usable attack primitive in the first place. + amount := sdk.NewCoins(sdk.NewCoin(app.BaseDenom, sdkmath.NewInt(1).MulRaw(1e18))) + future := time.Now().Add(365 * 24 * time.Hour).Unix() + + // Comfortably above the dynamic min gas price so the tx is not rejected by + // the fee decorators instead of the blocked-msgs decorator. + fees := sdk.NewCoins(sdk.NewCoin(app.BaseDenom, + sdkmath.NewInt(1e10).MulRaw(int64(simtestutil.DefaultGenTxGas)))) + + testCases := []struct { + name string + msg func(from, to sdk.AccAddress) sdk.Msg + }{ + { + "MsgCreateVestingAccount", + func(from, to sdk.AccAddress) sdk.Msg { + return sdkvesting.NewMsgCreateVestingAccount(from, to, amount, future, false) + }, + }, + { + "MsgCreatePermanentLockedAccount", + func(from, to sdk.AccAddress) sdk.Msg { + return sdkvesting.NewMsgCreatePermanentLockedAccount(from, to, amount) + }, + }, + { + "MsgCreatePeriodicVestingAccount", + func(from, to sdk.AccAddress) sdk.Msg { + return sdkvesting.NewMsgCreatePeriodicVestingAccount(from, to, time.Now().Unix(), + []sdkvesting.Period{{Length: 3600, Amount: amount}}) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + chainApp, senderPriv, senderAddr := setupVestingAnteApp(t) + disableInflation(t, chainApp) + victimAddr := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + + ctx := chainApp.BaseApp.NewContext(true) + senderAccount := chainApp.AccountKeeper.GetAccount(ctx, senderAddr) + require.NotNil(t, senderAccount) + + supplyBefore := totalSupply(t, chainApp, ctx) + senderBalanceBefore := chainApp.BankKeeper.GetAllBalances(ctx, senderAddr) + victimBalanceBefore := chainApp.BankKeeper.GetAllBalances(ctx, victimAddr) + + res, err := app.SignAndDeliverWithoutCommit( + t, + chainApp.TxConfig(), + chainApp.BaseApp, + []sdk.Msg{tc.msg(senderAddr, victimAddr)}, + fees, + testChainID, + []uint64{senderAccount.GetAccountNumber()}, + []uint64{senderAccount.GetSequence()}, + time.Now(), + senderPriv, + ) + require.NoError(t, err, "block must still be produced") + require.Len(t, res.TxResults, 1) + + txRes := res.TxResults[0] + require.NotEqual(t, abci.CodeTypeOK, txRes.Code, + "vesting account creation must be rejected in ante, got success: %s", txRes.Log) + require.Contains(t, txRes.Log, "found blocked msg type", + "tx must be rejected by the blocked-msgs decorator, got: %s", txRes.Log) + + // The tx failed in ante, so nothing it would have done may be visible. + ctxAfter := chainApp.BaseApp.NewContext(true) + + require.Nil(t, chainApp.AccountKeeper.GetAccount(ctxAfter, victimAddr), + "no vesting account may be created") + require.Equal(t, victimBalanceBefore, chainApp.BankKeeper.GetAllBalances(ctxAfter, victimAddr), + "victim spendable balance must be unchanged") + require.Equal(t, senderBalanceBefore, chainApp.BankKeeper.GetAllBalances(ctxAfter, senderAddr), + "sender spendable balance must be unchanged") + require.Equal(t, supplyBefore, totalSupply(t, chainApp, ctxAfter), + "total native supply must be unchanged across the tx") + }) + } +}