x/dex/keeper/msg_server_create_pool.go (LP minted vs LP tracked): L231-L303x/dex/keeper/msg_server_create_pool.go (minimal lock / userShares): L318-L346x/dex/keeper/msg_server_remove_liquidity.go (pool LP decreased by burned amount): L14-L115x/dex/keeper/msg_server_remove_liquidity.go (withdraw proration uses pool.LpToken.Amount): L125-L157x/dex implements a “minimal liquidity lock” by tracking a larger LP total supply in pool state than the LP tokens actually minted. This creates “phantom LP supply” and makes a portion of each affected pool’s reserves permanently unwithdrawable, even after all real LP tokens are burned.
During pool creation, the keeper calculates:
lpAmount = floor(sqrt(base * quote)) (stored in pool.LpToken.Amount as the pool’s total LP supply)userShares = lpAmount - MinimalLiquidityLock (actually minted and sent to the user)However, only userShares is minted, while the pool state stores lpAmount as total LP supply. Later, liquidity removal returns reserves based on:
coinsOut = poolCoins * (lptoken.Amount / pool.LpToken.Amount)
Because pool.LpToken.Amount includes the lock amount that was never minted to anyone, the system behaves as if there exists a permanently locked LP position without minting any locked LP tokens. This guarantees a fraction of pool reserves will remain stuck forever.
MinimalLiquidityLock > 0, and the stuck fraction can be significant for small pools near the lock threshold.This meets “high impact” because it creates irrecoverable locked funds (loss of funds) at the protocol level.
This meets “high likelihood” because it is trivially triggerable without privileges or significant resources.
Fix the minimal liquidity lock by ensuring total LP tracked in state matches actual minted supply, and the “locked” portion is represented by real LP tokens minted to an unspendable sink (not phantom supply).
One safe pattern:
lpAmount total LP.userShares to the user.minimalLock LP to an address that cannot sign (e.g., the pool account address), making the lock real and permanent.Illustrative patch snippet (conceptual):
// After computing lpAmount and userShares:
// minimalLock = lpAmount - userShares
// 1) Mint total LP = lpAmount
_ = k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(lpAmount))
// 2) Send userShares to receiver
_ = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, receiver, sdk.NewCoins(userShares))
// 3) Send minimalLock LP to an unspendable sink (e.g. pool address)
minimalLockCoin := sdk.NewCoin(poolIDString, lpAmount.Amount.Sub(userShares.Amount))
poolAddr := types.GetPoolAddress(poolIDString)
_ = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, poolAddr, sdk.NewCoins(minimalLockCoin))
// 4) Ensure pool.LpToken tracks lpAmount (which now matches real minted supply)
pool.LpToken = lpAmount
This preserves the intended “minimum liquidity lock” behavior without creating phantom LP.
Reproducible PoC test added:
x/dex/keeper/critical_poc_phantom_lp_locks_funds_test.go
MinimalLiquidityLock = 1000, deposits base/quote, then removes all minted LP.Run:
go test ./x/dex/keeper -run TestCriticalPoC_PhantomLPSupply_LocksPoolReserves -count=1
package keeper_test
import (
"testing"
sdk "github.com/cosmos/cosmos-sdk/types"
minttypes "github.com/cosmos/cosmos-sdk/x/mint/types"
"github.com/stretchr/testify/require"
keepertest "zigchain/testutil/keeper"
"zigchain/testutil/sample"
"zigchain/x/dex/keeper"
"zigchain/x/dex/types"
)
// PoC: demonstrate that with MinimalLiquidityLock > 0, pool accounting can create
// "phantom LP supply" where total LP tracked in state exceeds actual minted supply.
// As a result, after burning all LP tokens that users can possibly own, the pool still
// retains positive reserves that are permanently unwithdrawable (no permissionless recovery).
func TestCriticalPoC_PhantomLPSupply_LocksPoolReserves(t *testing.T) {
creator := sample.AccAddress()
signer := sdk.MustAccAddressFromBech32(creator)
k, ctx, bankKeeper := keepertest.DexKeeperWithBank(t, nil)
srv := keeper.NewMsgServerImpl(k)
// Configure params to isolate the issue:
// - No creation fee (avoid unrelated funding requirements)
// - MinimalLiquidityLock > 0 (the feature that triggers the bug)
params := types.DefaultParams()
params.CreationFee = 0
params.MinimalLiquidityLock = 1000
require.NoError(t, k.SetParams(ctx, params))
// Choose amounts so that:
// lpAmount = sqrt(base*quote) = 1002 (perfect square)
// userShares = lpAmount - minimalLock = 2
base := sample.Coin("base", 1002)
quote := sample.Coin("quote", 1002)
// Fund creator with base+quote
funding := sdk.NewCoins(base, quote)
require.NoError(t, bankKeeper.MintCoins(ctx, minttypes.ModuleName, funding))
require.NoError(t, bankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, signer, funding))
// Create pool
createRes, err := srv.CreatePool(ctx, &types.MsgCreatePool{
Creator: creator,
Base: base,
Quote: quote,
})
require.NoError(t, err)
require.NotNil(t, createRes)
poolID := createRes.PoolId
require.Equal(t, uint64(2), createRes.LpToken.Amount.Uint64(), "creator should only receive userShares=2 LP tokens")
// Confirm on-chain total LP supply equals the minted amount (2), not the pool state's total (1002).
supply := bankKeeper.GetSupply(ctx, poolID)
require.Equal(t, uint64(2), supply.Amount.Uint64(), "only userShares should be minted on create")
pool, found := k.GetPool(ctx, poolID)
require.True(t, found)
require.Equal(t, uint64(1002), pool.LpToken.Amount.Uint64(), "pool state tracks total LP = lpAmount (includes minimal lock)")
// Remove ALL LP tokens the user can possibly own (1)
_, err = srv.RemoveLiquidity(ctx, &types.MsgRemoveLiquidity{
Creator: creator,
Lptoken: createRes.LpToken,
})
require.NoError(t, err)
// After burning the only minted LP token, total supply becomes zero...
supplyAfter := bankKeeper.GetSupply(ctx, poolID)
require.True(t, supplyAfter.Amount.IsZero(), "all minted LP should be burned")
// ...but the pool still retains positive reserves, proving funds are stuck with no LP token claims.
poolAddr := types.GetPoolAddress(poolID)
baseLeft := bankKeeper.GetBalance(ctx, poolAddr, base.Denom)
quoteLeft := bankKeeper.GetBalance(ctx, poolAddr, quote.Denom)
require.True(t, baseLeft.Amount.IsPositive(), "pool should still have leftover base reserves (locked)")
require.True(t, quoteLeft.Amount.IsPositive(), "pool should still have leftover quote reserves (locked)")
// And pool state still tracks non-zero LP total despite supply being zero.
poolAfter, found := k.GetPool(ctx, poolID)
require.True(t, found)
require.Equal(t, uint64(1000), poolAfter.LpToken.Amount.Uint64(), "pool LP total decreases by burned amount but remains non-zero (phantom lock)")
}