Skip to main content

The invariant

CPMM maintains the classic constant-product invariant on its two vaults: xy=kx \cdot y = k where x is the vault0 balance after any Token-2022 transfer fees on receipt, and similarly for y. Every swap must leave k' ≥ k after accounting for trade fees credited to the LP (the protocol, fund, and creator buckets are not counted toward k — they sit in the vault but are excluded from the curve view, see Fees on the curve below). k therefore grows monotonically over time as LPs accrue fees. LP shares are priced by the pool’s reserves, not by k: LP price in token0=xlpSupply,LP price in token1=ylpSupply\text{LP price in token0} = \frac{x}{\text{lpSupply}}, \qquad \text{LP price in token1} = \frac{y}{\text{lpSupply}} Burning ΔLP LP tokens returns exactly ΔLP × x / lpSupply of token0 and ΔLP × y / lpSupply of token1. Neither the curve nor k moves on deposit or withdrawal — only swaps change the price.

Fee model on the swap path

CPMM applies two independently-rated fees on every swap:
  • The trade fee is taken on the input side, charged at AmmConfig.trade_fee_rate. It is then split into LP, protocol, and fund shares (the LP share stays in the vault and grows k; the protocol and fund shares are extracted from vault accounting).
  • The creator fee (active only when enable_creator_fee == true) is charged at AmmConfig.creator_fee_rate. It is taken on the input side or the output side depending on PoolState.creator_fee_on and the swap direction (see products/cpmm/fees). It is its own bucket — never a slice of the trade fee.
Let:
  • FEE_RATE_DENOMINATOR = 1_000_000
  • trade_fee_rate — from AmmConfig, e.g., 2500 = 0.25% of the relevant volume side
  • creator_fee_rate — from AmmConfig, e.g., 1000 = 0.10% of the relevant volume side
  • protocol_fee_rate, fund_fee_rate — denominated in units of 1/FEE_RATE_DENOMINATOR of the trade fee, not of volume
When the creator fee is on the input side:
When the creator fee is on the output side:
In both cases the trade fee is split the same way:
The protocol_fee + fund_fee + creator_fee amount is held in the vaults but tracked separately on the pool state (protocol_fees_token*, fund_fees_token*, creator_fees_token*). When the constant-product invariant checks k' ≥ k, it uses vault balances minus all three accrued-but-unswept fees — so LPs capture only lp_fee. See products/cpmm/fees for the collection instructions and the worked numerical examples.

SwapBaseInput (input-exact)

“The user gives us exactly amount_in of the input mint and receives at least minimum_amount_out of the output mint.” Ignoring Token-2022 for a moment:
By algebra: amount_out=yΔxnetx+Δxnet\text{amount\_out} = \frac{y \cdot \Delta x_{\text{net}}}{x + \Delta x_{\text{net}}} where Δx_net = amount_in_after_trade_fee. The program then updates the vault accounting such that the portion of trade_fee owed to protocol/fund/creator sits in “accrued” buckets (not included in the curve’s next x), while the LP share does join x for the next swap.

Token-2022 on the input side

If the input mint has a transfer-fee extension, the mint deducts its fee on the transfer from user → vault. So the vault actually receives amount_in − transfer_fee_in. The CPMM program therefore computes:
and runs the curve against amount_in_after_trade_fee. This matters because the curve price is computed off the net amount that landed in the vault, not off the user’s headline amount.

Token-2022 on the output side

If the output mint has a transfer fee, the pool sends amount_out from its vault to the user. The mint will then skim its fee on the way out, so the user receives amount_out − transfer_fee_out(amount_out). The program computes amount_out from the curve as usual, but it is the integrator’s responsibility to convert the pool’s “vault send” number into a “user receive” number when showing quotes.

Slippage check

After computing amount_out:
If the output mint charges a transfer fee, the SDK applies the transfer fee before setting minimum_amount_out so the slippage constant is denominated in what the user will actually receive, not in what the vault sends.

SwapBaseOutput (output-exact)

“The user will receive exactly amount_out of the output mint and is willing to pay up to maximum_amount_in of the input mint.” Inverting the curve for Δx_net: Δxnet=xamount_outyamount_out\Delta x_{\text{net}} = \left\lceil \frac{x \cdot \text{amount\_out}}{y - \text{amount\_out}} \right\rceil The ceiling is important — it guarantees k' ≥ k after integer truncation. Then:
On Token-2022 input, wrap with:
so the user pays enough that after the mint’s transfer-fee deduction the pool still receives gross_needed.

Slippage check

Worked example

Pool state, ignoring Token-2022:
  • x = 1_000_000_000_000 (1,000,000.000000 of token0, 6 decimals)
  • y = 2_000_000_000_000 (2,000,000.000000 of token1, 6 decimals)
  • AmmConfig: trade_fee_rate = 2500, protocol_fee_rate = 120_000, fund_fee_rate = 40_000, creator_fee_rate = 0
User: SwapBaseInput with amount_in = 1_000_000_000 (1,000.000000 of token0). Creator fee is disabled (enable_creator_fee = false).
If the same pool had enable_creator_fee = true with creator_fee_rate = 1000 (0.10%) on the input side, the program would charge total_input_fee = ceil(1_000_000_000 * 3500 / 1_000_000) = 3_500_000, then split it as creator_fee = 1_000_000 and trade_fee = 2_500_000. The protocol/fund/LP arithmetic on trade_fee is unchanged from the example above — the creator fee is its own bucket, accrued to creator_fees_token0 and excluded from curve_x along with the protocol and fund buckets. If the input mint has a 1% Token-2022 transfer fee, the vault receives 990_000_000 tokens instead of 1_000_000_000, and every subsequent calculation uses that net amount.

Observation update rule

On every swap, the program evaluates whether to push a new observation into the ring buffer:
Two properties:
  • Cumulative price, not spot price. A single observation is not a price. To get a TWAP from time t0 to t1, read the observations closest to each end and compute (cumulative(t1) − cumulative(t0)) / (t1 − t0).
  • Samples are rate-limited. Back-to-back swaps in the same slot may share one observation. Reading an observation immediately after a swap can therefore look stale by one slot — this is normal.
More in products/clmm/accounts.

Fees on the curve

This is the subtle part and worth calling out. The curve arithmetic works against the net vault balances — i.e., raw SPL balance minus accrued protocol, fund, and creator fees (all three are independent buckets — see products/cpmm/fees). A concrete picture:
Consequences for integrators:
  • Do not quote from raw balances. Subtract the accrued-fee fields first, or call SwapBaseInput as a simulation and take its return.
  • CollectProtocolFee moves tokens out of the vault. After collection, raw_vault_balance drops but curve_balance is unchanged; the pool’s price does not move. This is deliberate.

Precision and overflow

  • All curve arithmetic uses u128 intermediates to prevent overflow on x * y.
  • Division rounds toward zero except for SwapBaseOutput’s Δx_net, which rounds up, and fee computation, which rounds up on the trade_fee and down on the sub-splits. These rounding directions are chosen so the invariant never decreases due to integer truncation.
  • Pools with extreme vault ratios (billions : 1) may hit precision floors on small trades; the program returns ZeroTradingTokens in that case. See reference/error-codes.

Where to go next

Sources: