DeFi & DEX
Liquidity Pools

Liquidity Pools

Providing liquidity means depositing both sides of a pair into a pool and receiving LP tokens that represent your share of it. In exchange you earn the 0.3% fee on every swap that routes through your pool, all of it unless the factory's protocol-fee switch is on. You also take on impermanent loss, worked through below with real numbers.

Manage positions at https://superhero.com/defi/pool: the add or remove form is on the left and your positions on the right.

Add liquidity

  1. Open /defi/pool and connect your wallet.
  2. Pick the two tokens. Native AE is selectable; the app routes AE pairs through the WAE address behind the scenes.
  3. Enter an amount. If the pool exists, the form previews the current ratio in both directions, your estimated pool share, and the LP tokens you would mint.
  4. Approve each AEX-9 token for the router if prompted, then confirm.

Match the ratio. For an existing pool the app compares your two amounts against the current reserve ratio. If they differ by more than 1% it blocks with a ratio-mismatch message and offers the optimal counter-amount for each side. Take the suggestion: a constant-product pool only accepts proportional deposits, and the surplus on one side buys you nothing.

lpMinted  = totalSupply × min( amountA / reserveA , amountB / reserveB )
yourShare = lpMinted / totalSupply × 100

The min is why an off-ratio deposit wastes the larger side: the smaller ratio sets the mint.

Minimum amounts are your desired amounts minus your slippage tolerance, clamped to at least one aetto. The deadline is Date.now() plus your configured minutes, in milliseconds. Every router entrypoint checks require(deadline >= Chain.timestamp, "AedexV2Router: EXPIRED"), and Sophia's Chain.timestamp is unix time in milliseconds.

Router entrypoints

Pair typeEntrypoint
Token + tokenadd_liquidity(token_a, token_b, amount_a_desired, amount_b_desired, amount_a_min, amount_b_min, to, min_liquidity, deadline)
Token + native AEadd_liquidity_ae(token, amount_token_desired, amount_token_min, amount_ae_min, to, min_liquidity, deadline), with the AE attached as call value

add_liquidity_ae is payable. The attached AE is the upper bound on the AE side: the router wraps what it needs with wae.deposit_to(pair, value = amount_ae) and refunds the rest with Chain.spend in the same transaction, so over-attaching AE is safe.

Both forms move your tokens with transfer_allowance(Call.caller, pair, amount), which is why each AEX-9 side needs a router allowance first. The 1% ratio check is client-side only; on chain, the router computes the optimal counter-amount with quote and aborts with AedexV2Router: INSUFFICIENT_A_AMOUNT or INSUFFICIENT_B_AMOUNT if it falls below your minimums.

Creating a pool

Adding liquidity to a pair that does not exist yet creates it in the same transaction: the router calls factory.create_pair when get_pair returns None. Anyone can create a pair of any two distinct AEX-9 tokens; there is no separate "create pool" step.

min_liquidity is an option(int) and only has an effect when the pair is being created. The client passes 1000, which becomes the pool's permanently locked floor. On the very first mint the pair runs:

if (total_supply == 0)
    mint_update(None, min_liq)                  // locked_liquidity, owned by nobody
    Math.sqrt(amount0 * amount1) - min_liq      // what the first provider gets
else
    Math.min((amount0*total_supply) / reserve0, (amount1*total_supply) / reserve1)

mint_update(None, ...) credits state.locked_liquidity rather than any address and emits a LockLiquidity event, so those 1000 units can never be burned. That keeps the pool from being drained to a zero-supply state where the price of an LP token would be undefined. The first provider into a new pool pays for it; later providers are unaffected. Read the value back with minimum_liquidity().

Value a position

An LP token is a claim on a fraction of both reserves. LP tokens carry 18 decimals.

amountA_out = liquidity × reserveA / totalSupply
amountB_out = liquidity × reserveB / totalSupply

The remove form computes exactly this before submitting, reading reserveA, reserveB and totalSupply from the pair contract and subtracting your slippage tolerance to derive the minimum amounts it will accept. GET /api/dex/pairs/<address> returns the same three numbers.

Reserves grow as fees accumulate while total supply does not, so the same LP balance redeems for more tokens over time. That growth is your fee income; fees are never paid out as a separate stream.

total_supply includes the permanently locked min_liquidity, so a pool's LP tokens never all belong to somebody. On a pool of any real size the 1000 locked units are a rounding error.

Where the 0.3% goes

