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
- Open
/defi/pooland connect your wallet. - Pick the two tokens. Native
AEis selectable; the app routes AE pairs through the WAE address behind the scenes. - 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.
- 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 × 100The 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 type | Entrypoint |
|---|---|
| Token + token | add_liquidity(token_a, token_b, amount_a_desired, amount_b_desired, amount_a_min, amount_b_min, to, min_liquidity, deadline) |
| Token + native AE | add_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 / totalSupplyThe 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.
AedexV2Factoryholdsfee_to: option(address)andfee_to_setter: address.initsetsfee_to = None. Only the currentfee_to_settermay callset_fee_toor hand the role on withset_fee_to_setter; anyone else getsAedexV2Factory: FORBIDDEN. - Each pair asks the factory on liquidity events only.
mint_feeruns at the top ofmintandburn(never during a swap) and readsfactory.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
mintorburn, diluting existing providers. A swapper pays 0.3% either way. - Switching it on is not retroactive.
k_lastis the anchor, and it is0while the fee is off. The firstmintorburnafter the switch flips findsk_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:
| Getter | Returns |
|---|---|
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 toto.
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
- Press Remove on a position card.
- Choose how much to withdraw. A full removal uses your raw on-chain LP balance, so no dust is left behind by rounding.
- Approve the LP token for the router. Removal needs an allowance on the pair contract itself, not on the two underlying tokens.
- Confirm.
| Pair type | Entrypoint |
|---|---|
| Token + token | remove_liquidity(token_a, token_b, liquidity, amount_a_min, amount_b_min, to, deadline) |
| Token + native AE | remove_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 holdingYou 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
allowancesextension. 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
- Swapping: the trades your pool earns fees from, and where price impact comes from.
- Wrapped AE: why an "AE pool" is really a WAE pool.
- Exploring the DEX: check a pair's depth and volume before providing.
- Token risks: the risks of the underlying assets you would be pooling.
- What is impermanent loss? (Uniswap Labs) (opens in a new tab): the same mechanism on a constant-product AMM.