From 964fa9bb0a96f0fed0441035ad9cc2614ec53ea2 Mon Sep 17 00:00:00 2001 From: metalarm10 Date: Tue, 23 Jun 2026 16:41:20 +0300 Subject: [PATCH 1/3] fix(asset-ft): include commission and burn in extension spendable check --- x/asset/ft/keeper/before_send.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/x/asset/ft/keeper/before_send.go b/x/asset/ft/keeper/before_send.go index f67e68af..02dc31aa 100644 --- a/x/asset/ft/keeper/before_send.go +++ b/x/asset/ft/keeper/before_send.go @@ -116,7 +116,13 @@ func (k Keeper) applyFeatures(ctx sdk.Context, input banktypes.Input, outputs [] } } - if err := k.validateCoinSpendable(ctx, sender, *def, coin.Amount); err != nil { + // The extension path debits amount+commission+burn (not pre-debited above), so + // validate against the full amount; otherwise the gap is taken from frozen funds. + amountToValidate := coin.Amount + if def.IsFeatureEnabled(types.Feature_extension) { + amountToValidate = amountToValidate.Add(commissionAmount).Add(burnAmount) + } + if err := k.validateCoinSpendable(ctx, sender, *def, amountToValidate); err != nil { return err } From feb91814bb5a107d0d644c91a81480b74395e773 Mon Sep 17 00:00:00 2001 From: metalarm10 Date: Tue, 23 Jun 2026 16:41:35 +0300 Subject: [PATCH 2/3] test(asset-ft): cover extension commission/burn frozen over-debit --- .../before_send_extension_frozen_test.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 x/asset/ft/keeper/before_send_extension_frozen_test.go diff --git a/x/asset/ft/keeper/before_send_extension_frozen_test.go b/x/asset/ft/keeper/before_send_extension_frozen_test.go new file mode 100644 index 00000000..55cb4a84 --- /dev/null +++ b/x/asset/ft/keeper/before_send_extension_frozen_test.go @@ -0,0 +1,115 @@ +package keeper_test + +import ( + "testing" + "time" + + sdkmath "cosmossdk.io/math" + wasmtypes "github.com/CosmWasm/wasmd/x/wasm/types" + tmproto "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + cosmoserrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/stretchr/testify/require" + + "github.com/tokenize-x/tx-chain/v8/testutil/simapp" + testcontracts "github.com/tokenize-x/tx-chain/v8/x/asset/ft/keeper/test-contracts" + "github.com/tokenize-x/tx-chain/v8/x/asset/ft/types" +) + +// TestKeeper_Extension_CommissionBurn_RespectsFrozenBalance checks that on the +// extension path the spendable check covers the full debit (amount+commission+burn). +// Otherwise a holder could send their whole unfrozen quota as the bare amount while +// the commission+burn portion is drawn from frozen funds, leaving balance < frozen. +func TestKeeper_Extension_CommissionBurn_RespectsFrozenBalance(t *testing.T) { + requireT := require.New(t) + + testApp := simapp.New() + ctx := testApp.NewContextLegacy(false, tmproto.Header{ + Time: time.Now(), + AppHash: []byte("frozen-overdebit"), + }) + + ftKeeper := testApp.AssetFTKeeper + bankKeeper := testApp.BankKeeper + + issuer := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + user := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + recipient := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + + codeID, _, err := testApp.WasmPermissionedKeeper.Create( + ctx, issuer, testcontracts.AssetExtensionWasm, &wasmtypes.AllowEverybody, + ) + requireT.NoError(err) + + // Boundary rates: 0.5 burn + 0.5 commission, so the full debit is 2x the send amount. + settings := types.IssueSettings{ + Issuer: issuer, + Symbol: "FROZ", + Subunit: "froz", + Precision: 1, + Description: "extension token with commission and burn", + InitialAmount: sdkmath.NewInt(10_000), + Features: []types.Feature{ + types.Feature_freezing, + types.Feature_extension, + }, + BurnRate: sdkmath.LegacyMustNewDecFromStr("0.5"), + SendCommissionRate: sdkmath.LegacyMustNewDecFromStr("0.5"), + ExtensionSettings: &types.ExtensionIssueSettings{ + CodeId: codeID, + }, + } + denom, err := ftKeeper.Issue(ctx, settings) + requireT.NoError(err) + + // User holds 1000, of which the admin freezes 500. Spendable (unfrozen) = 500. + requireT.NoError(bankKeeper.SendCoins( + ctx, issuer, user, sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(1000))), + )) + requireT.NoError(ftKeeper.Freeze( + ctx, issuer, user, sdk.NewCoin(denom, sdkmath.NewInt(500)), + )) + + assertInvariant := func(stage string) { + balance := bankKeeper.GetBalance(ctx, user, denom) + frozen, frozenErr := ftKeeper.GetFrozenBalance(ctx, user, denom) + requireT.NoError(frozenErr) + requireT.True(balance.Amount.GTE(frozen.Amount), + "%s: invariant balance(%s) >= frozen(%s) must hold", stage, balance.Amount, frozen.Amount) + } + + // Over-debit: sending 500 (the full unfrozen quota as the bare amount) costs + // 500 + 250 burn + 250 commission = 1000 > spendable 500 → must be rejected. + err = bankKeeper.SendCoins( + ctx, user, recipient, sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(500))), + ) + requireT.ErrorIs(err, cosmoserrors.ErrInsufficientFunds, + "over-debit transfer must be rejected: full cost 1000 exceeds spendable 500") + + // State is untouched after the rejected transfer. + requireT.Equal(sdkmath.NewInt(1000), bankKeeper.GetBalance(ctx, user, denom).Amount, + "balance must be unchanged after rejected transfer") + frozenAfterReject, err := ftKeeper.GetFrozenBalance(ctx, user, denom) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(500), frozenAfterReject.Amount, + "frozen reserve must be untouched after rejected transfer") + assertInvariant("after rejected over-debit") + + // Within-budget: sending 250 costs 250 + 125 + 125 = 500 == spendable → must succeed, + // drawing only from the unfrozen portion and preserving the frozen reserve. + err = bankKeeper.SendCoins( + ctx, user, recipient, sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(250))), + ) + requireT.NoError(err, "within-budget transfer (full cost 500 == spendable) must still succeed") + + requireT.Equal(sdkmath.NewInt(500), bankKeeper.GetBalance(ctx, user, denom).Amount, + "balance must drop by the full debit (1000 -> 500)") + frozenAfterSend, err := ftKeeper.GetFrozenBalance(ctx, user, denom) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(500), frozenAfterSend.Amount, + "frozen reserve must remain intact (500)") + requireT.Equal(sdkmath.NewInt(250), bankKeeper.GetBalance(ctx, recipient, denom).Amount, + "recipient receives the bare amount (250)") + assertInvariant("after within-budget transfer") +} From f8ffdabd701b791a2cedece114591b36c1bbe066 Mon Sep 17 00:00:00 2001 From: metalarm10 Date: Tue, 23 Jun 2026 16:41:49 +0300 Subject: [PATCH 3/3] test(integration): add asset-ft extension frozen over-debit test --- ...assetft_extension_frozen_overdebit_test.go | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 integration-tests/modules/assetft_extension_frozen_overdebit_test.go diff --git a/integration-tests/modules/assetft_extension_frozen_overdebit_test.go b/integration-tests/modules/assetft_extension_frozen_overdebit_test.go new file mode 100644 index 00000000..465117f4 --- /dev/null +++ b/integration-tests/modules/assetft_extension_frozen_overdebit_test.go @@ -0,0 +1,150 @@ +//go:build integrationtests + +package modules + +import ( + "testing" + + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + cosmoserrors "github.com/cosmos/cosmos-sdk/types/errors" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + + integrationtests "github.com/tokenize-x/tx-chain/v8/integration-tests" + "github.com/tokenize-x/tx-chain/v8/pkg/client" + "github.com/tokenize-x/tx-chain/v8/testutil/integration" + testcontracts "github.com/tokenize-x/tx-chain/v8/x/asset/ft/keeper/test-contracts" + assetfttypes "github.com/tokenize-x/tx-chain/v8/x/asset/ft/types" +) + +// TestAssetFTExtensionCommissionBurnRespectsFrozen checks that, on the extension +// transfer path, a holder cannot spend into their frozen balance via the +// commission+burn surcharge: the spendable check must cover amount+commission+burn. +func TestAssetFTExtensionCommissionBurnRespectsFrozen(t *testing.T) { + t.Parallel() + + ctx, chain := integrationtests.NewTXChainTestingContext(t) + requireT := require.New(t) + ftClient := assetfttypes.NewQueryClient(chain.ClientContext) + bankClient := banktypes.NewQueryClient(chain.ClientContext) + + issuer := chain.GenAccount() + user := chain.GenAccount() + recipient := chain.GenAccount() + + chain.FundAccountsWithOptions(ctx, t, []integration.AccWithBalancesOptions{ + { + Acc: issuer, + Options: integration.BalancesOptions{ + Messages: []sdk.Msg{&assetfttypes.MsgFreeze{}}, + Amount: chain.QueryAssetFTParams(ctx, t).IssueFee.Amount. + Add(sdkmath.NewInt(1_000_000)). // smart contract upload + Add(sdkmath.NewInt(1_000_000)), // issue + extension transfer gas + }, + }, + { + Acc: user, + Options: integration.BalancesOptions{Amount: sdkmath.NewInt(2_000_000)}, // gas for extension sends + }, + }) + + codeID, err := chain.Wasm.DeployWASMContract( + ctx, chain.TxFactoryAuto(), issuer, testcontracts.AssetExtensionWasm, + ) + requireT.NoError(err) + + // Token with freezing + extension and 0.5 burn + 0.5 commission, so a transfer + // debits 2x the sent amount. + issueMsg := &assetfttypes.MsgIssue{ + Issuer: issuer.String(), + Symbol: "FROZ", + Subunit: "ufroz", + Precision: 6, + Description: "extension token with commission and burn", + InitialAmount: sdkmath.NewInt(10_000), + Features: []assetfttypes.Feature{ + assetfttypes.Feature_freezing, + assetfttypes.Feature_extension, + }, + BurnRate: sdkmath.LegacyMustNewDecFromStr("0.5"), + SendCommissionRate: sdkmath.LegacyMustNewDecFromStr("0.5"), + ExtensionSettings: &assetfttypes.ExtensionIssueSettings{ + CodeId: codeID, + Funds: sdk.NewCoins(chain.NewCoin(sdkmath.NewInt(10))), + Label: "froz-extension", + }, + } + denom := assetfttypes.BuildDenom(issueMsg.Subunit, issuer) + + // Issue and seed the user with 1000 (issuer is admin, so this setup send is not surcharged). + fundUserSend := &banktypes.MsgSend{ + FromAddress: issuer.String(), + ToAddress: user.String(), + Amount: sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(1000))), + } + _, err = client.BroadcastTx(ctx, + chain.ClientContext.WithFromAddress(issuer), + chain.TxFactoryAuto(), + issueMsg, fundUserSend) + requireT.NoError(err) + + // Freeze 500 of the user's 1000 → spendable (unfrozen) = 500. + freezeMsg := &assetfttypes.MsgFreeze{ + Sender: issuer.String(), + Account: user.String(), + Coin: sdk.NewCoin(denom, sdkmath.NewInt(500)), + } + _, err = client.BroadcastTx(ctx, + chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(freezeMsg)), + freezeMsg) + requireT.NoError(err) + + // Over-debit: sending 500 costs 500 + 250 burn + 250 commission = 1000 > spendable 500 → rejected. + overDebitSend := &banktypes.MsgSend{ + FromAddress: user.String(), + ToAddress: recipient.String(), + Amount: sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(500))), + } + _, err = client.BroadcastTx(ctx, + chain.ClientContext.WithFromAddress(user), + chain.TxFactory().WithGas(500_000), + overDebitSend) + requireT.ErrorIs(err, cosmoserrors.ErrInsufficientFunds) + + // Frozen reserve is intact and the user keeps the full balance. + frozen, err := ftClient.FrozenBalance(ctx, &assetfttypes.QueryFrozenBalanceRequest{ + Account: user.String(), Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(500), frozen.Balance.Amount) + userBal, err := bankClient.Balance(ctx, &banktypes.QueryBalanceRequest{Address: user.String(), Denom: denom}) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(1000), userBal.Balance.Amount) + + // Within budget: sending 250 costs 250 + 125 + 125 = 500 == spendable → succeeds. + okSend := &banktypes.MsgSend{ + FromAddress: user.String(), + ToAddress: recipient.String(), + Amount: sdk.NewCoins(sdk.NewCoin(denom, sdkmath.NewInt(250))), + } + _, err = client.BroadcastTx(ctx, + chain.ClientContext.WithFromAddress(user), + chain.TxFactory().WithGas(500_000), + okSend) + requireT.NoError(err) + + // Frozen reserve still intact; balance dropped by the full debit; recipient got the bare amount. + frozen, err = ftClient.FrozenBalance(ctx, &assetfttypes.QueryFrozenBalanceRequest{ + Account: user.String(), Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(500), frozen.Balance.Amount) + userBal, err = bankClient.Balance(ctx, &banktypes.QueryBalanceRequest{Address: user.String(), Denom: denom}) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(500), userBal.Balance.Amount) + recipientBal, err := bankClient.Balance(ctx, &banktypes.QueryBalanceRequest{Address: recipient.String(), Denom: denom}) + requireT.NoError(err) + requireT.Equal(sdkmath.NewInt(250), recipientBal.Balance.Amount) +}