By default all of it stays in the pool. The 3 units in 1000 the pair withholds from each swap never leave the reserves, so reserve0 × reserve1 grows on every trade while total_supply does not, and every LP token redeems for more. See the swap fee for where the 0.3% is enforced.

The contracts carry a protocol-fee switch of the kind Uniswap V2 has. It is off by default.

  • The factory owns the switch. AedexV2Factory holds fee_to: option(address) and fee_to_setter: address. init sets fee_to = None. Only the current fee_to_setter may call set_fee_to or hand the role on with set_fee_to_setter; anyone else gets AedexV2Factory: FORBIDDEN.
  • Each pair asks the factory on liquidity events only. mint_fee runs at the top of mint and burn (never during a swap) and reads factory.fee_to().

When that returns Some(fee_to), the pair mints new LP tokens to it:

// If fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)
let root_k      = Math.sqrt(reserve0 * reserve1)
let root_k_last = Math.sqrt(k_last)
if (root_k > root_k_last)
    let numerator   = state.total_supply * (root_k - root_k_last)
    let denominator = root_k*5 + root_k_last
    let liquidity   = numerator / denominator
    if (liquidity > 0) mint_update(Some(fee_to), liquidity)

The root_k*5 + root_k_last denominator is the standard one-sixth split: with the switch on, the 0.3% divides as 0.25% to liquidity providers and 0.05% to the fee collector; with it off, providers keep the whole 0.3%.

  • A protocol fee is never deducted from a trade. It is minted as fresh LP tokens at the next mint or burn, diluting existing providers. A swapper pays 0.3% either way.
  • Switching it on is not retroactive. k_last is the anchor, and it is 0 while the fee is off. The first mint or burn after the switch flips finds k_last == 0, mints nothing, and only then records the current product. Every liquidity event after that captures the growth since the previous one.

k_last holds reserve0 * reserve1 as of immediately after the most recent liquidity event, is written only while the fee is on, and is zeroed the first time mint_fee runs with the fee off. While the switch is off, k_last() reads 0 on every pair that has seen a liquidity event, which is a cheap way to check the regime from outside. For the current value of k, multiply the two reserves.

The pair contract

A pair is an AEX-9 token contract (the LP token) with reserves attached. Everything worth reading is a plain entrypoint:

GetterReturns
get_reserves()A record of reserve0, reserve1 and block_timestamp_last
total_supply()LP tokens outstanding, including the locked min_liquidity
minimum_liquidity()The pair's locked floor: 1000 unless overridden at creation
k_last()The fee bookmark above; 0 while the protocol fee is off
price0_cumulative_last(), price1_cumulative_last()TWAP accumulators
token0(), token1()The two token contracts, sorted by contract address
factory()The factory that created this pair
meta_info()The LP token's own name, symbol and decimals
aex9_extensions()["allowances"]: LP tokens are not mintable or burnable from outside

The LP token's metadata is composed from the two underlying tokens: name is "<A name> vs <B name>", symbol is "<A symbol>/<B symbol>", and decimals is always 18 regardless of what the two sides use.

Reserves are a cache. reserve0 and reserve1 are a snapshot the pair rewrites at the end of every mint, burn, swap and sync. The authoritative numbers are the pair's actual AEX-9 balances, and the two diverge whenever tokens arrive at the pair address without an entrypoint call. swap relies on exactly that gap to work out what you paid in. Two entrypoints reconcile them, and anyone may call either:

  • sync() raises reserves to match balances, so a stray transfer becomes everyone's.
  • skim(to) lowers balances to match reserves, paying the excess to to.

Tokens sent directly to a pair address are therefore not a donation to the pool: until somebody calls sync, anyone can take them with skim. Always add liquidity through the router. burn divides by balances rather than reserves (amount0 = liquidity * balance0 / total_supply), and under normal conditions the two are equal.

The price accumulators are oracle inputs. On the first state change in each new block timestamp, the pair adds the reserve ratio in UQ112x112 fixed point, multiplied by the elapsed milliseconds. Sample two heights and divide the difference by the time between them for a manipulation-resistant average price. Superhero prices from the instantaneous ratio and does not read them.

Nobody owns a pair. owner() returns Contract.address. There is no admin key, no pause and no upgrade path on a pair. fee_to_setter on the factory is the only privileged role in the AMM, and its power is limited to set_fee_to and set_fee_to_setter.

Remove liquidity

  1. Press Remove on a position card.
  2. Choose how much to withdraw. A full removal uses your raw on-chain LP balance, so no dust is left behind by rounding.
  3. Approve the LP token for the router. Removal needs an allowance on the pair contract itself, not on the two underlying tokens.
  4. Confirm.
Pair typeEntrypoint
Token + tokenremove_liquidity(token_a, token_b, liquidity, amount_a_min, amount_b_min, to, deadline)
Token + native AEremove_liquidity_ae(token, liquidity, amount_token_min, amount_ae_min, to, deadline)

The allowance goes on the pair contract because the router calls pair.transfer_allowance(Call.caller, pair.address, liquidity). It moves your LP tokens to the pair itself, and the pair burns whatever it finds in its own balance.

remove_liquidity_ae is remove_liquidity with the router as the recipient, followed by token.transfer(to, ...), wae.withdraw(amount_ae) and Chain.spend(to, amount_ae). The AE reaches you as native AE in the same transaction.

Both forms abort with AedexV2Router: INSUFFICIENT_A_AMOUNT or INSUFFICIENT_B_AMOUNT if the burn returns less than your minimums, and with AedexV2Router: EXPIRED past the deadline. Slippage above 100% or below 0 is rejected client-side on both add and remove.

Impermanent loss

Impermanent loss is the gap between what your deposit is worth inside a pool and what the same tokens would be worth if you had held them. It appears whenever the relative price of the two assets moves, and it is a loss against holding, not an absolute loss: the pool can be up in dollar terms while underperforming a wallet.

Why it happens

A constant-product pool holds reserves x and y with x × y = k and quotes a price of y / x. When the market price moves away from that, arbitrageurs trade against the pool until the two match, taking the asset that appreciated and leaving the one that did not. Your pool always ends up holding more of the loser and less of the winner.

A worked example

You are the only provider in a WAE/USDC pool.

Deposit
  1,000 WAE and 100 USDC          (1 WAE = 0.10 USDC)
  k = 1,000 × 100 = 100,000
  Position value = 100 + 100 = 200 USDC

WAE doubles: the market now prices 1 WAE = 0.20 USDC.
Arbitrage pushes the pool to the same price, holding k constant:

  price = y / x = 0.20   →   y = 0.20x
  x × 0.20x = 100,000    →   x² = 500,000   →   x = 707.11 WAE
  y = 0.20 × 707.11      →   y = 141.42 USDC

Position value in the pool
  707.11 WAE × 0.20  =  141.42 USDC
  plus              +   141.42 USDC
                    =   282.84 USDC

Had you just held
  1,000 WAE × 0.20  =   200.00 USDC
  plus 100 USDC     +   100.00 USDC
                    =   300.00 USDC

Impermanent loss  =  282.84 − 300.00  =  −17.16 USDC
                  =  −5.72% against holding

You went in with 1,000 WAE and came out with 707. The pool sold your appreciating asset all the way up. You ended up with more USDC, but not enough to compensate.

The general formula

For a 50/50 constant-product pool, with d the ratio by which the relative price changed:

IL = 2 × sqrt(d) / (1 + d) − 1

d = 1.00  (no change)   →    0.00%
d = 1.25                →   −0.62%
d = 1.50                →   −2.02%
d = 2.00  (2× move)     →   −5.72%
d = 4.00  (4× move)     →  −20.00%
d = 5.00                →  −25.46%
  • It is symmetric in log-price. A halving and a doubling produce the same −5.72%.
  • It is never positive. Any relative price change underperforms holding.
  • It is "impermanent" only while you stay in. If the price returns to where you entered, the gap closes. Withdraw at a divergent price and the loss is realised.

Fees are the other side of the trade

Impermanent loss is the cost of providing liquidity; the swap fee is the revenue: the full 0.3% while the protocol-fee switch is off, 0.25% if it is turned on. A pool with heavy volume and a stable price ratio is profitable; a pool with thin volume and a volatile ratio is not. Before committing, compare the pair's volume against its price movement on the pool explorer.

Other risks

  • Thin pools cut both ways. If you are the only meaningful provider, your position is what everyone trades against, and you absorb the full price impact of every large swap.
  • Both assets are exposed. A community token that goes to zero takes your pool share with it, however well the other side performed.
  • LP tokens are ordinary AEX-9 tokens with the allowances extension. An LP balance transfers like any other token; losing it, or granting an allowance to the wrong contract, loses the position.
  • DEX prices are not bonding-curve prices. A token's pool can quote something very different from its curve price, and nothing arbitrages the two automatically. See the DeFi overview.

Related