Liquidity
Module name and store key: liquidity. Consensus version 5.
x/liquidity is a constant-product automated market maker. It holds two-denom
pools, mints LP shares through tokenfactory, lets LPs stake
those shares to earn a share of swap fees and a governance-funded emission, and
governs the whole pool lifecycle: pause, fee changes, ownership transfer,
closure, and retirement.
Pools
A pool is identified by a uint64 id and holds two denoms in canonical
lexicographic order, so denom0 < denom1 and a pair always resolves to the same
pool.
| Field | Meaning |
|---|---|
pool_id, owner | Identity and administrative owner. |
denom0, denom1 | The pair, sorted. |
reserve0, reserve1 | Reserves, mirrored in the liquidity-reserve-{id} module account. |
total_shares | LP supply, mirrored in the module's mint and burn rights. |
lp_share_denom | Always factory/liquidity/pool/{pool_id}. |
swap_fee_ppm, lp_fee_share_ppm, staking_fee_share_ppm | Fee level and its split. Parts per million, scale 1,000,000. |
paused, emergency_paused | Owner-level and governance-level stop. |
closing | Set by governance; the pool only accepts the final withdrawal. |
farm_lock_duration_blocks | Minimum staking period for LP shares. |
cumulative_lp_fees0, cumulative_lp_fees1 | Running LP fee counters. |
pending_* | Queued fee and ownership changes with their activation heights. |
Three module accounts back a pool, all created on demand with no special
permissions: liquidity-reserve-{id}, liquidity-farm-{id}, and
liquidity-fee-{id}.
The math
Every number is integer, and every division floors. The fee scale is 1,000,000.
Initial shares are the geometric mean of the two initial amounts:
shares = floor(sqrt(amount0 * amount1))
The result must be at least min_initial_liquidity, which is 2 by default.
Adding liquidity credits the smaller of the two proportional shares and returns the unused remainder:
shares0 = floor(amount0 * totalShares / reserve0)
shares1 = floor(amount1 * totalShares / reserve1)
shares = min(shares0, shares1)
used0 = floor(shares * reserve0 / totalShares)
used1 = floor(shares * reserve1 / totalShares)
Removing liquidity pays the proportional share of each reserve:
amount_i = floor(shares * reserve_i / totalShares)
Swapping in nets the fee off the top and applies the constant-product rule:
fee = floor(amountIn * swapFeePPM / 1_000_000)
netIn = amountIn - fee
amountOut = floor(reserveOut * netIn / (reserveIn + netIn))
netIn must be positive, so a swap too small to cover its own fee is rejected
rather than rounded to zero.
Swapping out cannot simply be inverted, because fee collection floors and the algebraic inverse is then only an upper bound. The implementation takes a ceiling bound and binary-searches for the smallest gross input whose net value still covers the reserve increase.
Fee split. On every swap:
totalFee = floor(amountIn * swapFeePPM / 1_000_000)
stakingFee = floor(totalFee * stakingFeeSharePPM / 1_000_000)
lpFees += totalFee - stakingFee
The staking fee leaves the reserve, moves to liquidity-fee-{id}, and is split
across stakers in proportion to their staked shares. The rounding remainder goes
to the lexicographically smallest owner address, so the split is exact and
deterministic. When a pool has no staked shares, the whole fee stays in the LP
counter.
:::note Cumulative LP fees are a counter only
cumulative_lp_fees0 and cumulative_lp_fees1 accumulate, but the current code
has no message that pays them out. They are observable, not withdrawable.
:::
Farming
Staking LP shares earns a pro-rata share of the pool's staking fee plus any emission schedule the owner funds.
A farm position is a per-(pool, owner) amount plus a list of lots, one per deposit, each recording its deposit and unlock height.
| Rule | Behaviour |
|---|---|
| Deposit | Adds a lot with unlock_height = height + farm_lock_duration_blocks. |
| Withdraw | Strict FIFO. If any lot is still locked, the whole withdrawal is rejected. |
| Emergency withdrawal | Governance-triggered paths skip the lock check and forfeit the accrued rewards. |
| Claim | Pays both the accrued fees and the accrued emission, and clears the record. |
The pool must not be paused, the LP denom must be poolable, and the position and lot counts are pre-flighted against their caps before any LP is transferred.
Emission schedules
A schedule is a governance-approved stream of ucen from the pool owner to
stakers, bounded by block heights.
budget, distributed, start_height, end_height, last_height
pending_end_height, pending_budget, pending_activation_height
At BeginBlock each schedule settles:
rate = budget / (end_height - start_height)
amount = min(rate * (end_height - last_height), budget - distributed)
Each staker receives floor(amount * shares / stakedShares). Whatever the
per-user floor leaves behind stays in the schedule and remains refundable.
Changes are queued: an update takes effect after emission_cooldown_blocks and
extends the end height, and a refund is only allowed once the schedule has
reached its end height.
Pool lifecycle
| Transition | Who | Delay |
|---|---|---|
| Pause / unpause | Pool owner | Immediate |
| Emergency pause / unpause | Module authority | Immediate |
| Fee change | Pool owner | fee_update_cooldown_blocks |
| Ownership transfer | Current owner, then the nominee | ownership_cooldown_blocks |
| Emergency ownership recovery | Module authority | emergency_ownership_cooldown_blocks |
| Closure | Module authority | Immediate; sets closing and clears all pending state |
| Retirement | Any holder | Only by withdrawing every share |
All cooldowns fall back to 720 blocks when the parameter is zero, and none may exceed 2,628,000 blocks.
The owner cannot cancel a pending emergency ownership transfer, and the module authority cannot initiate a normal one. The two paths are separate on purpose.
Messages
| Message | Signer | Constraint |
|---|---|---|
MsgCreatePool | sender | Pool creation enabled, fee parameters in range, both assets poolable, no LP denoms, minimum initial liquidity met. |
MsgAddLiquidity | sender | Optional min_shares slippage floor. |
MsgRemoveLiquidity | sender | min_amount0 and min_amount1 slippage floors. Withdrawing every share closes the pool. |
MsgSwapExactAmountIn | sender | Optional token_out_min_amount slippage floor. |
MsgSwapExactAmountOut | sender | token_in_max_amount is required. |
MsgDepositFarm / MsgWithdrawFarm / MsgClaimFarm | sender | See farming. |
MsgCreateEmissionSchedule / MsgUpdateEmissionSchedule / MsgRefundEmissionSchedule | sender | Schedule owner. |
MsgRequestFeeUpdate / MsgCancelFeeUpdate | sender | Pool owner, with the split summing to the full fee scale. |
MsgRequestOwnershipTransfer / MsgAcceptOwnershipTransfer / MsgCancelOwnershipTransfer | sender | Current owner, then the nominee. |
MsgRequestEmergencyOwnershipTransfer / MsgActivateEmergencyOwnershipTransfer | authority | Module authority. |
MsgPausePool / MsgUnpausePool | sender | Pool owner. |
MsgEmergencyPausePool / MsgEmergencyUnpausePool | authority | Module authority. |
MsgClosePool | authority | Module authority. Idempotent. |
MsgRecoverPoolCoins | sender | Pool owner, one of the three accounts, and only for denoms that are not registered IBC denoms. |
MsgRegisterIBCDenom / MsgRemoveIBCDenom | authority | Module authority. The denom must be ibc/{sha256(full_trace)} with a consistent trace. |
MsgUpdateParams | authority | Module authority. |
The IBC denom registry is a governance allow-list: registering a denom marks it approved and records its port, channel, and base denom, and it gates risk-increasing operations on that asset.
Params
| Field | Default |
|---|---|
pool_creation_enabled | true |
min_swap_fee_ppm / max_swap_fee_ppm | 100 / 10,000 |
fee_scale | 1,000,000 (must equal this exactly) |
emission_cooldown_blocks | 720 |
ownership_cooldown_blocks | 720 |
fee_update_cooldown_blocks | 720 |
emergency_ownership_cooldown_blocks | 720 |
min_lp_fee_share_ppm / max_lp_fee_share_ppm | 0 / 1,000,000 |
min_initial_liquidity | 2 |
max_pools_per_owner | 0, meaning unlimited |
Per-pool defaults when a message leaves the field at zero: swap fee 3,000 ppm, LP share 300,000 ppm, staking share 700,000 ppm.
Queries
| Query | Returns |
|---|---|
Params | The module parameters. |
Pool, PoolByPair, Pools | One pool, the pool for a denom pair, or a paginated list. |
PoolShares | Total, circulating, locked, and farm-staked shares. |
FarmPosition | A position's amount, claimable fee rewards, and claimable emission. |
EmissionSchedule | A schedule and its remaining budget. |
PendingUpdates | Every queued fee and ownership change with its activation height. |
PoolAccountBalances | The three module account balances. |
SimulateSwapExactAmountIn / SimulateSwapExactAmountOut | A quote without touching state, using the fee that will actually be active. |
IBCDenom / IBCDenoms | The IBC registration allow-list. |
Queries read a private copy of the state, so a query can never write.
:::note No APY query
There is no annualised or APY query in the current code. Fee and emission
accrual are available per position through FarmPosition, and the running totals
are visible in Pool.
:::
Invariants and limits
BeginBlock loads the state, activates due fee updates, and settles every
emission schedule. EndBlock runs the in-memory invariants on every block, and
reconciles against bank balances and supply every 10,000 blocks when the bank
exposes those readers.
A violation emits liquidity_invariant_violation and returns no error, so block
finalization is never interrupted by an alarm.
| Limit | Value |
|---|---|
| Pools | 10,000 |
| Genesis records | 100,000 |
| Farm positions / per pool | 10,000 / 1,000 |
| Farm lots / per position | 100,000 / 10,000 |
| Farm reward records / per pool | 10,000 / 1,000 |
| Emission reward records / per pool | 10,000 / 1,000 |
| Reward denoms per record | 64 |
| Farm lock duration | 2,628,000 blocks |
| Any cooldown | 2,628,000 blocks |
Every state amount is bounded at 2^256 − 1. Limits are checked before any
transfer: if a new fee denom would push a record past its cap, the swap is
rejected and accruedFees is left untouched.
Events
| Event | Attributes |
|---|---|
liquidity_add, liquidity_remove | pool_id, sender, shares, used0/amount0, used1/amount1 |
swap_exact_in, swap_exact_out | pool_id, denom_in, amount_in, denom_out, amount_out |
farm_deposit, farm_withdraw, farm_claim | pool_id, owner, amount, claimed |
fee_update_pending, fee_update_activated, fee_update_cancelled | pool_id and the fee triple |
emission_schedule_created, emission_schedule_update_pending, emission_schedule_refunded | pool_id |
ownership_transfer_requested, ownership_transferred, ownership_transfer_cancelled | pool_id, owner, new_owner |
emergency_ownership_recovery_requested, emergency_ownership_recovered | pool_id, owner, new_owner |
pool_closing, pool_retired | pool_id |
pool_emergency_pause, pool_emergency_unpause | pool_id |
pool_coins_recovered | pool_id, owner, account_type, denom, amount |
liquidity_invariant_violation | height, error |
Errors
| Code | Identifier | Raised when |
|---|---|---|
| 1100 | ErrInvalidPool | Pool state or parameters are inconsistent. |
| 1101 | ErrInvalidParams | A parameter is outside its allowed range. |
| 1102 | ErrInvalidGenesis | The genesis state is inconsistent. |
| 1103 | ErrInvalidAmount | An amount is nil, zero, negative, or out of bounds. |
| 1104 | ErrInsufficientOutput | A slippage bound was violated. |
| 1105 | ErrInvalidFee | The fee or fee split is invalid. |
| 1106 | ErrOwnershipTransfer | A transfer is not allowed at this time. |
| 1107 | ErrRecovery | A coin recovery is not permitted. |
| 1108 | ErrLPSharePolicy | The LP denom does not satisfy the tokenfactory policy. |
Aggregate record limits and fee record caps are reported as plain errors rather than registered errors.
CLI
x/liquidity defines no autocli commands. Use gRPC, or build the transactions as
JSON and broadcast them. The gRPC gateway serves /centerchain/liquidity/v1/...
for queries.