diff --git a/integration-tests/modules/dex_test.go b/integration-tests/modules/dex_test.go index 2ff63c57..315e5539 100644 --- a/integration-tests/modules/dex_test.go +++ b/integration-tests/modules/dex_test.go @@ -3046,3 +3046,390 @@ 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) + + // 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(), + 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") +} + +// 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_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))) } 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)