Status DataClose notification

ZIGChain Disclosed Report

Phantom LP supply causes permanently locked pool reserves

Company
Created date
Dec 21 2025

Target

hidden

Vulnerability Details

File Location

  • Core logic:
    • x/dex/keeper/msg_server_create_pool.go (LP minted vs LP tracked): L231-L303
    • x/dex/keeper/msg_server_create_pool.go (minimal lock / userShares): L318-L346
    • x/dex/keeper/msg_server_remove_liquidity.go (pool LP decreased by burned amount): L14-L115
    • x/dex/keeper/msg_server_remove_liquidity.go (withdraw proration uses pool.LpToken.Amount): L125-L157

Summary

x/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.

Finding Description

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.

Impact Explanation

  • Permanent freezing of funds: even after all LP tokens that users can ever own are burned, the pool retains a positive reserve balance.
  • This affects any pool created with 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.

Likelihood Explanation

  • Permissionless: any user can create a pool.
  • No special balances required beyond minimal pool creation liquidity.
  • Deterministic and reliable: it follows directly from the minting and accounting logic.

This meets “high likelihood” because it is trivially triggerable without privileges or significant resources.

Recommendation

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:

  • Mint lpAmount total LP.
  • Send userShares to the user.
  • Send 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.


Validation steps

Proof of Concept

Reproducible PoC test added:

  • x/dex/keeper/critical_poc_phantom_lp_locks_funds_test.go
    • Creates a pool with MinimalLiquidityLock = 1000, deposits base/quote, then removes all minted LP.
    • Asserts LP supply becomes 0 while pool reserves remain positive, proving reserves are permanently locked.

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)")
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$2,215
Visibilitypartially
VulnerabilityRace Conditions
Participants
hidden