Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions integration-tests/modules/assetft_extension_frozen_overdebit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 7 additions & 1 deletion x/asset/ft/keeper/before_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
115 changes: 115 additions & 0 deletions x/asset/ft/keeper/before_send_extension_frozen_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading