Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions building-financial-products-in-solana/constant-product-amm.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The deeper the pool, the less any one trade moves the price.

Maria operates the AMM and is launching ACME today. She earns twice: an admin share of every trading fee, and LP fees on the liquidity she provides herself. Like every character in this book she starts with one SOL and 1,000 USDC; she also holds 400 ACME of founder stock.

Maria runs `initialize_config()` with a fee of 30 basis points and an admin share of 1,667 basis points, about one sixth of each fee. Then `initialize_pool()` for ACME/USDC, which creates the pool config, the LP mint, and the two empty vaults.
Maria runs `initialize_config()` with a fee of 30 basis points and an admin share of 1,667 basis points, about one sixth of each fee. Then `initialize_pool()` for ACME/USDC, which creates the pool config, the LP mint, and the two vaults, and takes the pool's first deposit in the same instruction. She believes a fair launch price is $2.25, so she passes 400 ACME and 900 USDC, the founder stock and nine-tenths of her cash.

Those two transactions created the venue's five accounts.

Expand All @@ -41,9 +41,9 @@ The tokens are held in two vaults, `pool_a` and `pool_b`, whose authority is the

Two more fields are `admin_fees_owed_a` and `admin_fees_owed_b` on the pool config. The admin's share of fees is never transferred during a swap; it accrues in these counters as a *virtual claim* while the tokens physically sit in the vaults. So every piece of LP-facing math runs on **effective reserves**: the raw vault balance minus the admin's owed slice. The admin's fees do not boost LP yield, do not distort the price, and get swept out later in one transfer per token. The order book kept its fees in a separate fee vault; this pool leaves them in the trading vaults and records the admin's share in these two counters.

Empty pools cannot price anything, so Maria seeds it. She believes a fair launch price is $2.25, so she runs `deposit_liquidity()` with her 400 ACME and 900 USDC, the founder stock and nine-tenths of her cash. **The first deposit sets the price**, because the ratio she deposits *is* the ratio. X = 400, Y = 900, K = 360,000.
**The first deposit sets the price**, because the ratio she deposits *is* the ratio, and that is why `initialize_pool()` takes it: a pool that existed empty between its creation and its first deposit would let whoever deposited first set the price for its creator. A zero on either side fails with `EmptyInitialDeposit`, so no pool exists with an empty vault. X = 400, Y = 900, K = 360,000.

Her LP tokens come from the formula the *Arithmetic of Money* chapter described: the **integer square root of X times Y**, computed by `integer_sqrt()`, the program's own Newton's method loop on u128, floored. The square root of 360,000 is exactly 600, so that is her LP position, minus one thing: `MINIMUM_LIQUIDITY`. That is a floor of 100 **minor units** of LP supply (the LP mint has six decimals, so a ten-thousandth of one LP token) that is **never minted to anyone**. It is not sent to a burn address; it never exists as supply, while the reserves it represents stay in the pool. Its value is a fraction of a cent. Why that floor exists is the inflation-attack defense, covered in the failure modes below.
Her LP tokens come from the formula the *Arithmetic of Money* chapter described: the **integer square root of X times Y**, computed by `initial_lp_amount()` with `integer_sqrt()`, the program's own Newton's method loop on u128, floored. The square root of 360,000 is exactly 600, so that is her LP position, minus one thing: `MINIMUM_LIQUIDITY`. That is a floor of 100 **minor units** of LP supply (the LP mint has six decimals, so a ten-thousandth of one LP token) that is **never minted to anyone**. It is not sent to a burn address; it never exists as supply, while the reserves it represents stay in the pool. Its value is a fraction of a cent. Why that floor exists is the inflation-attack defense, covered in the failure modes below. The transfers into the vaults and the mint to Maria are `deposit_and_mint_lp_tokens()`, the same code every later deposit ends with; only the arithmetic that chose the amounts is particular to the first.

![](figures/amm-first-deposit.svg)

Expand Down Expand Up @@ -114,26 +114,26 @@ Across any row, LPing wins once fee income outruns the gap, and for ordinary mov

## The Math: Floors Everywhere, and a Square Root by Newton

Every division in this program floors. Swap output, LP minting, and withdrawal amounts floor **in the pool's favor** (the only floor that favors the trader is the fee itself, clipped by at most one minor unit). The dust stays in the reserves and accrues to all LPs, the direction the *Arithmetic of Money* chapter sets. Every multiplication runs in u128 before its division (multiply before divide), and every step that can overflow is `checked_*` with `MathOverflow` on failure. The only unusual operation, the initial deposit's square root, is a short loop of Newton's method on integers, floored like everything else.
Every division in this program floors. Swap output, LP minting, and withdrawal amounts floor **in the pool's favor** (the only floor that favors the trader is the fee itself, clipped by at most one minor unit). The dust stays in the reserves and accrues to all LPs, the direction the *Arithmetic of Money* chapter sets. Every multiplication runs in u128 before its division (multiply before divide), and every step that can overflow is `checked_*` with `MathOverflow` on failure. The only unusual operation, the first deposit's square root in `initialize_pool()`, is a short loop of Newton's method on integers, floored like everything else.

## The Invariants: K Must Not Decrease

- After every swap, effective X times effective Y is at least what it was before, enforced onchain, after the transfers, not assumed from the algebra.
- The admin's owed fees never participate in pricing, LP minting, or withdrawal: all LP-facing math uses effective reserves.
- LP tokens are a fixed-share claim: minted in proportion to the deposit and burned in proportion to the withdrawal.
- The pool never prices against an empty side (see below).
- Both effective reserves are positive from creation on: `initialize_pool()` funds them, a withdrawal leaves the floor's share behind, and a swap's output is less than the reserve it comes from. The pool never prices against an empty side (see below).

## The Failure Modes: Empty Pools, First Deposits, and the Vacuous Invariant

- **The empty-reserve drain.** If one reserve were zero, the swap formula outputs the *entire* other reserve (output = input × other ÷ (0 + input) = other). The invariant check would not catch it either, because the pre-trade K would be 0 × other = 0, and "new K ≥ 0" holds vacuously. A check requires both effective reserves positive (`EmptyPoolReserve`) before any pricing runs.
- **The empty-reserve drain.** If one reserve were zero, the swap formula outputs the *entire* other reserve (output = input × other ÷ (0 + input) = other). The invariant check would not catch it either, because the pre-trade K would be 0 × other = 0, and "new K ≥ 0" holds vacuously. A check requires both effective reserves positive (`EmptyPoolReserve`) before any pricing runs, in the swap and in the deposit, which would otherwise take the depositor's ratio as the price of a pool that has none. No instruction reaches that state, so `test_pool_creation_cannot_be_front_run` puts a pool there by hand, zeroing a vault's balance, and checks the refusal.
- **The inflation attack.** The `MINIMUM_LIQUIDITY` floor makes the inflation attack from the *Adversary* chapter uneconomic. The smallest pool an attacker can open leaves them 1 minor unit of LP out of 101 units of supply. Rounding a deposit down to zero then takes a donation of about 101 times that deposit, and 100/101 of the donation belongs to units nobody can redeem. The floor has to be in both divisors: a deposit divided by the bare supply of 1 rounds down against a donation merely as large as itself. `test_donation_cannot_round_a_later_deposit_to_zero` runs the attack with a donation of 1,000,000 minor units to each vault: the victim's equal deposit is minted 100 minor units of LP, and the attacker's single unit redeems 9,950 minor units of token A.
- **The first deposit sets the price, and anyone can be first.** The floor bounds the share-math games a first depositor can play, and arbitrage moves whatever price they set to the market's.
- **The first deposit sets the price, so the creator makes it.** A pool that existed empty between its creation and its first deposit would let whoever deposited first set the price for its creator, and clamp the creator's own deposit to that ratio. `initialize_pool()` takes the first deposit in the instruction that creates the pool, so there is no empty pool to be first into; every later deposit is clamped to the creator's price, and arbitrage moves that price to the market's. `test_pool_creation_cannot_be_front_run` opens Maria's pool and has a second wallet deposit 400 ACME against 100 USDC in the next transaction: the USDC binds, 44.44 of the ACME is taken, and the pool still prices at $2.25 to the cent.
- **Slippage and sandwiches.** Every trade and deposit carries a caller-supplied minimum (`min_output_amount`, `minimum_lp_tokens_out`, the withdrawal minimums), and the tests check that each one rejects (`SlippageExceeded`, `DepositBelowMinimum`, `WithdrawalBelowMinimum`). What the program cannot do is choose the number for you; a zero minimum is legal and is what a sandwich attacker hopes you send.
- **Liquidity fragmentation.** Because `initialize_pool()` fails with `InvalidMintOrder` unless `mint_a` sorts below `mint_b`, each pair of tokens can have only one pool, so its liquidity is never split between two.

## Testing Considerations

The suite (`tests/test_swap.rs`) covers the deposit, swap, withdrawal, and fee sections of this chapter. Two tests, `test_invariant_holds_after_normal_swap` and `test_invariant_holds_after_b_to_a_swap`, check K's direction both ways, while `test_lp_mint_after_swap_uses_effective_reserves` and `test_deposit_after_swap_uses_shifted_effective_ratio` check the admin-fee exclusion, the accounting a refactor is most likely to break. Two more, `test_deposit_clamps_excess_amount_a` and `_b`, check the ratio clamp from both directions (both directions of every symmetric flow), and the three reverts-below-minimum tests check every slippage minimum. If you write one invariant test for your own AMM, make it the round trip: swap in, swap back, and assert the pool did not lose value.
The suite (`tests/test_swap.rs`) covers the creation, deposit, swap, withdrawal, and fee sections of this chapter: every test opens its pool through `initialize_pool()`, and `test_pool_creation_cannot_be_front_run` runs the hostile deposit against Maria's. Two tests, `test_invariant_holds_after_normal_swap` and `test_invariant_holds_after_b_to_a_swap`, check K's direction both ways, while `test_lp_mint_after_swap_uses_effective_reserves` and `test_deposit_after_swap_uses_shifted_effective_ratio` check the admin-fee exclusion, the accounting a refactor is most likely to break. Two more, `test_deposit_clamps_excess_amount_a` and `_b`, check the ratio clamp from both directions (both directions of every symmetric flow), and the three reverts-below-minimum tests check every slippage minimum. If you write one invariant test for your own AMM, make it the round trip: swap in, swap back, and assert the pool did not lose value.

## What You Learnt from Building the Constant-Product AMM

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading