From 9f6239b6f20df6ed1fad09af02fd62bfa58cf7bb Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Mon, 23 Feb 2026 00:46:23 +0330 Subject: [PATCH 1/6] Fix DEX freeze bug --- integration-tests/modules/dex_test.go | 193 ++++++++++++++++++++++++++ x/asset/ft/keeper/keeper_dex.go | 38 +++++ x/asset/ft/keeper/keeper_dex_test.go | 113 +++++++++++++++ 3 files changed, 344 insertions(+) diff --git a/integration-tests/modules/dex_test.go b/integration-tests/modules/dex_test.go index 2ff63c57..40b12560 100644 --- a/integration-tests/modules/dex_test.go +++ b/integration-tests/modules/dex_test.go @@ -3046,3 +3046,196 @@ func assertBalance( require.NoError(t, err) require.Equal(t, sdkmath.NewInt(expectedBalance).String(), acc1Denom2BalanceRes.Balance.Amount.String()) } + +// TestFrozenTokenEscapeViaDEX verifies that frozen tokens cannot be DEX-locked +// or transferred via DEX settlement. +// +// This is the integration test counterpart of TestKeeper_FrozenTokenEscapeViaDEX. +// If the fix were missing: placing multiple sell orders could lock frozen tokens +// (DEXIncreaseLocked had no frozen check), and DEX settlement would transfer them +// via raw bank keeper. This test asserts the fix: the second order (which would +// lock frozen tokens) must be rejected. +func TestFrozenTokenEscapeViaDEX(t *testing.T) { + t.Parallel() + ctx, chain := integrationtests.NewTXChainTestingContext(t) + + requireT := require.New(t) + assetFTClient := assetfttypes.NewQueryClient(chain.ClientContext) + dexClient := dextypes.NewQueryClient(chain.ClientContext) + + dexParamsRes, err := dexClient.Params(ctx, &dextypes.QueryParamsRequest{}) + requireT.NoError(err) + + // ======================================================================== + // SETUP: Create token with Feature_freezing, fund Alice, freeze tokens + // ======================================================================== + + // Issue base token with freezing feature (the token Alice sells) + // issuer already has the initial amount from issuance + issuer, denom := genAccountAndIssueFT( + ctx, t, chain, 1_000_000, sdkmath.NewInt(1_000_000), assetfttypes.Feature_freezing, + ) + + // Issue quote token (the token Bob uses to buy) - issuer issues both tokens + quoteDenom := issueFT(ctx, t, chain, issuer, sdkmath.NewInt(1_000_000)) + + alice := chain.GenAccount() + bob := chain.GenAccount() + + // Fund accounts with necessary messages and amounts + chain.FundAccountsWithOptions(ctx, t, []integration.AccWithBalancesOptions{ + { + Acc: issuer, + Options: integration.BalancesOptions{ + Messages: []sdk.Msg{ + &banktypes.MsgSend{}, + &banktypes.MsgSend{}, + &assetfttypes.MsgFreeze{}, + }, + }, + }, + { + Acc: alice, + Options: integration.BalancesOptions{ + Amount: dexParamsRes.Params.OrderReserve.Amount.MulRaw(3).Add(sdkmath.NewInt(100_000)), + }, + }, + { + Acc: bob, + Options: integration.BalancesOptions{ + Amount: dexParamsRes.Params.OrderReserve.Amount.Add(sdkmath.NewInt(100_000)), + }, + }, + }) + + // Fund Alice with 1,000,000 base tokens (100 * 10,000 to meet quantity step requirement) + aliceBaseAmount := int64(1_000_000) + sendMsg := &banktypes.MsgSend{ + FromAddress: issuer.String(), + ToAddress: alice.String(), + Amount: sdk.NewCoins(sdk.NewInt64Coin(denom, aliceBaseAmount)), + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(sendMsg)), + sendMsg, + ) + requireT.NoError(err) + + // Fund Bob with 1,000,000 quote tokens (for buying) + bobQuoteAmount := int64(1_000_000) + bobSendMsg := &banktypes.MsgSend{ + FromAddress: issuer.String(), + ToAddress: bob.String(), + Amount: sdk.NewCoins(sdk.NewInt64Coin(quoteDenom, bobQuoteAmount)), + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(bobSendMsg)), + bobSendMsg, + ) + requireT.NoError(err) + + // Admin freezes 600,000 of Alice's base tokens (60 * 10,000) + frozenAmount := int64(600_000) + freezeMsg := &assetfttypes.MsgFreeze{ + Sender: issuer.String(), + Account: alice.String(), + Coin: sdk.NewInt64Coin(denom, frozenAmount), + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(freezeMsg)), + freezeMsg, + ) + requireT.NoError(err) + + // Verify initial state + balanceRes, err := assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: alice.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(aliceBaseAmount, balanceRes.Balance.Int64(), "Alice should have 1,000,000 tokens") + requireT.Equal(frozenAmount, balanceRes.Frozen.Int64(), "Alice should have 600,000 frozen tokens") + spendableAmount := aliceBaseAmount - frozenAmount // 400,000 + spendable := balanceRes.Balance.Sub(balanceRes.Frozen).Sub(balanceRes.LockedInDEX) + requireT.Equal(spendableAmount, spendable.Int64(), "Alice should have 400,000 spendable tokens") + + t.Logf("=== Initial state: balance=%d, frozen=%d, dexLocked=0, spendable=%d ===", + aliceBaseAmount, frozenAmount, spendableAmount) + + // ======================================================================== + // PHASE 1: Verify that frozen tokens cannot be DEX-locked via orders + // + // If the fix were missing: we could place multiple sell orders that together + // lock more than spendable (400k + 400k + 200k = 1M, but only 400k spendable). + // Each order would pass because validation checked (balance - dexLocked) + // without subtracting frozen. The fix ensures only spendable tokens can be locked. + // ======================================================================== + + // Step 1: Place first sell order for 400,000 tokens (the unfrozen portion) — must succeed + order1Amount := int64(400_000) // 40 * 10,000 + placeOrder1 := &dextypes.MsgPlaceOrder{ + Sender: alice.String(), + Type: dextypes.ORDER_TYPE_LIMIT, + ID: "order1", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Price: lo.ToPtr(dextypes.MustNewPriceFromString("1")), + Quantity: sdkmath.NewInt(order1Amount), + Side: dextypes.SIDE_SELL, + TimeInForce: dextypes.TIME_IN_FORCE_GTC, + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(alice), + chain.TxFactoryAuto(), + placeOrder1, + ) + requireT.NoError(err, "First order of 400,000 should pass (400,000 unfrozen available)") + + balanceRes, err = assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: alice.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(order1Amount, balanceRes.LockedInDEX.Int64(), "400,000 tokens should be DEX-locked") + t.Logf("Step 1: Placed order for %d tokens. dexLocked=%d", order1Amount, order1Amount) + + // Step 2: Place second sell order for 400,000 MORE tokens — must FAIL + // Available spendable = 1,000,000 - 400,000 (dexLocked) - 600,000 (frozen) = 0. + // If the fix were missing, (balance - dexLocked) = 600,000 >= 400,000 would incorrectly pass. + order2Amount := int64(400_000) // 40 * 10,000 + placeOrder2 := &dextypes.MsgPlaceOrder{ + Sender: alice.String(), + Type: dextypes.ORDER_TYPE_LIMIT, + ID: "order2", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Price: lo.ToPtr(dextypes.MustNewPriceFromString("1")), + Quantity: sdkmath.NewInt(order2Amount), + Side: dextypes.SIDE_SELL, + TimeInForce: dextypes.TIME_IN_FORCE_GTC, + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(alice), + chain.TxFactoryAuto(), + placeOrder2, + ) + requireT.Error(err, "Second order of 400,000 must fail: it would lock frozen tokens; available spendable is 0") + t.Logf("Step 2: Second order of %d correctly rejected — frozen tokens cannot be DEX-locked", order2Amount) + + // Verify state unchanged: still 400,000 DEX-locked, 600,000 frozen + balanceRes, err = assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: alice.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(order1Amount, balanceRes.LockedInDEX.Int64(), "DEX-locked should remain 400,000") + requireT.Equal(frozenAmount, balanceRes.Frozen.Int64(), "Frozen should remain 600,000") +} diff --git a/x/asset/ft/keeper/keeper_dex.go b/x/asset/ft/keeper/keeper_dex.go index 7d202d65..aaee944e 100644 --- a/x/asset/ft/keeper/keeper_dex.go +++ b/x/asset/ft/keeper/keeper_dex.go @@ -66,6 +66,26 @@ func (k Keeper) DEXExecuteActions(ctx sdk.Context, actions types.DEXActions) err } for _, send := range actions.Send { + // Validate frozen balances before sending to prevent transferring frozen tokens + def, err := k.getDefinitionOrNil(ctx, send.Coin.Denom) + if err != nil { + return err + } + if def != nil && def.IsFeatureEnabled(types.Feature_freezing) && !def.HasAdminPrivileges(send.FromAddress) { + balance := k.bankKeeper.GetBalance(ctx, send.FromAddress, send.Coin.Denom) + dexLocked := k.GetDEXLockedBalance(ctx, send.FromAddress, send.Coin.Denom).Amount + bankLocked := k.bankKeeper.LockedCoins(ctx, send.FromAddress).AmountOf(send.Coin.Denom) + frozenBalance, err := k.GetFrozenBalance(ctx, send.FromAddress, send.Coin.Denom) + if err != nil { + return err + } + available := balance.Amount.Sub(dexLocked).Sub(bankLocked).Sub(frozenBalance.Amount) + if available.LT(send.Coin.Amount) { + return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, + "cannot send %s: insufficient spendable balance (frozen tokens cannot be transferred)", + send.Coin.String()) + } + } k.logger(ctx).Debug( "DEX sending coin", "from", send.FromAddress.String(), @@ -666,6 +686,24 @@ func (k Keeper) validateCoinIsNotLockedByDEXAndBank( coin.String(), availableAmt.String(), coin.Denom) } + // validate that we don't use frozen coins (if freezing feature is enabled) + def, err := k.getDefinitionOrNil(ctx, coin.Denom) + if err != nil { + return err + } + if def != nil && def.IsFeatureEnabled(types.Feature_freezing) && !def.HasAdminPrivileges(addr) { + frozenBalance, err := k.GetFrozenBalance(ctx, addr, coin.Denom) + if err != nil { + return err + } + frozenAmt := frozenBalance.Amount + notFrozenAmt := availableAmt.Sub(frozenAmt) + if notFrozenAmt.LT(coin.Amount) { + return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, "%s is not available, available %s%s", + coin.String(), notFrozenAmt.String(), coin.Denom) + } + } + return nil } diff --git a/x/asset/ft/keeper/keeper_dex_test.go b/x/asset/ft/keeper/keeper_dex_test.go index dda8b7ab..202a990b 100644 --- a/x/asset/ft/keeper/keeper_dex_test.go +++ b/x/asset/ft/keeper/keeper_dex_test.go @@ -1151,3 +1151,116 @@ func TestKeeper_DEXExtensions(t *testing.T) { ) // since DEXCheckOrderAmounts is failed, won't check DEXInvokeAssetExtension } + +// TestKeeper_FrozenTokenEscapeViaDEX verifies that frozen tokens cannot be +// DEX-locked or transferred via DEX settlement. +// +// If the fix were missing: DEXIncreaseLocked would allow locking frozen tokens +// (it only checked balance - dexLocked - bankLocked), and DEXExecuteActions would +// transfer them via raw bank keeper, bypassing FT hooks. This test asserts the +// fix: the second lock attempt (which would lock frozen tokens) must fail. +func TestKeeper_FrozenTokenEscapeViaDEX(t *testing.T) { + requireT := require.New(t) + + testApp := simapp.New() + ctx := testApp.NewContextLegacy(false, tmproto.Header{ + Time: time.Now(), + AppHash: []byte("some-hash"), + }) + + ftKeeper := testApp.AssetFTKeeper + bankKeeper := testApp.BankKeeper + + // ======================================================================== + // SETUP: Create token with Feature_freezing, fund Alice, freeze tokens + // ======================================================================== + + issuer := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + alice := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + bob := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + + // Issue a token with the freezing feature enabled (the "base" token Alice sells) + settings := types.IssueSettings{ + Issuer: issuer, + Symbol: "VULN", + Subunit: "vuln", + Precision: 6, + InitialAmount: sdkmath.NewInt(1_000_000), + Features: []types.Feature{types.Feature_freezing}, + } + denom, err := ftKeeper.Issue(ctx, settings) + requireT.NoError(err) + + // Issue a second token (the "quote" token Bob uses to buy) + quoteSettings := types.IssueSettings{ + Issuer: issuer, + Symbol: "QUOTE", + Subunit: "quote", + Precision: 6, + InitialAmount: sdkmath.NewInt(1_000_000), + Features: []types.Feature{}, + } + quoteDenom, err := ftKeeper.Issue(ctx, quoteSettings) + requireT.NoError(err) + + // Fund Alice with 100 base tokens + aliceCoins := sdk.NewInt64Coin(denom, 100) + requireT.NoError(bankKeeper.SendCoins(ctx, issuer, alice, sdk.NewCoins(aliceCoins))) + + // Fund Bob with 100 quote tokens (for buying) + bobCoins := sdk.NewInt64Coin(quoteDenom, 100) + requireT.NoError(bankKeeper.SendCoins(ctx, issuer, bob, sdk.NewCoins(bobCoins))) + + // Admin freezes 60 of Alice's base tokens + freezeAmount := sdk.NewInt64Coin(denom, 60) + requireT.NoError(ftKeeper.Freeze(ctx, issuer, alice, freezeAmount)) + + // Verify initial state + balance := bankKeeper.GetBalance(ctx, alice, denom) + requireT.Equal(int64(100), balance.Amount.Int64(), "Alice should have 100 tokens") + + frozenBalance, err := ftKeeper.GetFrozenBalance(ctx, alice, denom) + requireT.NoError(err) + requireT.Equal(int64(60), frozenBalance.Amount.Int64(), "Alice should have 60 frozen tokens") + + spendable, err := ftKeeper.GetSpendableBalance(ctx, alice, denom) + requireT.NoError(err) + requireT.Equal(int64(40), spendable.Amount.Int64(), "Alice should have 40 spendable tokens") + + t.Log("=== Initial state: balance=100, frozen=60, dexLocked=0, spendable=40 ===") + + // ======================================================================== + // PHASE 1: Verify that frozen tokens cannot be DEX-locked + // + // If the vulnerability existed: DEXIncreaseLocked would only check + // (balance - dexLocked - bankLocked >= amount) without considering frozen. + // That would allow locking 40 more tokens (overlapping with frozen), then + // 20 more, until all 60 frozen tokens were DEX-locked and transferable. + // The fix: validateCoinIsNotLockedByDEXAndBank now subtracts frozen from + // available balance, so only truly spendable tokens can be DEX-locked. + // ======================================================================== + + // Step 1: Lock 40 tokens (the unfrozen portion) — must succeed + lockAmount1 := sdk.NewInt64Coin(denom, 40) + err = ftKeeper.DEXIncreaseLocked(ctx, alice, lockAmount1) + requireT.NoError(err, "First lock of 40 should pass (40 unfrozen available)") + + dexLocked := ftKeeper.GetDEXLockedBalance(ctx, alice, denom) + requireT.Equal(int64(40), dexLocked.Amount.Int64(), "40 tokens should be DEX-locked") + t.Log("Step 1: DEXIncreaseLocked(40) passed. dexLocked=40") + + // Step 2: Lock 40 MORE tokens — must FAIL (would lock frozen tokens) + // Available spendable = 100 - 40 (dexLocked) - 60 (frozen) = 0. + // If the fix were missing, (balance - dexLocked) = 60 >= 40 would incorrectly pass. + lockAmount2 := sdk.NewInt64Coin(denom, 40) + err = ftKeeper.DEXIncreaseLocked(ctx, alice, lockAmount2) + requireT.Error(err, "Second lock of 40 must fail: it would lock frozen tokens; available spendable is 0") + t.Log("Step 2: DEXIncreaseLocked(40) correctly rejected — frozen tokens cannot be DEX-locked") + + // Verify state unchanged: still 40 DEX-locked, 60 frozen + dexLocked = ftKeeper.GetDEXLockedBalance(ctx, alice, denom) + requireT.Equal(int64(40), dexLocked.Amount.Int64(), "DEX-locked should remain 40") + frozenBalance, err = ftKeeper.GetFrozenBalance(ctx, alice, denom) + requireT.NoError(err) + requireT.Equal(int64(60), frozenBalance.Amount.Int64(), "Frozen should remain 60") +} From 362baa796c19c8f3860b3344e690498a5e8cefb7 Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Tue, 24 Feb 2026 13:57:57 +0330 Subject: [PATCH 2/6] Simplify implementation --- x/asset/ft/keeper/keeper_dex.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/x/asset/ft/keeper/keeper_dex.go b/x/asset/ft/keeper/keeper_dex.go index aaee944e..6fece95f 100644 --- a/x/asset/ft/keeper/keeper_dex.go +++ b/x/asset/ft/keeper/keeper_dex.go @@ -66,26 +66,6 @@ func (k Keeper) DEXExecuteActions(ctx sdk.Context, actions types.DEXActions) err } for _, send := range actions.Send { - // Validate frozen balances before sending to prevent transferring frozen tokens - def, err := k.getDefinitionOrNil(ctx, send.Coin.Denom) - if err != nil { - return err - } - if def != nil && def.IsFeatureEnabled(types.Feature_freezing) && !def.HasAdminPrivileges(send.FromAddress) { - balance := k.bankKeeper.GetBalance(ctx, send.FromAddress, send.Coin.Denom) - dexLocked := k.GetDEXLockedBalance(ctx, send.FromAddress, send.Coin.Denom).Amount - bankLocked := k.bankKeeper.LockedCoins(ctx, send.FromAddress).AmountOf(send.Coin.Denom) - frozenBalance, err := k.GetFrozenBalance(ctx, send.FromAddress, send.Coin.Denom) - if err != nil { - return err - } - available := balance.Amount.Sub(dexLocked).Sub(bankLocked).Sub(frozenBalance.Amount) - if available.LT(send.Coin.Amount) { - return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, - "cannot send %s: insufficient spendable balance (frozen tokens cannot be transferred)", - send.Coin.String()) - } - } k.logger(ctx).Debug( "DEX sending coin", "from", send.FromAddress.String(), From 85a8a6cdcab1a839829684fad426617456d9bbc0 Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Tue, 24 Feb 2026 14:02:50 +0330 Subject: [PATCH 3/6] Remove the unit test --- x/asset/ft/keeper/keeper_dex_test.go | 113 --------------------------- 1 file changed, 113 deletions(-) diff --git a/x/asset/ft/keeper/keeper_dex_test.go b/x/asset/ft/keeper/keeper_dex_test.go index 202a990b..dda8b7ab 100644 --- a/x/asset/ft/keeper/keeper_dex_test.go +++ b/x/asset/ft/keeper/keeper_dex_test.go @@ -1151,116 +1151,3 @@ func TestKeeper_DEXExtensions(t *testing.T) { ) // since DEXCheckOrderAmounts is failed, won't check DEXInvokeAssetExtension } - -// TestKeeper_FrozenTokenEscapeViaDEX verifies that frozen tokens cannot be -// DEX-locked or transferred via DEX settlement. -// -// If the fix were missing: DEXIncreaseLocked would allow locking frozen tokens -// (it only checked balance - dexLocked - bankLocked), and DEXExecuteActions would -// transfer them via raw bank keeper, bypassing FT hooks. This test asserts the -// fix: the second lock attempt (which would lock frozen tokens) must fail. -func TestKeeper_FrozenTokenEscapeViaDEX(t *testing.T) { - requireT := require.New(t) - - testApp := simapp.New() - ctx := testApp.NewContextLegacy(false, tmproto.Header{ - Time: time.Now(), - AppHash: []byte("some-hash"), - }) - - ftKeeper := testApp.AssetFTKeeper - bankKeeper := testApp.BankKeeper - - // ======================================================================== - // SETUP: Create token with Feature_freezing, fund Alice, freeze tokens - // ======================================================================== - - issuer := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) - alice := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) - bob := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) - - // Issue a token with the freezing feature enabled (the "base" token Alice sells) - settings := types.IssueSettings{ - Issuer: issuer, - Symbol: "VULN", - Subunit: "vuln", - Precision: 6, - InitialAmount: sdkmath.NewInt(1_000_000), - Features: []types.Feature{types.Feature_freezing}, - } - denom, err := ftKeeper.Issue(ctx, settings) - requireT.NoError(err) - - // Issue a second token (the "quote" token Bob uses to buy) - quoteSettings := types.IssueSettings{ - Issuer: issuer, - Symbol: "QUOTE", - Subunit: "quote", - Precision: 6, - InitialAmount: sdkmath.NewInt(1_000_000), - Features: []types.Feature{}, - } - quoteDenom, err := ftKeeper.Issue(ctx, quoteSettings) - requireT.NoError(err) - - // Fund Alice with 100 base tokens - aliceCoins := sdk.NewInt64Coin(denom, 100) - requireT.NoError(bankKeeper.SendCoins(ctx, issuer, alice, sdk.NewCoins(aliceCoins))) - - // Fund Bob with 100 quote tokens (for buying) - bobCoins := sdk.NewInt64Coin(quoteDenom, 100) - requireT.NoError(bankKeeper.SendCoins(ctx, issuer, bob, sdk.NewCoins(bobCoins))) - - // Admin freezes 60 of Alice's base tokens - freezeAmount := sdk.NewInt64Coin(denom, 60) - requireT.NoError(ftKeeper.Freeze(ctx, issuer, alice, freezeAmount)) - - // Verify initial state - balance := bankKeeper.GetBalance(ctx, alice, denom) - requireT.Equal(int64(100), balance.Amount.Int64(), "Alice should have 100 tokens") - - frozenBalance, err := ftKeeper.GetFrozenBalance(ctx, alice, denom) - requireT.NoError(err) - requireT.Equal(int64(60), frozenBalance.Amount.Int64(), "Alice should have 60 frozen tokens") - - spendable, err := ftKeeper.GetSpendableBalance(ctx, alice, denom) - requireT.NoError(err) - requireT.Equal(int64(40), spendable.Amount.Int64(), "Alice should have 40 spendable tokens") - - t.Log("=== Initial state: balance=100, frozen=60, dexLocked=0, spendable=40 ===") - - // ======================================================================== - // PHASE 1: Verify that frozen tokens cannot be DEX-locked - // - // If the vulnerability existed: DEXIncreaseLocked would only check - // (balance - dexLocked - bankLocked >= amount) without considering frozen. - // That would allow locking 40 more tokens (overlapping with frozen), then - // 20 more, until all 60 frozen tokens were DEX-locked and transferable. - // The fix: validateCoinIsNotLockedByDEXAndBank now subtracts frozen from - // available balance, so only truly spendable tokens can be DEX-locked. - // ======================================================================== - - // Step 1: Lock 40 tokens (the unfrozen portion) — must succeed - lockAmount1 := sdk.NewInt64Coin(denom, 40) - err = ftKeeper.DEXIncreaseLocked(ctx, alice, lockAmount1) - requireT.NoError(err, "First lock of 40 should pass (40 unfrozen available)") - - dexLocked := ftKeeper.GetDEXLockedBalance(ctx, alice, denom) - requireT.Equal(int64(40), dexLocked.Amount.Int64(), "40 tokens should be DEX-locked") - t.Log("Step 1: DEXIncreaseLocked(40) passed. dexLocked=40") - - // Step 2: Lock 40 MORE tokens — must FAIL (would lock frozen tokens) - // Available spendable = 100 - 40 (dexLocked) - 60 (frozen) = 0. - // If the fix were missing, (balance - dexLocked) = 60 >= 40 would incorrectly pass. - lockAmount2 := sdk.NewInt64Coin(denom, 40) - err = ftKeeper.DEXIncreaseLocked(ctx, alice, lockAmount2) - requireT.Error(err, "Second lock of 40 must fail: it would lock frozen tokens; available spendable is 0") - t.Log("Step 2: DEXIncreaseLocked(40) correctly rejected — frozen tokens cannot be DEX-locked") - - // Verify state unchanged: still 40 DEX-locked, 60 frozen - dexLocked = ftKeeper.GetDEXLockedBalance(ctx, alice, denom) - requireT.Equal(int64(40), dexLocked.Amount.Int64(), "DEX-locked should remain 40") - frozenBalance, err = ftKeeper.GetFrozenBalance(ctx, alice, denom) - requireT.NoError(err) - requireT.Equal(int64(60), frozenBalance.Amount.Int64(), "Frozen should remain 60") -} From d5fff9671fe96e90ad62eb67fc8d71a5c96c2e4d Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Tue, 24 Feb 2026 14:22:17 +0330 Subject: [PATCH 4/6] Add smaller orders to the integration test --- integration-tests/modules/dex_test.go | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/integration-tests/modules/dex_test.go b/integration-tests/modules/dex_test.go index 40b12560..ffdd49f6 100644 --- a/integration-tests/modules/dex_test.go +++ b/integration-tests/modules/dex_test.go @@ -3230,6 +3230,50 @@ func TestFrozenTokenEscapeViaDEX(t *testing.T) { requireT.Error(err, "Second order of 400,000 must fail: it would lock frozen tokens; available spendable is 0") t.Logf("Step 2: Second order of %d correctly rejected — frozen tokens cannot be DEX-locked", order2Amount) + // Step 2b: Try to lock 100,000 more — must FAIL (smaller portion of frozen) + order2bAmount := int64(100_000) + placeOrder2b := &dextypes.MsgPlaceOrder{ + Sender: alice.String(), + Type: dextypes.ORDER_TYPE_LIMIT, + ID: "order2b", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Price: lo.ToPtr(dextypes.MustNewPriceFromString("1")), + Quantity: sdkmath.NewInt(order2bAmount), + Side: dextypes.SIDE_SELL, + TimeInForce: dextypes.TIME_IN_FORCE_GTC, + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(alice), + chain.TxFactoryAuto(), + placeOrder2b, + ) + requireT.Error(err, "Order of 100,000 must fail: it would lock frozen tokens; available spendable is 0") + t.Logf("Step 2b: Order of %d correctly rejected — no portion of frozen tokens can be DEX-locked", order2bAmount) + + // Step 2c: Try to lock 10,000 more — must FAIL (even smaller portion of frozen) + order2cAmount := int64(10_000) + placeOrder2c := &dextypes.MsgPlaceOrder{ + Sender: alice.String(), + Type: dextypes.ORDER_TYPE_LIMIT, + ID: "order2c", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Price: lo.ToPtr(dextypes.MustNewPriceFromString("1")), + Quantity: sdkmath.NewInt(order2cAmount), + Side: dextypes.SIDE_SELL, + TimeInForce: dextypes.TIME_IN_FORCE_GTC, + } + _, err = client.BroadcastTx( + ctx, + chain.ClientContext.WithFromAddress(alice), + chain.TxFactoryAuto(), + placeOrder2c, + ) + requireT.Error(err, "Order of 10,000 must fail: it would lock frozen tokens; available spendable is 0") + t.Logf("Step 2c: Order of %d correctly rejected — no portion of frozen tokens can be DEX-locked", order2cAmount) + // Verify state unchanged: still 400,000 DEX-locked, 600,000 frozen balanceRes, err = assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ Account: alice.String(), From 44f169ea67504b4fcafb9218a6ba38ef43485dca Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Wed, 25 Feb 2026 01:04:50 +0330 Subject: [PATCH 5/6] Implement the fix in the GetSpendableBalance of asset FT keeper --- integration-tests/modules/dex_test.go | 150 ++++++++++++++++++++++++++ x/asset/ft/keeper/keeper.go | 28 ++--- x/asset/ft/keeper/keeper_dex.go | 18 ---- x/asset/ft/keeper/keeper_dex_test.go | 20 ++-- 4 files changed, 175 insertions(+), 41 deletions(-) diff --git a/integration-tests/modules/dex_test.go b/integration-tests/modules/dex_test.go index ffdd49f6..315e5539 100644 --- a/integration-tests/modules/dex_test.go +++ b/integration-tests/modules/dex_test.go @@ -3283,3 +3283,153 @@ func TestFrozenTokenEscapeViaDEX(t *testing.T) { requireT.Equal(order1Amount, balanceRes.LockedInDEX.Int64(), "DEX-locked should remain 400,000") requireT.Equal(frozenAmount, balanceRes.Frozen.Int64(), "Frozen should remain 600,000") } + +// TestFrozenTokenFreezeAfterOrderPlacement verifies that an order placed when the user had +// sufficient balance still executes after the issuer freezes tokens. +// +// Scenario: user has 100 TKN, places a sell order for 80 TKN, issuer freezes 20 TKN, +// then the order is matched. The order must be executed because it was valid at placement time. +// The fix must be at order placement only (reject locking frozen tokens); execution must +// not block orders that were legitimately placed before the freeze. +func TestFrozenTokenFreezeAfterOrderPlacement(t *testing.T) { + t.Parallel() + ctx, chain := integrationtests.NewTXChainTestingContext(t) + + requireT := require.New(t) + assetFTClient := assetfttypes.NewQueryClient(chain.ClientContext) + dexClient := dextypes.NewQueryClient(chain.ClientContext) + bankClient := banktypes.NewQueryClient(chain.ClientContext) + + dexParamsRes, err := dexClient.Params(ctx, &dextypes.QueryParamsRequest{}) + requireT.NoError(err) + + // Setup: token with freezing, Alice has 100 TKN (1,000,000), Bob has quote + issuer, denom := genAccountAndIssueFT( + ctx, t, chain, 1_000_000, sdkmath.NewInt(1_000_000), assetfttypes.Feature_freezing, + ) + quoteDenom := issueFT(ctx, t, chain, issuer, sdkmath.NewInt(1_000_000)) + + alice := chain.GenAccount() + bob := chain.GenAccount() + + chain.FundAccountsWithOptions(ctx, t, []integration.AccWithBalancesOptions{ + { + Acc: issuer, + Options: integration.BalancesOptions{ + Messages: []sdk.Msg{ + &banktypes.MsgSend{}, + &banktypes.MsgSend{}, + &assetfttypes.MsgFreeze{}, + }, + }, + }, + { + Acc: alice, + Options: integration.BalancesOptions{ + Amount: dexParamsRes.Params.OrderReserve.Amount.MulRaw(3).Add(sdkmath.NewInt(100_000)), + }, + }, + { + Acc: bob, + Options: integration.BalancesOptions{ + Amount: dexParamsRes.Params.OrderReserve.Amount.Add(sdkmath.NewInt(100_000)), + }, + }, + }) + + aliceBaseAmount := int64(1_000_000) // 100 TKN + sendToAlice := &banktypes.MsgSend{ + FromAddress: issuer.String(), + ToAddress: alice.String(), + Amount: sdk.NewCoins(sdk.NewInt64Coin(denom, aliceBaseAmount)), + } + _, err = client.BroadcastTx(ctx, chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(sendToAlice)), sendToAlice) + requireT.NoError(err) + + bobQuoteAmount := int64(1_000_000) + sendToBob := &banktypes.MsgSend{ + FromAddress: issuer.String(), + ToAddress: bob.String(), + Amount: sdk.NewCoins(sdk.NewInt64Coin(quoteDenom, bobQuoteAmount)), + } + _, err = client.BroadcastTx(ctx, chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(sendToBob)), sendToBob) + requireT.NoError(err) + + // Step 1: Alice places sell order for 80 TKN (800,000) — no freeze yet, all 100 available + orderAmount := int64(800_000) + placeSell := &dextypes.MsgPlaceOrder{ + Sender: alice.String(), + Type: dextypes.ORDER_TYPE_LIMIT, + ID: "alice-sell", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Price: lo.ToPtr(dextypes.MustNewPriceFromString("1")), + Quantity: sdkmath.NewInt(orderAmount), + Side: dextypes.SIDE_SELL, + TimeInForce: dextypes.TIME_IN_FORCE_GTC, + } + _, err = client.BroadcastTx(ctx, chain.ClientContext.WithFromAddress(alice), + chain.TxFactoryAuto(), placeSell) + requireT.NoError(err, "Alice's sell order must pass when she has 100 TKN available") + + balanceRes, err := assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: alice.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(orderAmount, balanceRes.LockedInDEX.Int64(), "800,000 should be DEX-locked") + + // Step 2: Issuer freezes 20 TKN (200,000) of Alice's balance + freezeAmount := int64(200_000) + freezeMsg := &assetfttypes.MsgFreeze{ + Sender: issuer.String(), + Account: alice.String(), + Coin: sdk.NewInt64Coin(denom, freezeAmount), + } + _, err = client.BroadcastTx(ctx, chain.ClientContext.WithFromAddress(issuer), + chain.TxFactory().WithGas(chain.GasLimitByMsgs(freezeMsg)), freezeMsg) + requireT.NoError(err) + + // Step 3: Bob places buy order — should match Alice's sell + placeBuy := &dextypes.MsgPlaceOrder{ + Sender: bob.String(), + Type: dextypes.ORDER_TYPE_MARKET, + ID: "bob-buy", + BaseDenom: denom, + QuoteDenom: quoteDenom, + Quantity: sdkmath.NewInt(orderAmount), + Side: dextypes.SIDE_BUY, + TimeInForce: dextypes.TIME_IN_FORCE_IOC, + } + _, err = client.BroadcastTx(ctx, chain.ClientContext.WithFromAddress(bob), + chain.TxFactoryAuto(), placeBuy) + requireT.NoError(err, "Bob's buy order must match and execute — order was valid at placement time") + + // Step 4: Verify order executed + aliceBalanceRes, err := assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: alice.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(aliceBaseAmount-orderAmount, aliceBalanceRes.Balance.Int64(), + "Alice should have 200,000 base tokens after selling 800,000") + requireT.Equal(int64(0), aliceBalanceRes.LockedInDEX.Int64(), "Alice's DEX lock should be released") + + bobBalanceRes, err := assetFTClient.Balance(ctx, &assetfttypes.QueryBalanceRequest{ + Account: bob.String(), + Denom: denom, + }) + requireT.NoError(err) + requireT.Equal(orderAmount, bobBalanceRes.Balance.Int64(), + "Bob should have received 800,000 base tokens") + + aliceQuoteRes, err := bankClient.Balance(ctx, &banktypes.QueryBalanceRequest{ + Address: alice.String(), + Denom: quoteDenom, + }) + requireT.NoError(err) + requireT.True(aliceQuoteRes.Balance.Amount.GTE(sdkmath.NewInt(orderAmount)), + "Alice should have received quote tokens from Bob") +} diff --git a/x/asset/ft/keeper/keeper.go b/x/asset/ft/keeper/keeper.go index ee8c3be7..8f040769 100644 --- a/x/asset/ft/keeper/keeper.go +++ b/x/asset/ft/keeper/keeper.go @@ -808,17 +808,16 @@ func (k Keeper) GetSpendableBalance( if err != nil { return sdk.Coin{}, err } - // the spendable balance counts the frozen balance + // the spendable balance counts the frozen balance (additive: balance - locked - frozen) if def != nil && def.IsFeatureEnabled(types.Feature_freezing) { frozenBalance, err := k.GetFrozenBalance(ctx, addr, denom) if err != nil { return sdk.Coin{}, err } - notFrozenAmt := balance.Amount.Sub(frozenBalance.Amount) - if notFrozenAmt.IsNegative() { + spendableAmount := notLockedAmt.Sub(frozenBalance.Amount) + if spendableAmount.IsNegative() { return sdk.NewCoin(denom, sdkmath.ZeroInt()), nil } - spendableAmount := sdkmath.MinInt(notLockedAmt, notFrozenAmt) return sdk.NewCoin(denom, spendableAmount), nil } @@ -1015,22 +1014,23 @@ func (k Keeper) validateCoinSpendable( ) } - if err := k.validateCoinIsNotLockedByDEXAndBank(ctx, addr, sdk.NewCoin(def.Denom, amount)); err != nil { - return err - } + // Spendable = balance - dexLocked - bankLocked - frozen (additive when freezing enabled) + balance := k.bankKeeper.GetBalance(ctx, addr, def.Denom) + dexLockedAmt := k.GetDEXLockedBalance(ctx, addr, def.Denom).Amount + bankLockedAmt := k.bankKeeper.LockedCoins(ctx, addr).AmountOf(def.Denom) + availableAmt := balance.Amount.Sub(dexLockedAmt).Sub(bankLockedAmt) if def.IsFeatureEnabled(types.Feature_freezing) && !def.HasAdminPrivileges(addr) { frozenBalance, err := k.GetFrozenBalance(ctx, addr, def.Denom) if err != nil { return err } - frozenAmt := frozenBalance.Amount - balance := k.bankKeeper.GetBalance(ctx, addr, def.Denom) - notFrozenAmt := balance.Amount.Sub(frozenAmt) - if notFrozenAmt.LT(amount) { - return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, "%s%s is not available, available %s%s", - amount.String(), def.Denom, notFrozenAmt.String(), def.Denom) - } + availableAmt = availableAmt.Sub(frozenBalance.Amount) + } + + if availableAmt.LT(amount) { + return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, "%s%s is not available, available %s%s", + amount.String(), def.Denom, availableAmt.String(), def.Denom) } return nil diff --git a/x/asset/ft/keeper/keeper_dex.go b/x/asset/ft/keeper/keeper_dex.go index 6fece95f..7d202d65 100644 --- a/x/asset/ft/keeper/keeper_dex.go +++ b/x/asset/ft/keeper/keeper_dex.go @@ -666,24 +666,6 @@ func (k Keeper) validateCoinIsNotLockedByDEXAndBank( coin.String(), availableAmt.String(), coin.Denom) } - // validate that we don't use frozen coins (if freezing feature is enabled) - def, err := k.getDefinitionOrNil(ctx, coin.Denom) - if err != nil { - return err - } - if def != nil && def.IsFeatureEnabled(types.Feature_freezing) && !def.HasAdminPrivileges(addr) { - frozenBalance, err := k.GetFrozenBalance(ctx, addr, coin.Denom) - if err != nil { - return err - } - frozenAmt := frozenBalance.Amount - notFrozenAmt := availableAmt.Sub(frozenAmt) - if notFrozenAmt.LT(coin.Amount) { - return sdkerrors.Wrapf(cosmoserrors.ErrInsufficientFunds, "%s is not available, available %s%s", - coin.String(), notFrozenAmt.String(), coin.Denom) - } - } - return nil } diff --git a/x/asset/ft/keeper/keeper_dex_test.go b/x/asset/ft/keeper/keeper_dex_test.go index dda8b7ab..907f8c6f 100644 --- a/x/asset/ft/keeper/keeper_dex_test.go +++ b/x/asset/ft/keeper/keeper_dex_test.go @@ -268,10 +268,11 @@ func TestKeeper_DEXLocked(t *testing.T) { // freeze locked balance requireT.NoError(ftKeeper.Freeze(ctx, issuer, acc, coinToSend)) - // 1050 - total, 600 locked by dex, 50 locked by bank, 1000 frozen + // 1050 total, 600 locked by dex, 50 locked by bank, 1000 frozen + // spendable = 1050 - 600 - 50 - 1000 = -600 -> 0 (additive: locked and frozen both reduce spendable) spendableBalance, err = ftKeeper.GetSpendableBalance(ctx, acc, denom) requireT.NoError(err) - requireT.Equal(sdk.NewInt64Coin(denom, 50).String(), spendableBalance.String()) + requireT.Equal(sdk.NewInt64Coin(denom, 0).String(), spendableBalance.String()) // decrease locked 2d part, even when it's frozen we allow it requireT.NoError(ftKeeper.DEXDecreaseLocked(ctx, acc, sdk.NewInt64Coin(denom, 600))) @@ -295,20 +296,21 @@ func TestKeeper_DEXLocked(t *testing.T) { requireT.Equal(sdk.NewInt64Coin(denom, 700).String(), frozenBalance.String()) // now 700 frozen, 50 locked by vesting, 1050 balance + // spendable = 1050 - 50 - 700 = 300 (additive) // try to use more than allowed err = ftKeeper.DEXCheckOrderAmounts( ctx, types.DEXOrder{Creator: acc}, - sdk.NewInt64Coin(denom, 351), + sdk.NewInt64Coin(denom, 301), sdk.NewInt64Coin(denom1, 0), ) requireT.ErrorIs(err, types.ErrDEXInsufficientSpendableBalance) - requireT.ErrorContains(err, "available 350") + requireT.ErrorContains(err, "available 300") // try to send more than allowed - err = bankKeeper.SendCoins(ctx, acc, acc, sdk.NewCoins(sdk.NewInt64Coin(denom, 351))) + err = bankKeeper.SendCoins(ctx, acc, acc, sdk.NewCoins(sdk.NewInt64Coin(denom, 301))) requireT.ErrorIs(err, cosmoserrors.ErrInsufficientFunds) - requireT.ErrorContains(err, "available 350") + requireT.ErrorContains(err, "available 300") // try to use with global freezing requireT.NoError(ftKeeper.GloballyFreeze(ctx, issuer, denom)) @@ -316,7 +318,7 @@ func TestKeeper_DEXLocked(t *testing.T) { ftKeeper.DEXCheckOrderAmounts( ctx, types.DEXOrder{Creator: acc}, - sdk.NewInt64Coin(denom, 350), + sdk.NewInt64Coin(denom, 300), sdk.NewInt64Coin(denom1, 0), ), fmt.Sprintf("usage of %s for DEX is blocked because the token is globally frozen", denom), @@ -330,11 +332,11 @@ func TestKeeper_DEXLocked(t *testing.T) { ftKeeper.DEXCheckOrderAmounts( ctx, types.DEXOrder{Creator: acc}, - sdk.NewInt64Coin(denom, 350), + sdk.NewInt64Coin(denom, 300), sdk.NewInt64Coin(denom1, 0), ), ) - requireT.NoError(ftKeeper.DEXIncreaseLocked(ctx, acc, sdk.NewInt64Coin(denom, 350))) + requireT.NoError(ftKeeper.DEXIncreaseLocked(ctx, acc, sdk.NewInt64Coin(denom, 300))) // freeze more than balance requireT.NoError(ftKeeper.Freeze(ctx, issuer, acc, sdk.NewInt64Coin(denom, 1_000_000))) } From 877736e457fb4e8c1cb6f208571518d58447274a Mon Sep 17 00:00:00 2001 From: Masih Yeganeh Date: Wed, 25 Feb 2026 13:03:09 +0330 Subject: [PATCH 6/6] Fix TestBaseKeeperWrapper_SpendableBalanceByDenom --- x/wbank/keeper/keeper_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/wbank/keeper/keeper_test.go b/x/wbank/keeper/keeper_test.go index 531c19c1..38700202 100644 --- a/x/wbank/keeper/keeper_test.go +++ b/x/wbank/keeper/keeper_test.go @@ -155,7 +155,7 @@ func TestBaseKeeperWrapper_SpendableBalanceByDenom(t *testing.T) { Denom: denom, }) requireT.NoError(err) - requireT.Equal(balance.Sub(coinToFreeze).String(), spendableBalanceRes.Balance.String()) + requireT.Equal(balance.Sub(coinToFreeze).Sub(coinToLock).String(), spendableBalanceRes.Balance.String()) // freeze globally err = ftKeeper.GloballyFreeze(ctx, issuer, denom)