diff --git a/finance/token-swap/README.md b/finance/token-swap/README.md index 75205bde..bcb2efc2 100644 --- a/finance/token-swap/README.md +++ b/finance/token-swap/README.md @@ -8,6 +8,7 @@ The pool keeps `x * y = K` invariant: if `x` is the reserve of token A and `y` i - A singleton `Config` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) at seeds `[b"config"]` holding the trading-fee bps, admin-share bps, and admin authority. - A unique pool PDA per `(config, mint_a, mint_b)`, with `mint_a < mint_b` for canonical addressing. +- A pool opens with its creator's deposit: `initialize_pool` takes `amount_a` and `amount_b`, moves them into the reserves it creates, and mints the creator's LP tokens, so the pool's first price is set by whoever created it, in the transaction that created it. A pool never exists with an empty reserve. - LP positions tracked as SPL tokens via a per-pool `liquidity_provider_mint`, so they're composable with any wallet or downstream [program](https://solana.com/docs/terminology#program). - Deposits clamped to the current pool ratio (Uniswap V2's `mint()` pattern), with caller amounts treated as upper bounds and all ratio math done in `u128` with checked arithmetic. - Constant-product (`x * y = k`) swaps with a trading fee split between LPs and the admin, configured by `Config.fee` and `Config.admin_share_bps`. @@ -102,21 +103,25 @@ Initializes the singleton `Config` account with the supplied `admin`, `fee`, and ### `initialize_pool` -Initializes a `PoolConfig` account, an LP mint (`liquidity_provider_mint`) whose mint authority is `pool_config`, and the two pool reserve token accounts (`pool_a`, `pool_b`), the associated token accounts of `pool_config`. Enforces `mint_a < mint_b` for canonical pool addressing. +Initializes a `PoolConfig` account, an LP mint (`liquidity_provider_mint`) whose mint authority is `pool_config`, and the two pool reserve token accounts (`pool_a`, `pool_b`), the associated token accounts of `pool_config`, then takes the creator's first deposit: `amount_a` of token A and `amount_b` of token B move from the creator's token accounts into the reserves, and the creator's LP tokens are minted to their LP token account, which the handler also creates. Enforces `mint_a < mint_b` for canonical pool addressing. + +- The first deposit sets the pool's price (the ratio of its reserves), so both amounts must be nonzero; a zero on either side fails with `EmptyInitialDeposit` and nothing is created. Taking the deposit in the same instruction means no pool ever exists empty, so nobody can set a pool's price for its creator by depositing first. `test_pool_creation_cannot_be_front_run` runs the attack: a deposit at a hostile ratio right after the pool opens is clamped to the creator's price, and a deposit against an empty reserve fails with `EmptyPoolReserve`. +- The creator is minted `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens (`liquidity::initial_lp_amount`), computed with a `u128` integer-sqrt (Newton's method), no floats. If `sqrt(amount_a * amount_b)` is below `MINIMUM_LIQUIDITY`, the handler fails with `DepositTooSmall`. The floor is never minted to anyone; from then on `deposit_liquidity` and `withdraw_liquidity` divide by `supply + MINIMUM_LIQUIDITY`, so its share of the reserves stays in the pool and no withdrawal can empty a reserve. +- The transfers and the LP mint are the same code `deposit_liquidity` ends with (`liquidity::deposit_and_mint_lp_tokens`); only the arithmetic that decides the amounts differs. ### `deposit_liquidity` Transfers token A and token B from the depositor to the pool, then mints LP tokens to the depositor. `amount_a` and `amount_b` are treated as **upper bounds** - the caller's maximum willingness on each side. The contract clamps both numbers down to the largest pair that lies on the current price line, then pulls exactly that pair. `minimum_lp_tokens_out` is the caller's **lower bound** on what they're willing to receive in LP tokens; the handler reverts with `DepositBelowMinimum` if the post-clamp LP mint amount falls below it. Pass `0` to opt out (any non-zero mint is acceptable). -- For the first deposit, both amounts are used as-is and the LP amount is `sqrt(amount_a * amount_b)` - computed with a `u128` integer-sqrt (Newton's method), no floats - with `MINIMUM_LIQUIDITY` locked away forever (to prevent the empty-pool edge case). No admin fees can be owed yet, so this case is unchanged by the admin-fee mechanism. -- For later deposits, the amounts are clamped to the current pool ratio (Uniswap V2's `mint()` pattern): +- The pool already has a price, set by its creator in `initialize_pool`, so every deposit is clamped to the current pool ratio (Uniswap V2's `mint()` pattern): 1. Compute `amount_b_required = amount_a * effective_pool_b / effective_pool_a`. 2. If `amount_b_required ≤ amount_b`, use `(amount_a, amount_b_required)` - the depositor offered enough B, so we take the full A and clamp B down. 3. Otherwise, compute `amount_a_required = amount_b * effective_pool_a / effective_pool_b` and use `(amount_a_required, amount_b)` - B is the binding side, so we take the full B and clamp A down. -- The LP amount for a later deposit is `min(amount_a * total / effective_pool_a, amount_b * total / effective_pool_b)` with `total = liquidity_provider_mint.supply + MINIMUM_LIQUIDITY`: the unminted floor counts as supply, exactly as it does in `withdraw_liquidity`, so a deposit is minted the share of the pool it can later redeem. Counting the floor on both sides is also what makes the donation (inflation) attack cost the attacker: to round a later deposit down to zero LP tokens, the attacker has to donate at least `MINIMUM_LIQUIDITY + 1` times that deposit, and the floor keeps its share of the donation. +- The LP amount is `min(amount_a * total / effective_pool_a, amount_b * total / effective_pool_b)` with `total = liquidity_provider_mint.supply + MINIMUM_LIQUIDITY`: the unminted floor counts as supply, exactly as it does in `withdraw_liquidity`, so a deposit is minted the share of the pool it can later redeem. Counting the floor on both sides is also what makes the donation (inflation) attack cost the attacker: to round a later deposit down to zero LP tokens, the attacker has to donate at least `MINIMUM_LIQUIDITY + 1` times that deposit, and the floor keeps its share of the donation. - All ratio math runs in `u128` with checked arithmetic. No floats are used for money; rounding is always toward the pool (the depositor never gets a sub-base-unit advantage). - The ratio is computed on the **effective reserves** (`pool_X.amount - admin_fees_owed_X`). The admin's owed slice isn't LP-claimable capital, so it doesn't shift the deposit ratio. - If the clamp rounds one of the amounts down to zero (e.g. a depositor offering a sub-base-unit fraction against a thick pool), the handler reverts with `DepositAmountTooSmall` rather than minting LP shares against a zero contribution. +- If either effective reserve is zero, the handler reverts with `EmptyPoolReserve` rather than let the depositor set the price. The state is not reachable (every pool opens with both reserves positive, a withdrawal leaves the floor's share behind, and a swap's output is always less than the reserve it comes from); the check is there so the ratio math never divides by zero if that ever stopped being true. - If the computed LP-token amount falls below `minimum_lp_tokens_out`, the handler reverts with `DepositBelowMinimum`. This is the depositor's slippage guard for cases where the pool ratio shifted between offchain quote time and tx landing. ### `swap_tokens` @@ -157,7 +162,7 @@ A worked example, end to end, using this program. The example uses three tokens: **Cast:** -- **Alice** - AMM operator. Deploys and runs the exchange. Earns a slice of every trading fee via the admin fee mechanism; also earns LP [yield](https://www.investopedia.com/terms/y/yield.asp) on her own initial deposits. Wants real usage so fee income compounds. She calls `initialize_config` to fix the trading fee at 0.3% and sets `admin_share_bps = 1667` so she earns ~1/6 of every trading fee (LPs keep the other ~5/6). She seeds both the NVDAx/USDC pool and the TSLAx/USDC pool herself (eating the locked `MINIMUM_LIQUIDITY` cost) so users have something to trade from day one. +- **Alice** - AMM operator. Deploys and runs the exchange. Earns a slice of every trading fee via the admin fee mechanism; also earns LP [yield](https://www.investopedia.com/terms/y/yield.asp) on her own initial deposits. Wants real usage so fee income compounds. She calls `initialize_config` to fix the trading fee at 0.3% and sets `admin_share_bps = 1667` so she earns ~1/6 of every trading fee (LPs keep the other ~5/6). She opens both the NVDAx/USDC pool and the TSLAx/USDC pool herself, each with her own first deposit (paying the withheld `MINIMUM_LIQUIDITY`), so users have something to trade from the moment each pool exists. - **Bob** - yield farmer / [liquidity provider](https://www.investopedia.com/terms/l/liquidity-provider.asp). Has idle capital (NVDAx and USDC) earning nothing. Wants to earn [passive income](https://www.investopedia.com/terms/p/passiveincome.asp) from the swap fees the pool collects, without actively trading. - **Carol** - retail trader. Holds USDC and has a bullish [thesis](https://www.investopedia.com/terms/i/investmentthesis.asp) on NVIDIA: she believes NVDAx will appreciate. She wants to swap USDC for NVDAx quickly, without a centralised exchange account. She also later buys TSLAx on the TSLAx/USDC pool. - **Dave** - [arbitrageur](https://www.investopedia.com/terms/a/arbitrage.asp). Profits by trading the gap between the pool's mid-price and the offchain market price. Side effect: his trades drag the pool price back toward fair value. @@ -176,7 +181,9 @@ The singleton `Config` account is set once per deployed program. Every pool inhe `Config` exists. No pools yet, no liquidity yet. -### Step 2 - Alice creates the NVDAx/USDC pool +### Step 2 - Alice creates the NVDAx/USDC pool with its first deposit + +Alice picks a 1:5 ratio so the NVDAx/USDC pool launches at ~5 USDC per NVDAx. She opens the pool with **20 NVDAx and 100 USDC**; the pool is created, funded and priced in this one instruction. - **Handler:** `initialize_pool` - **Accounts (`InitializePoolAccounts`):** @@ -185,15 +192,24 @@ The singleton `Config` account is set once per deployed program. Every pool inhe - `liquidity_provider_mint` (created) - the LP-token mint, authority = `pool_config` - `mint_a` = NVDAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) - `pool_a`, `pool_b` (created, ATAs owned by `pool_config`) - the NVDAx and USDC reserves + - `creator` = Alice (signer) + - `creator_token_a` - Alice's NVDAx ATA, `creator_token_b` - Alice's USDC ATA + - `liquidity_provider_token` - Alice's LP-token ATA (created) - `payer` = Alice - token, ATA, system programs -- **Args:** none +- **Args:** `amount_a = 20`, `amount_b = 100` + +Math: -NVDAx/USDC pool exists; reserves are empty. No one can swap yet. +- 20 NVDAx move from Alice's NVDAx account into `pool_a`, and 100 USDC from her USDC account into `pool_b`. +- LP tokens minted to Alice: `sqrt(20 × 100) = sqrt(2000) ≈ 44.72`, minus the withheld `MINIMUM_LIQUIDITY = 100` floor (base units - negligible at major-unit scale). +- Alice receives ~44.72 LP tokens. The 100 base-unit floor is never minted to anyone; its share of the reserves stays in the pool. Alice pays that cost as the price of opening the pool. -### Step 2b - Alice creates the TSLAx/USDC pool +NVDAx/USDC pool state: **20 NVDAx, 100 USDC**. Mid-price = 5. Alice owns 100% of withdrawable LP supply on this pool. Trading can start. -Alice immediately creates a second pool for TSLAx (Tesla xStock, ~180 USDC each). The handler and account shape are identical to Step 2; only the mints differ. +### Step 2b - Alice creates the TSLAx/USDC pool with its first deposit + +Alice immediately opens a second pool for TSLAx (Tesla xStock, ~180 USDC each) with **1 TSLAx and 180 USDC**, a 1:180 ratio matching the offchain price. The handler and account shape are identical to Step 2; only the mints and amounts differ. - **Handler:** `initialize_pool` - **Accounts (`InitializePoolAccounts`):** @@ -202,53 +218,38 @@ Alice immediately creates a second pool for TSLAx (Tesla xStock, ~180 USDC each) - `liquidity_provider_mint` (created) - a separate LP-token mint for this pool, authority = `pool_config` - `mint_a` = TSLAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) - `pool_a`, `pool_b` (created, ATAs owned by `pool_config`) - the TSLAx and USDC reserves + - `creator` = Alice (signer) + - `creator_token_a` - Alice's TSLAx ATA, `creator_token_b` - Alice's USDC ATA + - `liquidity_provider_token` - Alice's LP-token ATA for this pool (created) - `payer` = Alice - token, ATA, system programs -- **Args:** none - -TSLAx/USDC pool exists; reserves are empty. Alice now needs to seed both pools before trading is possible. +- **Args:** `amount_a = 1`, `amount_b = 180` -Alice seeds the TSLAx/USDC pool with **1 TSLAx and 180 USDC** (a 1:180 ratio matching the offchain price), calling `deposit_liquidity` with `amount_a = 1`, `amount_b = 180`, `minimum_lp_tokens_out = 0`. The first-deposit math applies: LP tokens minted = `sqrt(1 × 180) ≈ 13.41`, minus the locked `MINIMUM_LIQUIDITY` floor. +LP tokens minted to Alice: `sqrt(1 × 180) ≈ 13.41`, minus the withheld `MINIMUM_LIQUIDITY` floor. TSLAx/USDC pool state: **1 TSLAx, 180 USDC**. Mid-price = 180. Alice owns 100% of withdrawable LP supply on this pool. -### Step 3 - Alice seeds initial liquidity in the NVDAx/USDC pool +### Step 3 - Bob adds liquidity -Alice picks a 1:5 ratio so the NVDAx/USDC pool launches at ~5 USDC per NVDAx. She deposits **20 NVDAx and 100 USDC**. +At the current 1:5 ratio, Bob deposits **100 NVDAx and 500 USDC**. - **Handler:** `deposit_liquidity` - **Accounts (`DepositLiquidityAccounts`):** - `pool_config`, `liquidity_provider_mint` - - `depositor` = Alice (signer) + - `depositor` = Bob (signer) - `mint_a`, `mint_b` - `pool_a`, `pool_b` (the pool's reserves) - - `liquidity_provider_token` - Alice's LP-token ATA (created) - - `token_a` - Alice's NVDAx ATA, `token_b` - Alice's USDC ATA - - `payer` = Alice + - `liquidity_provider_token` - Bob's LP-token ATA (created) + - `token_a` - Bob's NVDAx ATA, `token_b` - Bob's USDC ATA + - `payer` = Bob - token, ATA, system programs -- **Args:** `amount_a = 20`, `amount_b = 100`, `minimum_lp_tokens_out = 0` (initial deposit - Alice is the only LP, no slippage risk; production code should still set a floor to guard against frontrun pool-creations) - -Math: - -- LP tokens minted on the first deposit: `sqrt(20 × 100) = sqrt(2000) ≈ 44.72`. -- Minus the locked `MINIMUM_LIQUIDITY = 100` floor (base units - negligible at major-unit scale). -- Alice receives ~44.72 LP tokens. The 100 base-unit dust is locked forever, owned by no one. Alice eats that cost as the price of bootstrapping. - -NVDAx/USDC pool state: **20 NVDAx, 100 USDC**. Mid-price = 5. Alice owns 100% of withdrawable LP supply on this pool. - -### Step 4 - Bob adds liquidity - -At the current 1:5 ratio, Bob deposits **100 NVDAx and 500 USDC**. - -- **Handler:** `deposit_liquidity` -- **Accounts:** same shape as Step 3, `depositor` = Bob - **Args:** `amount_a = 100`, `amount_b = 500`, `minimum_lp_tokens_out = 223_000_000` (Bob quoted ~223.6 LP offchain and is unwilling to accept less than ~223.0 if the pool shifts before his tx lands; units here are LP base units at the LP mint's decimals) Math: subsequent deposits get `min(amount_a / pool_a, amount_b / pool_b) × (current_lp_supply + MINIMUM_LIQUIDITY) = min(100/20, 500/100) × 44.72 ≈ 223.6` LP tokens (the 100 base-unit floor is too small to show at this precision). NVDAx/USDC pool state: **120 NVDAx, 600 USDC**. LP supply ~268.32. Bob owns ~83%, Alice ~17%. -### Step 5 - Carol buys NVDAx with USDC +### Step 4 - Carol buys NVDAx with USDC - **Handler:** `swap_tokens` - **Accounts (`SwapTokensAccounts`):** @@ -278,12 +279,12 @@ Carol gets ~2.156 NVDAx. Effective price ~5.10 USDC/NVDAx - worse than mid-price NVDAx/USDC pool state: **117.844 NVDAx, 611 USDC raw** (`admin_fees_owed_a = 0`, `admin_fees_owed_b ≈ 0.0055`). Mid-price on the effective reserves drifted up to ~5.18. -### Step 6 - Dave arbitrages the NVDAx/USDC pool +### Step 5 - Dave arbitrages the NVDAx/USDC pool NVDAx still trades at 5.00 offchain; the NVDAx/USDC pool now says 5.18. There's a profitable trade: buy NVDAx offchain at 5.00, sell it into the pool at ~5.18. Dave does it. - **Handler:** `swap_tokens` -- **Accounts:** same shape as Step 5, `trader` = Dave +- **Accounts:** same shape as Step 4, `trader` = Dave - **Args:** `input_is_token_a = true` (input is token A = NVDAx), `input_amount = 2.15`, `min_output_amount = 10.5` Math: @@ -302,7 +303,7 @@ Dave paid ~10.75 USDC offchain for 2.15 NVDAx, sold into the pool for ~10.92 USD NVDAx/USDC pool state: **119.987 NVDAx, 600.07 USDC raw**, with `admin_fees_owed_a ≈ 0.001075` and `admin_fees_owed_b ≈ 0.0055`. Mid-price on the effective reserves back to ~5.00 - *because* that's the price at which Dave's profit hit zero and he stopped. -### Step 7 - Carol buys TSLAx with USDC +### Step 6 - Carol buys TSLAx with USDC Separately, Carol decides to add TSLAx exposure on top of her NVDAx purchase. She swaps USDC for TSLAx on the TSLAx/USDC pool Alice created in Step 2b. @@ -312,7 +313,7 @@ Separately, Carol decides to add TSLAx exposure on top of her NVDAx purchase. Sh - `pool_config` - the TSLAx/USDC `PoolConfig` PDA - `trader` = Carol (signer) - `mint_a` = TSLAx mint, `mint_b` = USDC mint - - `pool_a`, `pool_b` - the TSLAx/USDC reserves (1 TSLAx, 180 USDC after Alice's seed deposit) + - `pool_a`, `pool_b` - the TSLAx/USDC reserves (1 TSLAx, 180 USDC from Alice's opening deposit) - `token_a` - Carol's TSLAx ATA (created if missing), `token_b` - Carol's USDC ATA - `payer` = Carol - token, ATA, system programs @@ -334,7 +335,7 @@ Carol gets ~0.4992 TSLAx. The large price impact (~50% of the pool's TSLAx reser TSLAx/USDC pool state: **~0.5008 TSLAx, ~360 USDC raw** (`admin_fees_owed_b ≈ 0.09`). Mid-price on the effective reserves has roughly doubled to ~358 USDC per TSLAx, illustrating why deep liquidity matters for minimising price impact. -### Step 8 - Alice claims her admin fees +### Step 7 - Alice claims her admin fees After trading activity on both pools, Alice sweeps her accumulated slice from the NVDAx/USDC pool. @@ -354,7 +355,7 @@ She receives her accumulated `admin_fees_owed_a` of NVDAx and `admin_fees_owed_b NVDAx/USDC pool state: **119.986 NVDAx, 600.065 USDC raw**, with `admin_fees_owed_a = 0` and `admin_fees_owed_b = 0`. -### Step 9 - Bob withdraws +### Step 8 - Bob withdraws Later on, Bob exits. @@ -366,14 +367,14 @@ He receives his proportional share of the **effective reserves** (`pool_X.amount ### Recap -- **Alice** calls `initialize_config` → `initialize_pool` (NVDAx/USDC) → `initialize_pool` (TSLAx/USDC) → `deposit_liquidity` on NVDAx/USDC → `deposit_liquidity` on TSLAx/USDC (admin, pool creator, initial LP on both pools) +- **Alice** calls `initialize_config` → `initialize_pool` (NVDAx/USDC, with her 20 NVDAx and 100 USDC) → `initialize_pool` (TSLAx/USDC, with her 1 TSLAx and 180 USDC) (admin, pool creator, first LP on both pools) - **Bob** calls `deposit_liquidity` on NVDAx/USDC (LP / yield farmer) - **Carol** calls `swap_tokens` with `input_is_token_a = false` on NVDAx/USDC (buys NVDAx with USDC), then calls `swap_tokens` with `input_is_token_a = false` on TSLAx/USDC (buys TSLAx with USDC) - **Dave** calls `swap_tokens` with `input_is_token_a = true` on NVDAx/USDC (arbitrageur, restores the mid-price to ~5.00) - **Alice** calls `claim_admin_fees` on NVDAx/USDC, then `claim_admin_fees` on TSLAx/USDC (sweeps her accumulated fee slices from both pools) - **Bob** later calls `withdraw_liquidity` on NVDAx/USDC (exits with his fee income) -What makes this work: `x × y = K` on the effective reserves keeps the pool solvent on every swap without anyone quoting prices. LPs are paid in growing effective reserves (their share of the fee, parameterised by `Config.fee` and `Config.admin_share_bps`); the admin earns the other share, accumulated lazily and swept on demand; profit-chasing arbitrageurs incidentally keep the mid-price honest; traders get instant fills against a passive counterparty (the pool). The same `initialize_pool` handler and the same `swap_tokens` handler work identically for both the NVDAx/USDC and TSLAx/USDC pools - only the mint accounts differ. +What makes this work: `x × y = K` on the effective reserves keeps the pool solvent on every swap without anyone quoting prices. LPs are paid in growing effective reserves (their share of the fee, parameterised by `Config.fee` and `Config.admin_share_bps`); the admin earns the other share, accumulated lazily and swept on demand; profit-chasing arbitrageurs incidentally keep the mid-price honest; traders get instant fills against a passive counterparty (the pool). The same `initialize_pool` handler and the same `swap_tokens` handler work identically for both the NVDAx/USDC and TSLAx/USDC pools - only the mint accounts and amounts differ. ## Tests diff --git a/finance/token-swap/anchor-v1/CHANGELOG.md b/finance/token-swap/anchor-v1/CHANGELOG.md index 3d6e12e9..4222fc83 100644 --- a/finance/token-swap/anchor-v1/CHANGELOG.md +++ b/finance/token-swap/anchor-v1/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased (2026-10-04) + +`initialize_pool` now takes the creator's first deposit: it gains `amount_a` +and `amount_b` arguments and the `creator`, `creator_token_a`, +`creator_token_b` and `liquidity_provider_token` accounts, moves both amounts +into the reserves it creates, and mints the creator +`sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens. A zero on either +side fails with the new `EmptyInitialDeposit`. A pool created empty let +whoever deposited first set its price, and clamped the creator's own deposit +to that ratio. `deposit_liquidity` no longer has a pool-creation branch: it +refuses an empty effective reserve with `EmptyPoolReserve`, whose message now +reads "Pool reserves must both be positive to deposit or swap". The +square-root arithmetic (`initial_lp_amount`) and the transfers and LP mint +both handlers end with (`deposit_and_mint_lp_tokens`) live in the new +`liquidity` module, so there is one copy of each. New tests: +`test_initialize_pool_takes_first_deposit`, +`test_initialize_pool_rejects_zero_amount_a`, +`test_initialize_pool_rejects_zero_amount_b` and +`test_pool_creation_cannot_be_front_run`, which runs the front-run (a hostile +ratio deposited right after the pool opens is clamped to the creator's price) +and checks that a deposit against an empty reserve is refused; every other +test opens its pool through `initialize_pool`. + ## 2026-09-22 `deposit_liquidity` now mints later deposits against the LP supply plus diff --git a/finance/token-swap/anchor-v1/README.md b/finance/token-swap/anchor-v1/README.md index 896b221d..d26c712c 100644 --- a/finance/token-swap/anchor-v1/README.md +++ b/finance/token-swap/anchor-v1/README.md @@ -18,7 +18,7 @@ See also: [Token Swap overview](../README.md) and the [repository catalog](../.. ## Setup -From this directory (`finance/token-swap/anchor/`): +From this directory (`finance/token-swap/anchor-v1/`): ```bash anchor build @@ -44,7 +44,7 @@ Read the program `programs/` source and `Anchor.toml` for deployed program IDs. ### How does an AMM work on Solana? -An automated market maker replaces the order book with a liquidity pool: anyone can create a pool with `initialize_pool`, fund it with `deposit_liquidity`, and trade against it with `swap_tokens`. Prices come from the constant-product invariant on the pool's balances, and liquidity providers earn a share of trading fees. Solana exchanges like Raydium and Orca use this design. +An automated market maker replaces the order book with a liquidity pool: anyone can open a pool with `initialize_pool`, which takes the creator's first deposit of both tokens and so sets the pool's opening price; later providers add to it with `deposit_liquidity`, and traders trade against it with `swap_tokens`. Prices come from the constant-product invariant on the pool's balances, and liquidity providers earn a share of trading fees. Solana exchanges like Raydium and Orca use this design. ### How is slippage handled? diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs index 887ee82a..842a72e3 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs @@ -12,6 +12,10 @@ pub enum AmmError { #[msg("Admin share must be less than 10000 basis points")] AdminShareTooHigh, + // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is below + // `MINIMUM_LIQUIDITY`, so withholding the floor would leave the creator + // nothing, and by `deposit_liquidity` when a later deposit is too small a + // share of the pool to mint a single LP token. #[msg("Depositing too little liquidity")] DepositTooSmall, @@ -79,10 +83,19 @@ pub enum AmmError { #[msg("mint_a must be less than mint_b for canonical pool ordering")] InvalidMintOrder, - // Returned by `swap_tokens` when either LP-claimable (effective) reserve is - // zero. Swapping against an empty reserve would let the constant-product - // curve drain the opposite side while the invariant check passes vacuously - // (k = 0 >= 0), so the swap is rejected outright. - #[msg("Pool reserves must both be positive to swap")] + // Returned by `swap_tokens` and `deposit_liquidity` when either + // LP-claimable (effective) reserve is zero. Swapping against an empty + // reserve would let the constant-product curve drain the opposite side + // while the invariant check passes vacuously (k = 0 >= 0), and a deposit + // into an empty pool would set its price, so both are rejected outright. + // Every pool opens with its creator's deposit, and neither a withdrawal + // nor a swap can empty a reserve, so the state is not reachable. + #[msg("Pool reserves must both be positive to deposit or swap")] EmptyPoolReserve, + + // Returned by `initialize_pool` when either amount is zero. The deposit + // that opens a pool sets its price (the ratio of its reserves), so a pool + // is never created without one. + #[msg("A pool cannot open empty: the first deposit sets its price")] + EmptyInitialDeposit, } diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs index 154bfd75..61961b68 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -1,32 +1,16 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{self, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ constants::{LIQUIDITY_SEED, MINIMUM_LIQUIDITY}, errors::AmmError, + liquidity::{deposit_and_mint_lp_tokens, LiquidityDepositAccounts}, state::PoolConfig, }; -/// Integer sqrt via Newton's method. Operates on `u128` so it can handle the -/// product `amount_a * amount_b` (each is a `u64`, product can fill the full -/// `u128`). Floors the result, which matches Uniswap V2's `Math.sqrt` and -/// keeps the initial-deposit LP-mint rounding in the pool's favour. -fn integer_sqrt(n: u128) -> u128 { - if n < 2 { - return n; - } - let mut x = n; - let mut y = x.div_ceil(2); - while y < x { - x = y; - y = (x + n / x) / 2; - } - x -} - pub fn handle_deposit_liquidity( context: Context, amount_a: u64, @@ -40,8 +24,6 @@ pub fn handle_deposit_liquidity( if amount_a > context.accounts.token_a.amount || amount_b > context.accounts.token_b.amount { return err!(AmmError::InsufficientBalance); } - let mut amount_a = amount_a; - let mut amount_b = amount_b; // Clamp the caller's (amount_a, amount_b) to the current pool ratio. // @@ -74,40 +56,45 @@ pub fn handle_deposit_liquidity( .amount .checked_sub(pool_config.admin_fees_owed_b) .ok_or(AmmError::MathOverflow)?; - // Defining pool creation like this allows attackers to frontrun pool creation with bad ratios - let pool_creation = effective_pool_a == 0 && effective_pool_b == 0; - (amount_a, amount_b) = if pool_creation { - // Add as is if there is no liquidity. Admin fees can't be owed yet - // (no swap has happened), so the initial-deposit math is unchanged. - (amount_a, amount_b) + + // Every pool opens with its creator's deposit in `initialize_pool`, and + // neither a withdrawal nor a swap can empty a reserve (the + // `MINIMUM_LIQUIDITY` floor stays behind, and a swap's output is always + // less than the reserve it comes from), so both effective reserves are + // positive here. The check keeps the ratio math from dividing by zero if + // that ever stopped being true, rather than letting a depositor set the + // price of a pool that has none. + require!( + effective_pool_a > 0 && effective_pool_b > 0, + AmmError::EmptyPoolReserve + ); + + // amount_b_required = amount_a * effective_pool_b / effective_pool_a. + // Round down: this can only ever ask the depositor for *less* token B + // than perfect-ratio, which favours the pool by a sub-base-unit and + // matches Uniswap V2. + let amount_b_required = (amount_a as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_a as u128) + .ok_or(AmmError::MathOverflow)?; + let (amount_a, amount_b) = if amount_b_required <= amount_b as u128 { + // The depositor's `amount_b` is enough to cover the ratio; use + // the full `amount_a` and clamp `amount_b` down. + let amount_b_required = + u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a, amount_b_required) } else { - // amount_b_required = amount_a * effective_pool_b / effective_pool_a. - // Round down: this can only ever ask the depositor for *less* token B - // than perfect-ratio, which favours the pool by a sub-base-unit and - // matches Uniswap V2. - let amount_b_required = (amount_a as u128) - .checked_mul(effective_pool_b as u128) + // `amount_b` is the binding side; use the full `amount_b` and + // clamp `amount_a` down to what the ratio needs. + let amount_a_required = (amount_b as u128) + .checked_mul(effective_pool_a as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_a as u128) + .checked_div(effective_pool_b as u128) .ok_or(AmmError::MathOverflow)?; - if amount_b_required <= amount_b as u128 { - // The depositor's `amount_b` is enough to cover the ratio; use - // the full `amount_a` and clamp `amount_b` down. - let amount_b_required = - u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a, amount_b_required) - } else { - // `amount_b` is the binding side; use the full `amount_b` and - // clamp `amount_a` down to what the ratio needs. - let amount_a_required = (amount_b as u128) - .checked_mul(effective_pool_a as u128) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_b as u128) - .ok_or(AmmError::MathOverflow)?; - let amount_a_required = - u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a_required, amount_b) - } + let amount_a_required = + u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a_required, amount_b) }; // After clamping, both sides must contribute something. If either side @@ -115,37 +102,25 @@ pub fn handle_deposit_liquidity( // ratio (e.g. a depositor offering 1 base unit of A against a pool where // 1 A is worth less than 1 base unit of B). Fail rather than letting an // LP mint zero-priced shares. - if !pool_creation && (amount_a == 0 || amount_b == 0) { + if amount_a == 0 || amount_b == 0 { return err!(AmmError::DepositAmountTooSmall); } - // LP-mint math. Two branches: - // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. - // The `MINIMUM_LIQUIDITY` floor is never minted to anyone: the first - // depositor receives `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and - // from then on both the subsequent-deposit branch below and - // withdraw_liquidity divide by `lp_supply + MINIMUM_LIQUIDITY`, so the - // floor counts as supply that nobody holds. Its share of the reserves - // stays in the pool, claimable by nobody. An attacker who inflates the - // value behind each share by donating to the vaults inflates the value - // behind those units too, and loses that part of the donation. - // (Uniswap V2 instead mints the floor to the zero address; here, if - // every LP token is burned, the floor's leftover reserves stay behind - // and the next depositor mints against them through the - // subsequent-deposit branch.) - // - Subsequent deposit: `liquidity = min(a * total / pool_a, b * total / pool_b)` - // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical - // Uniswap V2 formula: mint LP tokens in proportion to the depositor's - // share of each reserve, taking the smaller side as the binding - // constraint. After the ratio clamp above, both sides give the same - // result; `min` is kept as an invariant safety net and to match the - // published formula (Uniswap V2's `totalSupply` includes the floor it - // minted to the zero address). `total` must be the same divisor - // withdraw_liquidity uses: dividing by the bare mint supply here would - // mint every depositor slightly less than they could redeem, and would - // let a donation merely as large as a victim's deposit round that - // deposit down to zero LP tokens, where counting the floor makes the - // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. + // LP-mint math: `liquidity = min(a * total / pool_a, b * total / pool_b)` + // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical + // Uniswap V2 formula: mint LP tokens in proportion to the depositor's + // share of each reserve, taking the smaller side as the binding + // constraint. After the ratio clamp above, both sides give the same + // result; `min` is kept as an invariant safety net and to match the + // published formula (Uniswap V2's `totalSupply` includes the floor it + // minted to the zero address). `total` must be the same divisor + // withdraw_liquidity uses: dividing by the bare mint supply here would + // mint every depositor slightly less than they could redeem, and would + // let a donation merely as large as a victim's deposit round that + // deposit down to zero LP tokens, where counting the floor makes the + // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. + // The floor itself is withheld from the creator's deposit in + // `initialize_pool` (see `liquidity::initial_lp_amount`). // // All math is in `u128` with checked arithmetic. `amount * total` can // overflow `u64`, but `u128` absorbs it for any supply a real mint can @@ -153,41 +128,24 @@ pub fn handle_deposit_liquidity( // dividing to keep precision, then round down (floor) so the pool keeps // any sub-unit rounding dust - program-favouring rounding, per the // financial-math rules. - let liquidity: u64 = if pool_creation { - let product = (amount_a as u128) - .checked_mul(amount_b as u128) - .ok_or(AmmError::MathOverflow)?; - let sqrt_product = integer_sqrt(product); - let sqrt_product_u64 = u64::try_from(sqrt_product).map_err(|_| AmmError::MathOverflow)?; - if sqrt_product_u64 < MINIMUM_LIQUIDITY { - return err!(AmmError::DepositTooSmall); - } - sqrt_product_u64 - .checked_sub(MINIMUM_LIQUIDITY) - .ok_or(AmmError::MathOverflow)? - } else { - // The floor is part of the supply the reserves are shared among, as - // it is in withdraw_liquidity. - let total_supply = (context.accounts.liquidity_provider_mint.supply as u128) - .checked_add(MINIMUM_LIQUIDITY as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity_from_a = (amount_a as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_a as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity_from_b = (amount_b as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_b as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity = liquidity_from_a.min(liquidity_from_b); - u64::try_from(liquidity).map_err(|_| AmmError::MathOverflow)? - }; + let total_supply = (context.accounts.liquidity_provider_mint.supply as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity_from_a = (amount_a as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_a as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity_from_b = (amount_b as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity: u64 = u64::try_from(liquidity_from_a.min(liquidity_from_b)) + .map_err(|_| AmmError::MathOverflow)?; if liquidity == 0 { - // Subsequent deposit too small relative to existing LP supply. - // (Initial deposits hit the `MINIMUM_LIQUIDITY` check above.) + // Deposit too small relative to the existing LP supply. return err!(AmmError::DepositTooSmall); } @@ -204,62 +162,24 @@ pub fn handle_deposit_liquidity( AmmError::DepositBelowMinimum ); - // Transfer tokens to the pool using transfer_checked. transfer_checked - // includes the mint and decimals in the CPI, which guards callers against - // decimal-mismatch bugs (and is the modern recommended path). - token_interface::transfer_checked( - CpiContext::new( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.token_a.to_account_info(), - mint: context.accounts.mint_a.to_account_info(), - to: context.accounts.pool_a.to_account_info(), - authority: context.accounts.depositor.to_account_info(), - }, - ), + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &context.accounts.token_program, + pool_config: &context.accounts.pool_config, + mint_a: &context.accounts.mint_a, + mint_b: &context.accounts.mint_b, + pool_a: &context.accounts.pool_a, + pool_b: &context.accounts.pool_b, + depositor: &context.accounts.depositor, + depositor_token_a: &context.accounts.token_a, + depositor_token_b: &context.accounts.token_b, + liquidity_provider_mint: &context.accounts.liquidity_provider_mint, + liquidity_provider_token: &context.accounts.liquidity_provider_token, + }, amount_a, - context.accounts.mint_a.decimals, - )?; - token_interface::transfer_checked( - CpiContext::new( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.token_b.to_account_info(), - mint: context.accounts.mint_b.to_account_info(), - to: context.accounts.pool_b.to_account_info(), - authority: context.accounts.depositor.to_account_info(), - }, - ), amount_b, - context.accounts.mint_b.decimals, - )?; - - // Mint the liquidity to the user. `pool_config` is the LP mint's - // authority and signs with its own seeds. - let config_bytes = context.accounts.pool_config.config.to_bytes(); - let mint_a_bytes = context.accounts.mint_a.key().to_bytes(); - let mint_b_bytes = context.accounts.mint_b.key().to_bytes(); - let pool_config_bump = [context.accounts.pool_config.bump]; - let signer_seeds: &[&[&[u8]]] = &[&[ - config_bytes.as_ref(), - mint_a_bytes.as_ref(), - mint_b_bytes.as_ref(), - &pool_config_bump, - ]]; - token_interface::mint_to( - CpiContext::new_with_signer( - context.accounts.token_program.key(), - MintTo { - mint: context.accounts.liquidity_provider_mint.to_account_info(), - to: context.accounts.liquidity_provider_token.to_account_info(), - authority: context.accounts.pool_config.to_account_info(), - }, - signer_seeds, - ), liquidity, - )?; - - Ok(()) + ) } #[derive(Accounts)] @@ -276,7 +196,7 @@ pub struct DepositLiquidityAccountConstraints<'info> { )] pub pool_config: Box>, - /// The account paying for all rents + /// Makes the deposit and receives the LP tokens. pub depositor: Signer<'info>, #[account( diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/initialize_pool.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/initialize_pool.rs index d2ef4ae5..8a821076 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/initialize_pool.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/initialize_pool.rs @@ -7,10 +7,30 @@ use anchor_spl::{ use crate::{ constants::{CONFIG_SEED, LIQUIDITY_SEED}, errors::AmmError, + liquidity::{deposit_and_mint_lp_tokens, initial_lp_amount, LiquidityDepositAccounts}, state::{Config, PoolConfig}, }; -pub fn handle_initialize_pool(context: Context) -> Result<()> { +/// Creates the pool and takes the creator's first deposit in the same +/// instruction. The first deposit sets the pool's price (the ratio of its +/// reserves), so a pool that existed empty between two transactions would let +/// whoever deposited first set the price for its creator. Taking the deposit +/// here means every pool has both reserves positive from the moment it +/// exists, and `deposit_liquidity` only ever clamps to a price the creator +/// chose. +pub fn handle_initialize_pool( + context: Context, + amount_a: u64, + amount_b: u64, +) -> Result<()> { + require!(amount_a > 0 && amount_b > 0, AmmError::EmptyInitialDeposit); + if amount_a > context.accounts.creator_token_a.amount + || amount_b > context.accounts.creator_token_b.amount + { + return err!(AmmError::InsufficientBalance); + } + let lp_amount = initial_lp_amount(amount_a, amount_b)?; + let bump = context.bumps.pool_config; let pool_config = &mut context.accounts.pool_config; pool_config.config = context.accounts.config.key(); @@ -18,7 +38,24 @@ pub fn handle_initialize_pool(context: Context pool_config.mint_b = context.accounts.mint_b.key(); pool_config.bump = bump; - Ok(()) + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &context.accounts.token_program, + pool_config: &context.accounts.pool_config, + mint_a: &context.accounts.mint_a, + mint_b: &context.accounts.mint_b, + pool_a: &context.accounts.pool_a, + pool_b: &context.accounts.pool_b, + depositor: &context.accounts.creator, + depositor_token_a: &context.accounts.creator_token_a, + depositor_token_b: &context.accounts.creator_token_b, + liquidity_provider_mint: &context.accounts.liquidity_provider_mint, + liquidity_provider_token: &context.accounts.liquidity_provider_token, + }, + amount_a, + amount_b, + lp_amount, + ) } #[derive(Accounts)] @@ -85,6 +122,36 @@ pub struct InitializePoolAccountConstraints<'info> { )] pub pool_b: Box>, + /// Makes the first deposit and receives the first LP tokens. + pub creator: Signer<'info>, + + #[account( + mut, + associated_token::mint = mint_a, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub creator_token_a: Box>, + + #[account( + mut, + associated_token::mint = mint_b, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub creator_token_b: Box>, + + /// The creator's LP token account, created here because the LP mint did + /// not exist before this instruction. + #[account( + init, + payer = payer, + associated_token::mint = liquidity_provider_mint, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub liquidity_provider_token: Box>, + /// The account paying for all rents #[account(mut)] pub payer: Signer<'info>, diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/lib.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/lib.rs index ead2b3cd..7effadfd 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/lib.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/lib.rs @@ -3,6 +3,7 @@ use anchor_lang::prelude::*; mod constants; mod errors; mod instructions; +mod liquidity; mod state; declare_id!("GahM6PrXesrBkHiGJ5no4EskLNnVBCaSwVKbM4UtzyK6"); @@ -20,8 +21,12 @@ pub mod swap_example { instructions::handle_initialize_config(context, fee, admin_share_bps) } - pub fn initialize_pool(context: Context) -> Result<()> { - instructions::handle_initialize_pool(context) + pub fn initialize_pool( + context: Context, + amount_a: u64, + amount_b: u64, + ) -> Result<()> { + instructions::handle_initialize_pool(context, amount_a, amount_b) } pub fn deposit_liquidity( diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs new file mode 100644 index 00000000..dfd1bc18 --- /dev/null +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs @@ -0,0 +1,141 @@ +//! The two things every liquidity deposit ends with, shared by +//! `initialize_pool` (the creator's first deposit) and `deposit_liquidity` +//! (every later one): moving both tokens into the reserves and minting LP +//! tokens signed by `pool_config`. Only the arithmetic that decides the +//! amounts differs between the two handlers, and the first deposit's +//! arithmetic lives here too so there is one copy of it. + +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + self, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::{constants::MINIMUM_LIQUIDITY, errors::AmmError, state::PoolConfig}; + +/// Integer sqrt via Newton's method. Operates on `u128` so it can handle the +/// product `amount_a * amount_b` (each is a `u64`, product can fill the full +/// `u128`). Floors the result, which matches Uniswap V2's `Math.sqrt` and +/// keeps the first deposit's LP-mint rounding in the pool's favour. +pub fn integer_sqrt(n: u128) -> u128 { + if n < 2 { + return n; + } + let mut x = n; + let mut y = x.div_ceil(2); + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +/// LP tokens minted to the creator for the deposit that opens a pool: +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// +/// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on +/// `deposit_liquidity` and `withdraw_liquidity` divide by +/// `lp_supply + MINIMUM_LIQUIDITY`, so the floor counts as supply that nobody +/// holds. Its share of the reserves stays in the pool, claimable by nobody. An +/// attacker who inflates the value behind each share by donating to the vaults +/// inflates the value behind those units too, and loses that part of the +/// donation. (Uniswap V2 instead mints the floor to the zero address; here, if +/// every LP token is burned, the floor's leftover reserves stay behind and the +/// next depositor mints against them through `deposit_liquidity`'s +/// proportional formula.) +/// +/// `u128` with checked arithmetic: `amount_a * amount_b` can fill the full +/// `u128`. +pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result { + let product = (amount_a as u128) + .checked_mul(amount_b as u128) + .ok_or(AmmError::MathOverflow)?; + let sqrt_product = u64::try_from(integer_sqrt(product)).map_err(|_| AmmError::MathOverflow)?; + if sqrt_product < MINIMUM_LIQUIDITY { + return err!(AmmError::DepositTooSmall); + } + sqrt_product + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| error!(AmmError::MathOverflow)) +} + +/// The accounts a deposit moves tokens between. `pool_config` owns both +/// reserves and is the LP mint's authority, so it signs the mint with its own +/// seeds `[config, mint_a, mint_b, bump]`. +pub struct LiquidityDepositAccounts<'a, 'info> { + pub token_program: &'a Interface<'info, TokenInterface>, + pub pool_config: &'a Account<'info, PoolConfig>, + pub mint_a: &'a InterfaceAccount<'info, Mint>, + pub mint_b: &'a InterfaceAccount<'info, Mint>, + pub pool_a: &'a InterfaceAccount<'info, TokenAccount>, + pub pool_b: &'a InterfaceAccount<'info, TokenAccount>, + pub depositor: &'a Signer<'info>, + pub depositor_token_a: &'a InterfaceAccount<'info, TokenAccount>, + pub depositor_token_b: &'a InterfaceAccount<'info, TokenAccount>, + pub liquidity_provider_mint: &'a InterfaceAccount<'info, Mint>, + pub liquidity_provider_token: &'a InterfaceAccount<'info, TokenAccount>, +} + +/// Moves `amount_a` and `amount_b` from the depositor's token accounts into +/// the reserves, then mints `lp_amount` LP tokens to the depositor. The +/// caller has already decided all three amounts. +/// +/// `transfer_checked` carries the mint and decimals through the CPI, so a +/// wrong-mint or wrong-decimals account fails at the token program instead of +/// moving the wrong quantity. +pub fn deposit_and_mint_lp_tokens( + accounts: LiquidityDepositAccounts, + amount_a: u64, + amount_b: u64, + lp_amount: u64, +) -> Result<()> { + token_interface::transfer_checked( + CpiContext::new( + accounts.token_program.key(), + TransferChecked { + from: accounts.depositor_token_a.to_account_info(), + mint: accounts.mint_a.to_account_info(), + to: accounts.pool_a.to_account_info(), + authority: accounts.depositor.to_account_info(), + }, + ), + amount_a, + accounts.mint_a.decimals, + )?; + token_interface::transfer_checked( + CpiContext::new( + accounts.token_program.key(), + TransferChecked { + from: accounts.depositor_token_b.to_account_info(), + mint: accounts.mint_b.to_account_info(), + to: accounts.pool_b.to_account_info(), + authority: accounts.depositor.to_account_info(), + }, + ), + amount_b, + accounts.mint_b.decimals, + )?; + + let pool_config = accounts.pool_config; + let config_bytes = pool_config.config.to_bytes(); + let mint_a_bytes = pool_config.mint_a.to_bytes(); + let mint_b_bytes = pool_config.mint_b.to_bytes(); + let pool_config_bump = [pool_config.bump]; + let signer_seeds: &[&[&[u8]]] = &[&[ + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), + &pool_config_bump, + ]]; + token_interface::mint_to( + CpiContext::new_with_signer( + accounts.token_program.key(), + MintTo { + mint: accounts.liquidity_provider_mint.to_account_info(), + to: accounts.liquidity_provider_token.to_account_info(), + authority: pool_config.to_account_info(), + }, + signer_seeds, + ), + lp_amount, + ) +} diff --git a/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs b/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs index 6a6e5135..d60b1e62 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs @@ -64,6 +64,7 @@ struct TestSetup { svm: LiteSVM, program_id: Pubkey, payer: Keypair, + /// The config admin, who also opens the pool and is its first LP. admin: Keypair, config_key: Pubkey, mint_a: Pubkey, @@ -77,7 +78,20 @@ struct TestSetup { liquidity_account: Pubkey, } -fn full_setup() -> TestSetup { +/// Assert that a transaction failed with the named program error. LiteSVM +/// puts the program's log lines, including `Error Code: `, in the +/// error's `Debug` output. +fn assert_error_code(result: Result, error_name: &str) { + let err = format!("{:?}", result.err().expect("transaction must fail")); + assert!( + err.contains(error_name), + "expected {error_name}, got: {err}" + ); +} + +/// Everything up to, but not including, the pool: the config, two ordered +/// mints, and the admin's token accounts holding 100 of each token. +fn setup_config_and_mints() -> TestSetup { let (mut svm, program_id, payer) = setup(); let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); @@ -148,33 +162,6 @@ fn full_setup() -> TestSetup { ) .unwrap(); - // Create Pool - let initialize_pool_ix = Instruction::new_with_bytes( - program_id, - &swap_example::instruction::InitializePool {}.data(), - swap_example::accounts::InitializePoolAccountConstraints { - config: config_key, - pool_config: pool_config_key, - liquidity_provider_mint, - mint_a, - mint_b, - pool_a, - pool_b, - payer: payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut svm, - vec![initialize_pool_ix], - &[&payer], - &payer.pubkey(), - ) - .unwrap(); - TestSetup { svm, program_id, @@ -193,6 +180,55 @@ fn full_setup() -> TestSetup { } } +/// Helper: build an `initialize_pool` ix in which the admin opens the pool +/// with `amount_a` of token A and `amount_b` of token B. +fn initialize_pool_ix(ts: &TestSetup, amount_a: u64, amount_b: u64) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::InitializePool { amount_a, amount_b }.data(), + swap_example::accounts::InitializePoolAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + liquidity_provider_mint: ts.liquidity_provider_mint, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + creator: ts.admin.pubkey(), + creator_token_a: ts.holder_account_a, + creator_token_b: ts.holder_account_b, + liquidity_provider_token: ts.liquidity_account, + payer: ts.payer.pubkey(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn send_initialize_pool(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), String> { + let ix = initialize_pool_ix(ts, amount_a, amount_b); + send_transaction_from_instructions( + &mut ts.svm, + vec![ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .map(|_| ()) + .map_err(|e| format!("{e:?}")) +} + +/// The config, the mints, and a pool the admin opened with `initial_a` of +/// token A and `initial_b` of token B, so the pool's price is +/// `initial_a : initial_b` and the admin holds +/// `sqrt(initial_a * initial_b) - MINIMUM_LIQUIDITY` LP tokens. +fn setup_pool(initial_a: u64, initial_b: u64) -> TestSetup { + let mut ts = setup_config_and_mints(); + send_initialize_pool(&mut ts, initial_a, initial_b).expect("open the pool"); + ts +} + #[test] fn test_initialize_config() { let (mut svm, program_id, payer) = setup(); @@ -238,7 +274,7 @@ fn test_initialize_config() { /// signs the transfers out of the reserves and the LP mint/burn CPIs. #[test] fn test_pool_config_owns_reserves_and_lp_mint() { - let ts = full_setup(); + let ts = setup_pool(4_000_000, 1_000_000); let expected: &[u8] = ts.pool_config_key.as_ref(); // SPL token account layout: mint (32 bytes), then owner (32 bytes). @@ -269,32 +305,128 @@ fn test_pool_config_owns_reserves_and_lp_mint() { ); } +/// `initialize_pool` creates the pool and takes the creator's deposit in the +/// same instruction: the reserves hold the two amounts, the creator holds +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens, and the LP +/// mint's supply is that same number, because the withheld floor is never +/// minted to anyone. +#[test] +fn test_initialize_pool_takes_first_deposit() { + let mut ts = setup_config_and_mints(); + let holder_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + send_initialize_pool(&mut ts, 4_000_000, 1_000_000).expect("open the pool"); + + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 4_000_000 + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), + 1_000_000 + ); + assert_eq!( + holder_a_before - get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(), + 4_000_000 + ); + assert_eq!( + holder_b_before - get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(), + 1_000_000 + ); + // sqrt(4_000_000 * 1_000_000) = 2_000_000, minus the 100 floor. + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 2_000_000 - MINIMUM_LIQUIDITY + ); + // SPL mint layout: 4-byte COption tag + 32-byte authority, then the + // u64 supply at byte 36. + let lp_mint = ts.svm.get_account(&ts.liquidity_provider_mint).unwrap(); + let lp_supply = u64::from_le_bytes(lp_mint.data[36..44].try_into().unwrap()); + assert_eq!(lp_supply, 2_000_000 - MINIMUM_LIQUIDITY); +} + +/// A pool cannot open empty on either side: the first deposit sets the +/// price, and a zero reserve has none. Nothing is created when the +/// instruction fails. +#[test] +fn test_initialize_pool_rejects_zero_amount_a() { + let mut ts = setup_config_and_mints(); + assert_error_code( + send_initialize_pool(&mut ts, 0, 1_000_000), + "EmptyInitialDeposit", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); +} + #[test] -fn test_deposit_liquidity() { - let mut ts = full_setup(); - let deposit_amount_a: u64 = 4_000_000; - let deposit_amount_b: u64 = 1_000_000; +fn test_initialize_pool_rejects_zero_amount_b() { + let mut ts = setup_config_and_mints(); + assert_error_code( + send_initialize_pool(&mut ts, 4_000_000, 0), + "EmptyInitialDeposit", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); +} - let deposit_ix = Instruction::new_with_bytes( +/// A liquidity provider other than the admin: a funded wallet with token +/// accounts for both mints and the address of its (not yet created) LP token +/// account. +struct Depositor { + wallet: Keypair, + token_a: Pubkey, + token_b: Pubkey, + lp_token: Pubkey, +} + +/// Helper: create a wallet holding `amount_a` of token A and `amount_b` of +/// token B. +fn fund_depositor(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Depositor { + let wallet = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); + let token_a = + create_associated_token_account(&mut ts.svm, &wallet.pubkey(), &ts.mint_a, &ts.payer) + .unwrap(); + let token_b = + create_associated_token_account(&mut ts.svm, &wallet.pubkey(), &ts.mint_b, &ts.payer) + .unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &token_a, amount_a, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &token_b, amount_b, &ts.admin).unwrap(); + let lp_token = derive_ata(&wallet.pubkey(), &ts.liquidity_provider_mint); + Depositor { + wallet, + token_a, + token_b, + lp_token, + } +} + +/// Helper: `deposit_liquidity` of up to `amount_a` and `amount_b` by a +/// depositor other than the admin, with no LP floor. +fn send_deposit_from( + ts: &mut TestSetup, + depositor: &Depositor, + amount_a: u64, + amount_b: u64, +) -> Result<(), String> { + let ix = Instruction::new_with_bytes( ts.program_id, &swap_example::instruction::DepositLiquidity { - amount_a: deposit_amount_a, - amount_b: deposit_amount_b, - // 0 = no slippage floor for this baseline test + amount_a, + amount_b, minimum_lp_tokens_out: 0, } .data(), swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), + depositor: depositor.wallet.pubkey(), liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, pool_a: ts.pool_a, pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, + liquidity_provider_token: depositor.lp_token, + token_a: depositor.token_a, + token_b: depositor.token_b, payer: ts.payer.pubkey(), token_program: token_program_id(), associated_token_program: ata_program_id(), @@ -302,59 +434,115 @@ fn test_deposit_liquidity() { } .to_account_metas(None), ); - send_transaction_from_instructions( &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], + vec![ix], + &[&ts.payer, &depositor.wallet], &ts.payer.pubkey(), ) - .unwrap(); - - // Verify liquidity tokens were minted - let liq_amount = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - assert!(liq_amount > 0, "Should have received liquidity tokens"); + .map(|_| ()) + .map_err(|e| format!("{e:?}")) } +/// The front-run. Maria (the admin) opens an ACME/USDC pool with 400 ACME +/// and 900 USDC, a price of 2.25 USDC per ACME, in one `initialize_pool` +/// call; the pool is created, funded and priced before anyone else can +/// touch it. Mallory then calls `deposit_liquidity` at once with a hostile +/// ratio, 400 ACME and 100 USDC (0.25 USDC per ACME). Her deposit is clamped +/// to Maria's ratio: her 100 USDC binds, only 44.444444 of her 400 ACME is +/// taken, and the pool still prices at 2.25 afterwards. The last part of the +/// test checks the refusal in code: a `deposit_liquidity` against a pool whose +/// effective reserves are zero fails with `EmptyPoolReserve`, so no +/// instruction lets a pool with no reserves take a depositor's ratio as its +/// price. (`initialize_pool` is the only instruction that creates a pool, and +/// `test_initialize_pool_rejects_zero_amount_a` / `_b` show it refuses a +/// zero side.) #[test] -fn test_swap_a_to_b() { - let mut ts = full_setup(); +fn test_pool_creation_cannot_be_front_run() { + let one: u64 = 10u64.pow(6); // both mints have 6 decimals + let mut ts = setup_config_and_mints(); + // Maria is the admin. The setup gives her 100 of each token; top her up + // to the 400 ACME (token A) and 900 USDC (token B) she opens with. + mint_tokens_to_token_account( + &mut ts.svm, + &ts.mint_a, + &ts.holder_account_a, + 300 * one, + &ts.admin, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut ts.svm, + &ts.mint_b, + &ts.holder_account_b, + 800 * one, + &ts.admin, + ) + .unwrap(); + send_initialize_pool(&mut ts, 400 * one, 900 * one).expect("Maria opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 400 * one + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), + 900 * one + ); - // Deposit liquidity first - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: 4_000_000, - amount_b: 1_000_000, - // 0 = no slippage floor for this setup deposit - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), + let mallory = fund_depositor(&mut ts, 400 * one, 100 * one); + send_deposit_from(&mut ts, &mallory, 400 * one, 100 * one) + .expect("a deposit at the wrong ratio is clamped, not refused"); + + // 400 ACME would need 900 USDC at Maria's price, more than Mallory + // offered, so her 100 USDC binds: 100 * 400 / 900 = 44.444444 ACME. + let acme_taken = 400 * one - get_token_account_balance(&ts.svm, &mallory.token_a).unwrap(); + let usdc_taken = 100 * one - get_token_account_balance(&ts.svm, &mallory.token_b).unwrap(); + assert_eq!(acme_taken, 44_444_444); + assert_eq!(usdc_taken, 100 * one); + let pool_a = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + assert_eq!(pool_a, 444_444_444); + assert_eq!(pool_b, 1_000 * one); + // The price is still 2.25 USDC per ACME, to the cent. + let price_in_cents = (pool_b as u128) * 100 / (pool_a as u128); + assert_eq!(price_in_cents, 225); + // Mallory is minted her share and nothing more: the LP supply is + // sqrt(400 * 900) = 600 (floor included), and 100 USDC is a ninth of + // the 900 in the pool, so min(44.444444 * 600 / 400, 100 * 600 / 900) + // = 66.666666 LP tokens. + assert_eq!( + get_token_account_balance(&ts.svm, &mallory.lp_token).unwrap(), + 66_666_666 ); - send_transaction_from_instructions( + + // The pool can never be in this state through the program, so put it + // there by hand: zero the token-A reserve's balance (an SPL token + // account; the `amount` is the u64 at bytes 64..72) and show the handler + // refuses to price a pool with an empty reserve. + let mut reserve = ts.svm.get_account(&ts.pool_a).unwrap(); + reserve.data[64..72].copy_from_slice(&0u64.to_le_bytes()); + ts.svm.set_account(ts.pool_a, reserve).unwrap(); + // The balance check runs first, so give Mallory the USDC she offers + // (a new blockhash, or this mint is byte-identical to the one in + // `fund_depositor` and litesvm rejects it as already processed). + ts.svm.expire_blockhash(); + mint_tokens_to_token_account( &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), + &ts.mint_b, + &mallory.token_b, + 100 * one, + &ts.admin, ) .unwrap(); + assert_error_code( + send_deposit_from(&mut ts, &mallory, 300 * one, 100 * one), + "EmptyPoolReserve", + ); +} + +#[test] +fn test_swap_a_to_b() { + let mut ts = setup_pool(4_000_000, 1_000_000); // Get balances before swap let before_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); @@ -403,43 +591,7 @@ fn test_swap_a_to_b() { #[test] fn test_withdraw_liquidity() { - let mut ts = full_setup(); - - // Deposit liquidity - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: 4_000_000, - amount_b: 4_000_000, - // 0 = no slippage floor for this setup deposit - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); + let mut ts = setup_pool(4_000_000, 4_000_000); // Get liquidity token balance let liq_amount = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); @@ -487,85 +639,6 @@ fn test_withdraw_liquidity() { assert_eq!(liq_amount, 0, "Liquidity should be fully withdrawn"); } -/// Helper: do a deposit and one A->B swap on top of `full_setup`. -/// Returns the swap input amount (token A base units) for fee-arithmetic checks. -fn deposit_and_swap_a_to_b( - ts: &mut TestSetup, - deposit_a: u64, - deposit_b: u64, - swap_in_a: u64, -) -> u64 { - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: deposit_a, - amount_b: deposit_b, - // 0 = no slippage floor for setup deposits in helpers - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); - - let swap_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::SwapTokens { - input_is_token_a: true, - input_amount: swap_in_a, - min_output_amount: 1, - } - .data(), - swap_example::accounts::SwapTokensAccountConstraints { - config: ts.config_key, - pool_config: ts.pool_config_key, - trader: ts.admin.pubkey(), - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![swap_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); - - swap_in_a -} - /// Helper: build a `claim_admin_fees` instruction for the standard setup. fn claim_admin_fees_ix(ts: &TestSetup) -> Instruction { Instruction::new_with_bytes( @@ -625,13 +698,13 @@ fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { #[test] fn test_claim_admin_fees() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); // fee = 500 bps (5%), admin_share_bps = 1667 (~1/6). // Per the swap: fee_amount = input * 500 / 10_000 = input * 5 / 100 // admin_portion = fee_amount * 1667 / 10_000 // Swap input is on the A side, so admin's claim accumulates in mint A. let swap_in = 1_000_000u64; - deposit_and_swap_a_to_b(&mut ts, 4_000_000, 1_000_000, swap_in); + swap_a_to_b(&mut ts, swap_in); let fee_amount = swap_in * 500 / 10_000; let expected_admin_a = fee_amount * 1667 / 10_000; @@ -726,17 +799,7 @@ fn test_claim_admin_fees() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - assert!( - result.is_err(), - "claim with both accumulators at zero must revert" - ); - let err_msg = format!("{:?}", result.unwrap_err()); - assert!( - err_msg.contains("NothingToClaim") - || err_msg.contains("0x1777") - || err_msg.contains("6007"), - "expected NothingToClaim error, got: {err_msg}" - ); + assert_error_code(result, "NothingToClaim"); // Balance unchanged - the revert rolled back any partial state. let balance_a_after_third_claim = @@ -749,10 +812,10 @@ fn test_claim_admin_fees() { #[test] fn test_claim_admin_fees_rejects_non_admin() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); // Need at least one swap so the program has a reason to reach the claim // handler (not strictly required, but matches the realistic flow). - deposit_and_swap_a_to_b(&mut ts, 4_000_000, 1_000_000, 1_000_000); + swap_a_to_b(&mut ts, 1_000_000); // Create a non-admin actor with their own ATAs and try to claim. let attacker = create_wallet(&mut ts.svm, 100_000_000_000).unwrap(); @@ -789,10 +852,7 @@ fn test_claim_admin_fees_rejects_non_admin() { &[&ts.payer, &attacker], &ts.payer.pubkey(), ); - assert!( - result.is_err(), - "claim_admin_fees by a non-admin signer must fail" - ); + assert_error_code(result, "ConstraintHasOne"); } /// Helper: issue a `deposit_liquidity` ix with the given amounts. Lets a test @@ -851,11 +911,9 @@ fn send_deposit(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), /// matching amounts unchanged and that LP tokens are minted. #[test] fn test_deposit_into_funded_pool_at_correct_ratio() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Seed the pool at a 4:1 ratio. This hits the pool-creation branch, which - // is unchanged by the fix. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + // The pool opened at a 4:1 ratio. let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -888,10 +946,7 @@ fn test_deposit_into_funded_pool_at_correct_ratio() { /// should be clamped down; `amount_a` should be used in full. #[test] fn test_deposit_clamps_excess_amount_b() { - let mut ts = full_setup(); - - // Seed at 4:1. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -927,10 +982,7 @@ fn test_deposit_clamps_excess_amount_b() { /// should be clamped down; `amount_b` should be used in full. #[test] fn test_deposit_clamps_excess_amount_a() { - let mut ts = full_setup(); - - // Seed at 4:1. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -960,12 +1012,10 @@ fn test_deposit_clamps_excess_amount_a() { /// effective-reserves subtraction works under real swap fees. #[test] fn test_deposit_after_swap_uses_shifted_effective_ratio() { - let mut ts = full_setup(); + let mut ts = setup_pool(10_000_000, 10_000_000); - // Seed at 100:100 (1:1) so the post-swap ratio is dramatic and easy to + // Opened at 1:1 so the post-swap ratio is dramatic and easy to // sanity-check. - send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); - // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps: // fee_amount = 50_000 // admin_portion = 50_000 * 1667 / 10_000 = 8_335 (accrues on side A) @@ -1067,19 +1117,13 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { /// zero contribution. #[test] fn test_deposit_too_small_for_ratio_reverts() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Seed at 4M:1M (A is "cheaper" - 4 A per 1 B). To force amount_b to + // Opened at 4M:1M (A is "cheaper" - 4 A per 1 B). To force amount_b to // round down to zero, the depositor must offer < 4 base units of A // (so amount_b_required = amount_a * 1M / 4M = 0). We offer 1 base unit // of A and a large amount_b. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); - - let result = send_deposit(&mut ts, 1, 1_000_000); - assert!( - result.is_err(), - "sub-ratio deposit should revert (clamped amount rounds to zero)" - ); + assert_error_code(send_deposit(&mut ts, 1, 1_000_000), "DepositAmountTooSmall"); } /// Test F: LP-mint correctness for a subsequent deposit at the current ratio. @@ -1091,18 +1135,16 @@ fn test_deposit_too_small_for_ratio_reverts() { /// proportionality. #[test] fn test_lp_mint_proportional_to_share_of_pool() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Initial deposit: 4M : 1M. sqrt(4M * 1M) = 2_000_000. Minus - // MINIMUM_LIQUIDITY (100) → depositor LP balance = 1_999_900, which is + // The pool opened with 4M : 1M. sqrt(4M * 1M) = 2_000_000. Minus + // MINIMUM_LIQUIDITY (100) → creator LP balance = 1_999_900, which is // also the total LP supply (we don't mint the locked floor anywhere). - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); - let lp_supply_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); let expected_initial: u64 = 2_000_000 - 100; assert_eq!( lp_supply_initial, expected_initial, - "initial LP supply should equal sqrt(a*b) - MINIMUM_LIQUIDITY" + "the creator's LP balance should equal sqrt(a*b) - MINIMUM_LIQUIDITY" ); // Second deposit at the same 4:1 ratio doubles the pool. The proportional @@ -1110,10 +1152,6 @@ fn test_lp_mint_proportional_to_share_of_pool() { // LP: the first deposit's whole `sqrt(a*b)`, floor included, because the // second depositor owns exactly as much of the pool as it does. let lp_before_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - // Same depositor + same args as the first deposit → identical tx - // signature. Bump the blockhash so litesvm doesn't reject the second - // tx as `AlreadyProcessed`. - ts.svm.expire_blockhash(); send_deposit(&mut ts, 4_000_000, 1_000_000).expect("second deposit"); let lp_after_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); @@ -1153,10 +1191,9 @@ fn test_lp_mint_proportional_to_share_of_pool() { /// keep shares honest. #[test] fn test_lp_mint_after_swap_uses_effective_reserves() { - let mut ts = full_setup(); + let mut ts = setup_pool(10_000_000, 10_000_000); // Seed 10M : 10M, then swap A→B so the pool shifts off 1:1. - send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); let lp_after_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // The divisor counts the unminted MINIMUM_LIQUIDITY floor as supply. let total_supply_before_second = lp_after_initial + MINIMUM_LIQUIDITY; @@ -1217,10 +1254,9 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { /// 100 of the 101 units it was spread over belong to no one. #[test] fn test_donation_cannot_round_a_later_deposit_to_zero() { - let mut ts = full_setup(); + let mut ts = setup_pool(101, 101); // sqrt(101 * 101) = 101, minus the 100 floor: the attacker holds 1 LP. - send_deposit(&mut ts, 101, 101).expect("attacker opens the pool"); assert_eq!( get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), 1 @@ -1233,52 +1269,15 @@ fn test_donation_cannot_round_a_later_deposit_to_zero() { // The victim deposits exactly as much as was donated. Dividing by the // bare supply of 1 would mint 1_000_000 * 1 / 1_000_101 = 0 LP tokens. - let victim = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); - let victim_a = - create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_a, &ts.payer) - .unwrap(); - let victim_b = - create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_b, &ts.payer) - .unwrap(); - mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &victim_a, donation, &ts.admin).unwrap(); - mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &victim_b, donation, &ts.admin).unwrap(); - let victim_lp = derive_ata(&victim.pubkey(), &ts.liquidity_provider_mint); - let victim_deposit = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: donation, - amount_b: donation, - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: victim.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: victim_lp, - token_a: victim_a, - token_b: victim_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![victim_deposit], - &[&ts.payer, &victim], - &ts.payer.pubkey(), - ) - .expect("victim deposit mints LP tokens"); + let victim = fund_depositor(&mut ts, donation, donation); + send_deposit_from(&mut ts, &victim, donation, donation) + .expect("victim deposit mints LP tokens"); // 1_000_000 * 101 / 1_000_101 = 100 LP tokens. - assert_eq!(get_token_account_balance(&ts.svm, &victim_lp).unwrap(), 100); + assert_eq!( + get_token_account_balance(&ts.svm, &victim.lp_token).unwrap(), + 100 + ); // The attacker exits. Their 1 LP token is 1 of 201 units of supply over a // 2_000_101 reserve: 9_950 of each token, back from a 1_000_101 outlay. @@ -1301,13 +1300,12 @@ fn test_donation_cannot_round_a_later_deposit_to_zero() { } /// Test I: once every LP token is burned, the floor's share of the reserves -/// is still in the pool, so the next deposit takes the subsequent-deposit -/// branch and mints against the floor alone. Dividing by the bare supply of +/// is still in the pool, so the next deposit is clamped to the floor's ratio +/// and mints against the floor alone. Dividing by the bare supply of /// zero would mint nothing and leave the pool unable to take deposits again. #[test] fn test_deposit_after_every_lp_token_is_burned() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 4_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 4_000_000); let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Burn all 3_999_900 LP tokens: 3_999_900 * 4_000_000 / 4_000_000 of @@ -1324,7 +1322,6 @@ fn test_deposit_after_every_lp_token_is_burned() { assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), 100); // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. - ts.svm.expire_blockhash(); send_deposit(&mut ts, 1_000_000, 1_000_000).expect("deposit into the emptied pool"); assert_eq!( get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), @@ -1441,9 +1438,8 @@ fn withdraw_ix_with_min( /// at a worse rate. #[test] fn test_swap_reverts_when_output_below_min() { - let mut ts = full_setup(); - // Seed a 4:1 pool so 1M of A out gives ~237k of B after a 5% fee. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); + // A 4:1 pool, so 1M of A in gives ~237k of B after a 5% fee. // First: prove the swap *would* succeed with a permissive floor. let baseline_ix = swap_a_to_b_ix(&ts, 1_000_000, 1); @@ -1462,8 +1458,7 @@ fn test_swap_reverts_when_output_below_min() { // Reset and try the same swap with `min_output_amount = actual + 1`. It // must revert because the pool can't beat the previous output (in fact // it can't even match it - the first swap shifted the ratio). - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let too_high = actual_output + 1; let strict_ix = swap_a_to_b_ix(&ts, 1_000_000, too_high); let result = send_transaction_from_instructions( @@ -1472,11 +1467,7 @@ fn test_swap_reverts_when_output_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert")); - assert!( - err.contains("SlippageExceeded"), - "expected SlippageExceeded, got: {err}" - ); + assert_error_code(result, "SlippageExceeded"); } /// Slippage test: a deposit with `minimum_lp_tokens_out` strictly higher @@ -1484,9 +1475,7 @@ fn test_swap_reverts_when_output_below_min() { /// `DepositBelowMinimum`. #[test] fn test_deposit_reverts_when_lp_below_min() { - let mut ts = full_setup(); - // Seed pool so the second deposit goes through the proportional branch. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); // Compute the LP that a `(4M, 1M)` deposit at the current ratio would // mint, using the same formula as the program (no probe tx needed): @@ -1509,11 +1498,7 @@ fn test_deposit_reverts_when_lp_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert")); - assert!( - err.contains("DepositBelowMinimum"), - "expected DepositBelowMinimum, got: {err}" - ); + assert_error_code(result, "DepositBelowMinimum"); // Sanity: the same deposit with `achievable_lp` as the floor succeeds. let ok_ix = deposit_ix_with_min_lp(&ts, 4_000_000, 1_000_000, achievable_lp); @@ -1531,8 +1516,7 @@ fn test_deposit_reverts_when_lp_below_min() { /// revert with `WithdrawalBelowMinimum`. #[test] fn test_withdraw_reverts_when_below_min() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 4_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 4_000_000); let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Burning half the LP at a 4M:4M pool returns ~2M of each side, but the @@ -1545,11 +1529,7 @@ fn test_withdraw_reverts_when_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert (A side)")); - assert!( - err.contains("WithdrawalBelowMinimum"), - "expected WithdrawalBelowMinimum (A side), got: {err}" - ); + assert_error_code(result, "WithdrawalBelowMinimum"); // Same on the B side. let strict_ix_b = withdraw_ix_with_min(&ts, lp / 2, 0, 4_000_000); @@ -1559,11 +1539,7 @@ fn test_withdraw_reverts_when_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err_b = format!("{:?}", result_b.expect_err("must revert (B side)")); - assert!( - err_b.contains("WithdrawalBelowMinimum"), - "expected WithdrawalBelowMinimum (B side), got: {err_b}" - ); + assert_error_code(result_b, "WithdrawalBelowMinimum"); } /// Slippage test: passing `min_output_amount = 0` is the explicit @@ -1571,8 +1547,7 @@ fn test_withdraw_reverts_when_below_min() { /// hatch and must still succeed. #[test] fn test_swap_with_zero_min_output_still_succeeds() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let before_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); let ix = swap_a_to_b_ix(&ts, 1_000_000, 0); @@ -1623,10 +1598,9 @@ fn swap_b_to_a_ix(ts: &TestSetup, input_amount: u64, min_output_amount: u64) -> /// `input_amount`, so the trader underpaid for every B→A swap). #[test] fn test_swap_b_to_a_trader_pays_full_input() { - let mut ts = full_setup(); - // Seed at 4:1 so the B side is the scarce asset and a B→A swap returns a - // multiple of its input in A. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); + // Opened at 4:1 so the B side is the scarce asset and a B→A swap returns + // a multiple of its input in A. let holder_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); @@ -1675,8 +1649,7 @@ fn test_swap_b_to_a_trader_pays_full_input() { /// variant so both directions of the symmetric flow are exercised. #[test] fn test_invariant_holds_after_b_to_a_swap() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -1718,8 +1691,7 @@ fn test_invariant_holds_after_b_to_a_swap() { /// gave away too much" bugs - verify the happy path doesn't trip it. #[test] fn test_invariant_holds_after_normal_swap() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); // Read effective reserves before. let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); diff --git a/finance/token-swap/anchor/CHANGELOG.md b/finance/token-swap/anchor/CHANGELOG.md index 3d6e12e9..4222fc83 100644 --- a/finance/token-swap/anchor/CHANGELOG.md +++ b/finance/token-swap/anchor/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased (2026-10-04) + +`initialize_pool` now takes the creator's first deposit: it gains `amount_a` +and `amount_b` arguments and the `creator`, `creator_token_a`, +`creator_token_b` and `liquidity_provider_token` accounts, moves both amounts +into the reserves it creates, and mints the creator +`sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens. A zero on either +side fails with the new `EmptyInitialDeposit`. A pool created empty let +whoever deposited first set its price, and clamped the creator's own deposit +to that ratio. `deposit_liquidity` no longer has a pool-creation branch: it +refuses an empty effective reserve with `EmptyPoolReserve`, whose message now +reads "Pool reserves must both be positive to deposit or swap". The +square-root arithmetic (`initial_lp_amount`) and the transfers and LP mint +both handlers end with (`deposit_and_mint_lp_tokens`) live in the new +`liquidity` module, so there is one copy of each. New tests: +`test_initialize_pool_takes_first_deposit`, +`test_initialize_pool_rejects_zero_amount_a`, +`test_initialize_pool_rejects_zero_amount_b` and +`test_pool_creation_cannot_be_front_run`, which runs the front-run (a hostile +ratio deposited right after the pool opens is clamped to the creator's price) +and checks that a deposit against an empty reserve is refused; every other +test opens its pool through `initialize_pool`. + ## 2026-09-22 `deposit_liquidity` now mints later deposits against the LP supply plus diff --git a/finance/token-swap/anchor/README.md b/finance/token-swap/anchor/README.md index 5dfc65d0..54ff0ff7 100644 --- a/finance/token-swap/anchor/README.md +++ b/finance/token-swap/anchor/README.md @@ -44,7 +44,7 @@ Read the program `programs/` source and `Anchor.toml` for deployed program IDs. ### How does an AMM work on Solana? -An automated market maker replaces the order book with a liquidity pool: anyone can create a pool with `initialize_pool`, fund it with `deposit_liquidity`, and trade against it with `swap_tokens`. Prices come from the constant-product invariant on the pool's balances, and liquidity providers earn a share of trading fees. Solana exchanges like Raydium and Orca use this design. +An automated market maker replaces the order book with a liquidity pool: anyone can open a pool with `initialize_pool`, which takes the creator's first deposit of both tokens and so sets the pool's opening price; later providers add to it with `deposit_liquidity`, and traders trade against it with `swap_tokens`. Prices come from the constant-product invariant on the pool's balances, and liquidity providers earn a share of trading fees. Solana exchanges like Raydium and Orca use this design. ### How is slippage handled? diff --git a/finance/token-swap/anchor/programs/token-swap/src/errors.rs b/finance/token-swap/anchor/programs/token-swap/src/errors.rs index 887ee82a..842a72e3 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/errors.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/errors.rs @@ -12,6 +12,10 @@ pub enum AmmError { #[msg("Admin share must be less than 10000 basis points")] AdminShareTooHigh, + // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is below + // `MINIMUM_LIQUIDITY`, so withholding the floor would leave the creator + // nothing, and by `deposit_liquidity` when a later deposit is too small a + // share of the pool to mint a single LP token. #[msg("Depositing too little liquidity")] DepositTooSmall, @@ -79,10 +83,19 @@ pub enum AmmError { #[msg("mint_a must be less than mint_b for canonical pool ordering")] InvalidMintOrder, - // Returned by `swap_tokens` when either LP-claimable (effective) reserve is - // zero. Swapping against an empty reserve would let the constant-product - // curve drain the opposite side while the invariant check passes vacuously - // (k = 0 >= 0), so the swap is rejected outright. - #[msg("Pool reserves must both be positive to swap")] + // Returned by `swap_tokens` and `deposit_liquidity` when either + // LP-claimable (effective) reserve is zero. Swapping against an empty + // reserve would let the constant-product curve drain the opposite side + // while the invariant check passes vacuously (k = 0 >= 0), and a deposit + // into an empty pool would set its price, so both are rejected outright. + // Every pool opens with its creator's deposit, and neither a withdrawal + // nor a swap can empty a reserve, so the state is not reachable. + #[msg("Pool reserves must both be positive to deposit or swap")] EmptyPoolReserve, + + // Returned by `initialize_pool` when either amount is zero. The deposit + // that opens a pool sets its price (the ratio of its reserves), so a pool + // is never created without one. + #[msg("A pool cannot open empty: the first deposit sets its price")] + EmptyInitialDeposit, } diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs index 44b072b9..eb92e7fc 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -1,32 +1,16 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{self, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ constants::{LIQUIDITY_SEED, MINIMUM_LIQUIDITY}, errors::AmmError, + liquidity::{deposit_and_mint_lp_tokens, LiquidityDepositAccounts}, state::PoolConfig, }; -/// Integer sqrt via Newton's method. Operates on `u128` so it can handle the -/// product `amount_a * amount_b` (each is a `u64`, product can fill the full -/// `u128`). Floors the result, which matches Uniswap V2's `Math.sqrt` and -/// keeps the initial-deposit LP-mint rounding in the pool's favour. -fn integer_sqrt(n: u128) -> u128 { - if n < 2 { - return n; - } - let mut x = n; - let mut y = x.div_ceil(2); - while y < x { - x = y; - y = (x + n / x) / 2; - } - x -} - pub fn handle_deposit_liquidity( context: &mut Context, amount_a: u64, @@ -41,8 +25,6 @@ pub fn handle_deposit_liquidity( { return err!(AmmError::InsufficientBalance); } - let mut amount_a = amount_a; - let mut amount_b = amount_b; // Clamp the caller's (amount_a, amount_b) to the current pool ratio. // @@ -75,40 +57,45 @@ pub fn handle_deposit_liquidity( .amount() .checked_sub(pool_config.admin_fees_owed_b) .ok_or(AmmError::MathOverflow)?; - // Defining pool creation like this allows attackers to frontrun pool creation with bad ratios - let pool_creation = effective_pool_a == 0 && effective_pool_b == 0; - (amount_a, amount_b) = if pool_creation { - // Add as is if there is no liquidity. Admin fees can't be owed yet - // (no swap has happened), so the initial-deposit math is unchanged. - (amount_a, amount_b) + + // Every pool opens with its creator's deposit in `initialize_pool`, and + // neither a withdrawal nor a swap can empty a reserve (the + // `MINIMUM_LIQUIDITY` floor stays behind, and a swap's output is always + // less than the reserve it comes from), so both effective reserves are + // positive here. The check keeps the ratio math from dividing by zero if + // that ever stopped being true, rather than letting a depositor set the + // price of a pool that has none. + require!( + effective_pool_a > 0 && effective_pool_b > 0, + AmmError::EmptyPoolReserve + ); + + // amount_b_required = amount_a * effective_pool_b / effective_pool_a. + // Round down: this can only ever ask the depositor for *less* token B + // than perfect-ratio, which favours the pool by a sub-base-unit and + // matches Uniswap V2. + let amount_b_required = (amount_a as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_a as u128) + .ok_or(AmmError::MathOverflow)?; + let (amount_a, amount_b) = if amount_b_required <= amount_b as u128 { + // The depositor's `amount_b` is enough to cover the ratio; use + // the full `amount_a` and clamp `amount_b` down. + let amount_b_required = + u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a, amount_b_required) } else { - // amount_b_required = amount_a * effective_pool_b / effective_pool_a. - // Round down: this can only ever ask the depositor for *less* token B - // than perfect-ratio, which favours the pool by a sub-base-unit and - // matches Uniswap V2. - let amount_b_required = (amount_a as u128) - .checked_mul(effective_pool_b as u128) + // `amount_b` is the binding side; use the full `amount_b` and + // clamp `amount_a` down to what the ratio needs. + let amount_a_required = (amount_b as u128) + .checked_mul(effective_pool_a as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_a as u128) + .checked_div(effective_pool_b as u128) .ok_or(AmmError::MathOverflow)?; - if amount_b_required <= amount_b as u128 { - // The depositor's `amount_b` is enough to cover the ratio; use - // the full `amount_a` and clamp `amount_b` down. - let amount_b_required = - u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a, amount_b_required) - } else { - // `amount_b` is the binding side; use the full `amount_b` and - // clamp `amount_a` down to what the ratio needs. - let amount_a_required = (amount_b as u128) - .checked_mul(effective_pool_a as u128) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_b as u128) - .ok_or(AmmError::MathOverflow)?; - let amount_a_required = - u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a_required, amount_b) - } + let amount_a_required = + u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a_required, amount_b) }; // After clamping, both sides must contribute something. If either side @@ -116,37 +103,25 @@ pub fn handle_deposit_liquidity( // ratio (e.g. a depositor offering 1 base unit of A against a pool where // 1 A is worth less than 1 base unit of B). Fail rather than letting an // LP mint zero-priced shares. - if !pool_creation && (amount_a == 0 || amount_b == 0) { + if amount_a == 0 || amount_b == 0 { return err!(AmmError::DepositAmountTooSmall); } - // LP-mint math. Two branches: - // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. - // The `MINIMUM_LIQUIDITY` floor is never minted to anyone: the first - // depositor receives `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and - // from then on both the subsequent-deposit branch below and - // withdraw_liquidity divide by `lp_supply + MINIMUM_LIQUIDITY`, so the - // floor counts as supply that nobody holds. Its share of the reserves - // stays in the pool, claimable by nobody. An attacker who inflates the - // value behind each share by donating to the vaults inflates the value - // behind those units too, and loses that part of the donation. - // (Uniswap V2 instead mints the floor to the zero address; here, if - // every LP token is burned, the floor's leftover reserves stay behind - // and the next depositor mints against them through the - // subsequent-deposit branch.) - // - Subsequent deposit: `liquidity = min(a * total / pool_a, b * total / pool_b)` - // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical - // Uniswap V2 formula: mint LP tokens in proportion to the depositor's - // share of each reserve, taking the smaller side as the binding - // constraint. After the ratio clamp above, both sides give the same - // result; `min` is kept as an invariant safety net and to match the - // published formula (Uniswap V2's `totalSupply` includes the floor it - // minted to the zero address). `total` must be the same divisor - // withdraw_liquidity uses: dividing by the bare mint supply here would - // mint every depositor slightly less than they could redeem, and would - // let a donation merely as large as a victim's deposit round that - // deposit down to zero LP tokens, where counting the floor makes the - // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. + // LP-mint math: `liquidity = min(a * total / pool_a, b * total / pool_b)` + // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical + // Uniswap V2 formula: mint LP tokens in proportion to the depositor's + // share of each reserve, taking the smaller side as the binding + // constraint. After the ratio clamp above, both sides give the same + // result; `min` is kept as an invariant safety net and to match the + // published formula (Uniswap V2's `totalSupply` includes the floor it + // minted to the zero address). `total` must be the same divisor + // withdraw_liquidity uses: dividing by the bare mint supply here would + // mint every depositor slightly less than they could redeem, and would + // let a donation merely as large as a victim's deposit round that + // deposit down to zero LP tokens, where counting the floor makes the + // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. + // The floor itself is withheld from the creator's deposit in + // `initialize_pool` (see `liquidity::initial_lp_amount`). // // All math is in `u128` with checked arithmetic. `amount * total` can // overflow `u64`, but `u128` absorbs it for any supply a real mint can @@ -154,41 +129,24 @@ pub fn handle_deposit_liquidity( // dividing to keep precision, then round down (floor) so the pool keeps // any sub-unit rounding dust - program-favouring rounding, per the // financial-math rules. - let liquidity: u64 = if pool_creation { - let product = (amount_a as u128) - .checked_mul(amount_b as u128) - .ok_or(AmmError::MathOverflow)?; - let sqrt_product = integer_sqrt(product); - let sqrt_product_u64 = u64::try_from(sqrt_product).map_err(|_| AmmError::MathOverflow)?; - if sqrt_product_u64 < MINIMUM_LIQUIDITY { - return err!(AmmError::DepositTooSmall); - } - sqrt_product_u64 - .checked_sub(MINIMUM_LIQUIDITY) - .ok_or(AmmError::MathOverflow)? - } else { - // The floor is part of the supply the reserves are shared among, as - // it is in withdraw_liquidity. - let total_supply = (context.accounts.liquidity_provider_mint.supply() as u128) - .checked_add(MINIMUM_LIQUIDITY as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity_from_a = (amount_a as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_a as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity_from_b = (amount_b as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(effective_pool_b as u128) - .ok_or(AmmError::MathOverflow)?; - let liquidity = liquidity_from_a.min(liquidity_from_b); - u64::try_from(liquidity).map_err(|_| AmmError::MathOverflow)? - }; + let total_supply = (context.accounts.liquidity_provider_mint.supply() as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity_from_a = (amount_a as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_a as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity_from_b = (amount_b as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity: u64 = u64::try_from(liquidity_from_a.min(liquidity_from_b)) + .map_err(|_| AmmError::MathOverflow)?; if liquidity == 0 { - // Subsequent deposit too small relative to existing LP supply. - // (Initial deposits hit the `MINIMUM_LIQUIDITY` check above.) + // Deposit too small relative to the existing LP supply. return err!(AmmError::DepositTooSmall); } @@ -205,72 +163,26 @@ pub fn handle_deposit_liquidity( AmmError::DepositBelowMinimum ); - // Transfer tokens to the pool using transfer_checked. transfer_checked - // includes the mint and decimals in the CPI, which guards callers against - // decimal-mismatch bugs (and is the modern recommended path). - token_interface::transfer_checked( - CpiContext::new( - context.accounts.token_program.address(), - TransferChecked { - from: context.accounts.token_a.to_cpi_handle_mut(), - mint: context.accounts.mint_a.to_cpi_handle(), - to: context.accounts.pool_a.to_cpi_handle_mut(), - authority: context.accounts.depositor.cpi_handle(), - }, - ), + // `pool_config` is loaded read-only here, so there is nothing to take + // back after the mint releases its borrow. + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &context.accounts.token_program, + pool_config: &mut context.accounts.pool_config, + mint_a: &context.accounts.mint_a, + mint_b: &context.accounts.mint_b, + pool_a: &mut context.accounts.pool_a, + pool_b: &mut context.accounts.pool_b, + depositor: &context.accounts.depositor, + depositor_token_a: &mut context.accounts.token_a, + depositor_token_b: &mut context.accounts.token_b, + liquidity_provider_mint: &mut context.accounts.liquidity_provider_mint, + liquidity_provider_token: &mut context.accounts.liquidity_provider_token, + }, amount_a, - context.accounts.mint_a.decimals(), - )?; - token_interface::transfer_checked( - CpiContext::new( - context.accounts.token_program.address(), - TransferChecked { - from: context.accounts.token_b.to_cpi_handle_mut(), - mint: context.accounts.mint_b.to_cpi_handle(), - to: context.accounts.pool_b.to_cpi_handle_mut(), - authority: context.accounts.depositor.cpi_handle(), - }, - ), amount_b, - context.accounts.mint_b.decimals(), - )?; - - // Mint the liquidity to the user. `pool_config` is the LP mint's - // authority and signs with its own seeds. - let config_bytes = context.accounts.pool_config.config.to_bytes(); - let mint_a_bytes = context.accounts.mint_a.address().to_bytes(); - let mint_b_bytes = context.accounts.mint_b.address().to_bytes(); - let pool_config_bump = [context.accounts.pool_config.bump]; - let signer_seeds: &[&[&[u8]]] = &[&[ - config_bytes.as_ref(), - mint_a_bytes.as_ref(), - mint_b_bytes.as_ref(), - &pool_config_bump, - ]]; - - // `pool_config` signs the CPI below. It is a data account holding a live - // borrow on its buffer, which the runtime would reject when the CPI - // borrows the same account, so hand the borrow back for the duration. It - // is loaded read-only here, so there is nothing to take back afterwards. - context.accounts.pool_config.release_borrow()?; - - token_interface::mint_to( - CpiContext::new_with_signer( - context.accounts.token_program.address(), - MintTo { - mint: context.accounts.liquidity_provider_mint.to_cpi_handle_mut(), - to: context - .accounts - .liquidity_provider_token - .to_cpi_handle_mut(), - authority: context.accounts.pool_config.to_cpi_handle(), - }, - signer_seeds, - ), liquidity, - )?; - - Ok(()) + ) } #[derive(Accounts)] @@ -286,7 +198,7 @@ pub struct DepositLiquidityAccountConstraints { )] pub pool_config: Box>, - /// The account paying for all rents + /// Makes the deposit and receives the LP tokens. pub depositor: Signer, #[account( diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/initialize_pool.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/initialize_pool.rs index 581c3f59..a2aebeeb 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/initialize_pool.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/initialize_pool.rs @@ -8,12 +8,30 @@ use anchor_spl::{ use crate::{ constants::{CONFIG_SEED, LIQUIDITY_SEED}, errors::AmmError, + liquidity::{deposit_and_mint_lp_tokens, initial_lp_amount, LiquidityDepositAccounts}, state::{Config, PoolConfig}, }; +/// Creates the pool and takes the creator's first deposit in the same +/// instruction. The first deposit sets the pool's price (the ratio of its +/// reserves), so a pool that existed empty between two transactions would let +/// whoever deposited first set the price for its creator. Taking the deposit +/// here means every pool has both reserves positive from the moment it +/// exists, and `deposit_liquidity` only ever clamps to a price the creator +/// chose. pub fn handle_initialize_pool( context: &mut Context, + amount_a: u64, + amount_b: u64, ) -> Result<()> { + require!(amount_a > 0 && amount_b > 0, AmmError::EmptyInitialDeposit); + if amount_a > context.accounts.creator_token_a.amount() + || amount_b > context.accounts.creator_token_b.amount() + { + return err!(AmmError::InsufficientBalance); + } + let lp_amount = initial_lp_amount(amount_a, amount_b)?; + let bump = context.bumps.pool_config; let pool_config = &mut context.accounts.pool_config; pool_config.config = *context.accounts.config.address(); @@ -21,6 +39,30 @@ pub fn handle_initialize_pool( pool_config.mint_b = *context.accounts.mint_b.address(); pool_config.bump = bump; + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &context.accounts.token_program, + pool_config: &mut context.accounts.pool_config, + mint_a: &context.accounts.mint_a, + mint_b: &context.accounts.mint_b, + pool_a: &mut context.accounts.pool_a, + pool_b: &mut context.accounts.pool_b, + depositor: &context.accounts.creator, + depositor_token_a: &mut context.accounts.creator_token_a, + depositor_token_b: &mut context.accounts.creator_token_b, + liquidity_provider_mint: &mut context.accounts.liquidity_provider_mint, + liquidity_provider_token: &mut context.accounts.liquidity_provider_token, + }, + amount_a, + amount_b, + lp_amount, + )?; + + // `pool_config` was created here, so it is loaded mutably and the derive + // writes it back when the handler returns: take the borrow the mint + // released back before then. + context.accounts.pool_config.reacquire_borrow_mut()?; + Ok(()) } @@ -91,6 +133,36 @@ pub struct InitializePoolAccountConstraints { )] pub pool_b: Box>, + /// Makes the first deposit and receives the first LP tokens. + pub creator: Signer, + + #[account( + mut, + associated_token::mint = mint_a, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub creator_token_a: Box>, + + #[account( + mut, + associated_token::mint = mint_b, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub creator_token_b: Box>, + + /// The creator's LP token account, created here because the LP mint did + /// not exist before this instruction. + #[account( + init, + payer = payer, + associated_token::mint = liquidity_provider_mint, + associated_token::authority = creator, + associated_token::token_program = token_program, + )] + pub liquidity_provider_token: Box>, + /// The account paying for all rents #[account(mut)] pub payer: Signer, diff --git a/finance/token-swap/anchor/programs/token-swap/src/lib.rs b/finance/token-swap/anchor/programs/token-swap/src/lib.rs index df2acb44..35ecfb1b 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/lib.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/lib.rs @@ -3,6 +3,7 @@ use anchor_lang::prelude::*; mod constants; pub mod errors; pub mod instructions; +mod liquidity; mod state; // The `#[derive(Accounts)]` client modules are generated beside their structs, @@ -24,8 +25,12 @@ pub mod swap_example { instructions::handle_initialize_config(context, fee, admin_share_bps) } - pub fn initialize_pool(context: &mut Context) -> Result<()> { - instructions::handle_initialize_pool(context) + pub fn initialize_pool( + context: &mut Context, + amount_a: u64, + amount_b: u64, + ) -> Result<()> { + instructions::handle_initialize_pool(context, amount_a, amount_b) } pub fn deposit_liquidity( diff --git a/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs new file mode 100644 index 00000000..026da3ca --- /dev/null +++ b/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs @@ -0,0 +1,159 @@ +//! The two things every liquidity deposit ends with, shared by +//! `initialize_pool` (the creator's first deposit) and `deposit_liquidity` +//! (every later one): moving both tokens into the reserves and minting LP +//! tokens signed by `pool_config`. Only the arithmetic that decides the +//! amounts differs between the two handlers, and the first deposit's +//! arithmetic lives here too so there is one copy of it. + +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + self, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::{constants::MINIMUM_LIQUIDITY, errors::AmmError, state::PoolConfig}; + +/// Integer sqrt via Newton's method. Operates on `u128` so it can handle the +/// product `amount_a * amount_b` (each is a `u64`, product can fill the full +/// `u128`). Floors the result, which matches Uniswap V2's `Math.sqrt` and +/// keeps the first deposit's LP-mint rounding in the pool's favour. +pub fn integer_sqrt(n: u128) -> u128 { + if n < 2 { + return n; + } + let mut x = n; + let mut y = x.div_ceil(2); + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +/// LP tokens minted to the creator for the deposit that opens a pool: +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// +/// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on +/// `deposit_liquidity` and `withdraw_liquidity` divide by +/// `lp_supply + MINIMUM_LIQUIDITY`, so the floor counts as supply that nobody +/// holds. Its share of the reserves stays in the pool, claimable by nobody. An +/// attacker who inflates the value behind each share by donating to the vaults +/// inflates the value behind those units too, and loses that part of the +/// donation. (Uniswap V2 instead mints the floor to the zero address; here, if +/// every LP token is burned, the floor's leftover reserves stay behind and the +/// next depositor mints against them through `deposit_liquidity`'s +/// proportional formula.) +/// +/// `u128` with checked arithmetic: `amount_a * amount_b` can fill the full +/// `u128`. +pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result { + let product = (amount_a as u128) + .checked_mul(amount_b as u128) + .ok_or(AmmError::MathOverflow)?; + let sqrt_product = u64::try_from(integer_sqrt(product)).map_err(|_| AmmError::MathOverflow)?; + if sqrt_product < MINIMUM_LIQUIDITY { + return err!(AmmError::DepositTooSmall); + } + let lp_amount = sqrt_product + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or(AmmError::MathOverflow)?; + Ok(lp_amount) +} + +/// The accounts a deposit moves tokens between. `pool_config` owns both +/// reserves and is the LP mint's authority, so it signs the mint with its own +/// seeds `[config, mint_a, mint_b, bump]`. +/// +/// The accounts a CPI writes are `&mut`: v2 hands a writable CPI slot a +/// `CpiHandleMut`, which borrows the wrapper mutably. `pool_config` is `&mut` +/// too, because it has to hand back its data borrow while it signs. +pub struct LiquidityDepositAccounts<'a> { + pub token_program: &'a Interface<'static, TokenInterface>, + pub pool_config: &'a mut BorshAccount, + pub mint_a: &'a InterfaceAccount, + pub mint_b: &'a InterfaceAccount, + pub pool_a: &'a mut InterfaceAccount, + pub pool_b: &'a mut InterfaceAccount, + pub depositor: &'a Signer, + pub depositor_token_a: &'a mut InterfaceAccount, + pub depositor_token_b: &'a mut InterfaceAccount, + pub liquidity_provider_mint: &'a mut InterfaceAccount, + pub liquidity_provider_token: &'a mut InterfaceAccount, +} + +/// Moves `amount_a` and `amount_b` from the depositor's token accounts into +/// the reserves, then mints `lp_amount` LP tokens to the depositor. The +/// caller has already decided all three amounts. +/// +/// `transfer_checked` carries the mint and decimals through the CPI, so a +/// wrong-mint or wrong-decimals account fails at the token program instead of +/// moving the wrong quantity. +/// +/// `pool_config` signs the mint. It is a data account holding a live borrow +/// on its buffer, which the runtime would reject when the CPI borrows the +/// same account, so this releases the borrow before the mint and leaves it +/// released. A caller that loaded `pool_config` mutably (`initialize_pool`, +/// which creates it) takes the borrow back with `reacquire_borrow_mut` once +/// this returns; a caller that loaded it read-only (`deposit_liquidity`) has +/// nothing to take back. +pub fn deposit_and_mint_lp_tokens( + accounts: LiquidityDepositAccounts, + amount_a: u64, + amount_b: u64, + lp_amount: u64, +) -> Result<()> { + token_interface::transfer_checked( + CpiContext::new( + accounts.token_program.address(), + TransferChecked { + from: accounts.depositor_token_a.to_cpi_handle_mut(), + mint: accounts.mint_a.to_cpi_handle(), + to: accounts.pool_a.to_cpi_handle_mut(), + authority: accounts.depositor.cpi_handle(), + }, + ), + amount_a, + accounts.mint_a.decimals(), + )?; + token_interface::transfer_checked( + CpiContext::new( + accounts.token_program.address(), + TransferChecked { + from: accounts.depositor_token_b.to_cpi_handle_mut(), + mint: accounts.mint_b.to_cpi_handle(), + to: accounts.pool_b.to_cpi_handle_mut(), + authority: accounts.depositor.cpi_handle(), + }, + ), + amount_b, + accounts.mint_b.decimals(), + )?; + + // Copy the seed inputs out first: `release_borrow` needs `&mut`, and the + // data cannot be read once it is released. + let pool_config = accounts.pool_config; + let config_bytes = pool_config.config.to_bytes(); + let mint_a_bytes = pool_config.mint_a.to_bytes(); + let mint_b_bytes = pool_config.mint_b.to_bytes(); + let pool_config_bump = [pool_config.bump]; + let signer_seeds: &[&[&[u8]]] = &[&[ + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), + &pool_config_bump, + ]]; + + pool_config.release_borrow()?; + + token_interface::mint_to( + CpiContext::new_with_signer( + accounts.token_program.address(), + MintTo { + mint: accounts.liquidity_provider_mint.to_cpi_handle_mut(), + to: accounts.liquidity_provider_token.to_cpi_handle_mut(), + authority: pool_config.to_cpi_handle(), + }, + signer_seeds, + ), + lp_amount, + ) +} diff --git a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs index 07b54e75..92fede8e 100644 --- a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs +++ b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs @@ -64,6 +64,7 @@ struct TestSetup { svm: LiteSVM, program_id: Address, payer: Keypair, + /// The config admin, who also opens the pool and is its first LP. admin: Keypair, config_key: Address, mint_a: Address, @@ -77,7 +78,31 @@ struct TestSetup { liquidity_account: Address, } -fn full_setup() -> TestSetup { +/// v2's `#[error_code]` does not log the variant name, so a failed transaction +/// carries only the numeric custom code: the enum discriminant plus anchor's +/// default 6000 offset. Assert on that rather than on a name that is no longer +/// in the logs. +const ANCHOR_ERROR_OFFSET: u32 = 6000; + +/// Assert that a transaction failed with the given custom error code. LiteSVM +/// puts the failure, including `Custom()`, in the error's `Debug` +/// output; `name` only labels the assertion message. +fn assert_custom_error(result: Result, code: u32, name: &str) { + let err = format!("{:?}", result.err().expect("transaction must fail")); + assert!( + err.contains(&format!("Custom({code})")), + "expected {name} (Custom({code})), got: {err}" + ); +} + +/// Assert that a transaction failed with the named program error. +fn assert_error_code(result: Result, expected: AmmError, name: &str) { + assert_custom_error(result, expected as u32 + ANCHOR_ERROR_OFFSET, name); +} + +/// Everything up to, but not including, the pool: the config, two ordered +/// mints, and the admin's token accounts holding 100 of each token. +fn setup_config_and_mints() -> TestSetup { let (mut svm, program_id, payer) = setup(); let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); @@ -148,33 +173,6 @@ fn full_setup() -> TestSetup { ) .unwrap(); - // Create Pool - let initialize_pool_ix = Instruction::new_with_bytes( - program_id, - &swap_example::instruction::InitializePool {}.data(), - swap_example::accounts::InitializePoolAccountConstraints { - config: config_key, - pool_config: pool_config_key, - liquidity_provider_mint, - mint_a, - mint_b, - pool_a, - pool_b, - payer: payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut svm, - vec![initialize_pool_ix], - &[&payer], - &payer.pubkey(), - ) - .unwrap(); - TestSetup { svm, program_id, @@ -193,18 +191,53 @@ fn full_setup() -> TestSetup { } } -/// v2's `#[error_code]` does not log the variant name, so a failed transaction -/// carries only the numeric custom code: the enum discriminant plus anchor's -/// default 6000 offset. Assert on that rather than on a name that is no longer -/// in the logs. -const ANCHOR_ERROR_OFFSET: u32 = 6000; +/// Helper: build an `initialize_pool` ix in which the admin opens the pool +/// with `amount_a` of token A and `amount_b` of token B. +fn initialize_pool_ix(ts: &TestSetup, amount_a: u64, amount_b: u64) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::InitializePool { amount_a, amount_b }.data(), + swap_example::accounts::InitializePoolAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + liquidity_provider_mint: ts.liquidity_provider_mint, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + creator: ts.admin.pubkey(), + creator_token_a: ts.holder_account_a, + creator_token_b: ts.holder_account_b, + liquidity_provider_token: ts.liquidity_account, + payer: ts.payer.pubkey(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} -fn assert_program_error(message: &str, expected: AmmError, name: &str) { - let code = expected as u32 + ANCHOR_ERROR_OFFSET; - assert!( - message.contains(&format!("Custom({code})")), - "expected {name} (Custom({code})), got: {message}" - ); +fn send_initialize_pool(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), String> { + let ix = initialize_pool_ix(ts, amount_a, amount_b); + send_transaction_from_instructions( + &mut ts.svm, + vec![ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .map(|_| ()) + .map_err(|e| format!("{e:?}")) +} + +/// The config, the mints, and a pool the admin opened with `initial_a` of +/// token A and `initial_b` of token B, so the pool's price is +/// `initial_a : initial_b` and the admin holds +/// `sqrt(initial_a * initial_b) - MINIMUM_LIQUIDITY` LP tokens. +fn setup_pool(initial_a: u64, initial_b: u64) -> TestSetup { + let mut ts = setup_config_and_mints(); + send_initialize_pool(&mut ts, initial_a, initial_b).expect("open the pool"); + ts } #[test] @@ -252,7 +285,7 @@ fn test_initialize_config() { /// signs the transfers out of the reserves and the LP mint/burn CPIs. #[test] fn test_pool_config_owns_reserves_and_lp_mint() { - let ts = full_setup(); + let ts = setup_pool(4_000_000, 1_000_000); let expected: &[u8] = ts.pool_config_key.as_ref(); // SPL token account layout: mint (32 bytes), then owner (32 bytes). @@ -265,7 +298,7 @@ fn test_pool_config_owns_reserves_and_lp_mint() { ); } - // SPL mint layout: COption mint_authority = 4-byte tag (1 = Some) + // SPL mint layout: COption
mint_authority = 4-byte tag (1 = Some) // followed by the 32-byte pubkey. let mint = ts .svm @@ -283,32 +316,130 @@ fn test_pool_config_owns_reserves_and_lp_mint() { ); } +/// `initialize_pool` creates the pool and takes the creator's deposit in the +/// same instruction: the reserves hold the two amounts, the creator holds +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens, and the LP +/// mint's supply is that same number, because the withheld floor is never +/// minted to anyone. +#[test] +fn test_initialize_pool_takes_first_deposit() { + let mut ts = setup_config_and_mints(); + let holder_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + send_initialize_pool(&mut ts, 4_000_000, 1_000_000).expect("open the pool"); + + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 4_000_000 + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), + 1_000_000 + ); + assert_eq!( + holder_a_before - get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(), + 4_000_000 + ); + assert_eq!( + holder_b_before - get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(), + 1_000_000 + ); + // sqrt(4_000_000 * 1_000_000) = 2_000_000, minus the 100 floor. + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 2_000_000 - MINIMUM_LIQUIDITY + ); + // SPL mint layout: 4-byte COption tag + 32-byte authority, then the + // u64 supply at byte 36. + let lp_mint = ts.svm.get_account(&ts.liquidity_provider_mint).unwrap(); + let lp_supply = u64::from_le_bytes(lp_mint.data[36..44].try_into().unwrap()); + assert_eq!(lp_supply, 2_000_000 - MINIMUM_LIQUIDITY); +} + +/// A pool cannot open empty on either side: the first deposit sets the +/// price, and a zero reserve has none. Nothing is created when the +/// instruction fails. +#[test] +fn test_initialize_pool_rejects_zero_amount_a() { + let mut ts = setup_config_and_mints(); + assert_error_code( + send_initialize_pool(&mut ts, 0, 1_000_000), + AmmError::EmptyInitialDeposit, + "EmptyInitialDeposit", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); +} + #[test] -fn test_deposit_liquidity() { - let mut ts = full_setup(); - let deposit_amount_a: u64 = 4_000_000; - let deposit_amount_b: u64 = 1_000_000; +fn test_initialize_pool_rejects_zero_amount_b() { + let mut ts = setup_config_and_mints(); + assert_error_code( + send_initialize_pool(&mut ts, 4_000_000, 0), + AmmError::EmptyInitialDeposit, + "EmptyInitialDeposit", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); +} + +/// A liquidity provider other than the admin: a funded wallet with token +/// accounts for both mints and the address of its (not yet created) LP token +/// account. +struct Depositor { + wallet: Keypair, + token_a: Address, + token_b: Address, + lp_token: Address, +} - let deposit_ix = Instruction::new_with_bytes( +/// Helper: create a wallet holding `amount_a` of token A and `amount_b` of +/// token B. +fn fund_depositor(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Depositor { + let wallet = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); + let token_a = + create_associated_token_account(&mut ts.svm, &wallet.pubkey(), &ts.mint_a, &ts.payer) + .unwrap(); + let token_b = + create_associated_token_account(&mut ts.svm, &wallet.pubkey(), &ts.mint_b, &ts.payer) + .unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &token_a, amount_a, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &token_b, amount_b, &ts.admin).unwrap(); + let lp_token = derive_ata(&wallet.pubkey(), &ts.liquidity_provider_mint); + Depositor { + wallet, + token_a, + token_b, + lp_token, + } +} + +/// Helper: `deposit_liquidity` of up to `amount_a` and `amount_b` by a +/// depositor other than the admin, with no LP floor. +fn send_deposit_from( + ts: &mut TestSetup, + depositor: &Depositor, + amount_a: u64, + amount_b: u64, +) -> Result<(), String> { + let ix = Instruction::new_with_bytes( ts.program_id, &swap_example::instruction::DepositLiquidity { - amount_a: deposit_amount_a, - amount_b: deposit_amount_b, - // 0 = no slippage floor for this baseline test + amount_a, + amount_b, minimum_lp_tokens_out: 0, } .data(), swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), + depositor: depositor.wallet.pubkey(), liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, pool_a: ts.pool_a, pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, + liquidity_provider_token: depositor.lp_token, + token_a: depositor.token_a, + token_b: depositor.token_b, payer: ts.payer.pubkey(), token_program: token_program_id(), associated_token_program: ata_program_id(), @@ -316,59 +447,116 @@ fn test_deposit_liquidity() { } .to_account_metas(None), ); - send_transaction_from_instructions( &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], + vec![ix], + &[&ts.payer, &depositor.wallet], &ts.payer.pubkey(), ) - .unwrap(); - - // Verify liquidity tokens were minted - let liq_amount = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - assert!(liq_amount > 0, "Should have received liquidity tokens"); + .map(|_| ()) + .map_err(|e| format!("{e:?}")) } +/// The front-run. Maria (the admin) opens an ACME/USDC pool with 400 ACME +/// and 900 USDC, a price of 2.25 USDC per ACME, in one `initialize_pool` +/// call; the pool is created, funded and priced before anyone else can +/// touch it. Mallory then calls `deposit_liquidity` at once with a hostile +/// ratio, 400 ACME and 100 USDC (0.25 USDC per ACME). Her deposit is clamped +/// to Maria's ratio: her 100 USDC binds, only 44.444444 of her 400 ACME is +/// taken, and the pool still prices at 2.25 afterwards. The last part of the +/// test checks the refusal in code: a `deposit_liquidity` against a pool whose +/// effective reserves are zero fails with `EmptyPoolReserve`, so no +/// instruction lets a pool with no reserves take a depositor's ratio as its +/// price. (`initialize_pool` is the only instruction that creates a pool, and +/// `test_initialize_pool_rejects_zero_amount_a` / `_b` show it refuses a +/// zero side.) #[test] -fn test_swap_a_to_b() { - let mut ts = full_setup(); +fn test_pool_creation_cannot_be_front_run() { + let one: u64 = 10u64.pow(6); // both mints have 6 decimals + let mut ts = setup_config_and_mints(); + // Maria is the admin. The setup gives her 100 of each token; top her up + // to the 400 ACME (token A) and 900 USDC (token B) she opens with. + mint_tokens_to_token_account( + &mut ts.svm, + &ts.mint_a, + &ts.holder_account_a, + 300 * one, + &ts.admin, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut ts.svm, + &ts.mint_b, + &ts.holder_account_b, + 800 * one, + &ts.admin, + ) + .unwrap(); + send_initialize_pool(&mut ts, 400 * one, 900 * one).expect("Maria opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 400 * one + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), + 900 * one + ); - // Deposit liquidity first - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: 4_000_000, - amount_b: 1_000_000, - // 0 = no slippage floor for this setup deposit - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), + let mallory = fund_depositor(&mut ts, 400 * one, 100 * one); + send_deposit_from(&mut ts, &mallory, 400 * one, 100 * one) + .expect("a deposit at the wrong ratio is clamped, not refused"); + + // 400 ACME would need 900 USDC at Maria's price, more than Mallory + // offered, so her 100 USDC binds: 100 * 400 / 900 = 44.444444 ACME. + let acme_taken = 400 * one - get_token_account_balance(&ts.svm, &mallory.token_a).unwrap(); + let usdc_taken = 100 * one - get_token_account_balance(&ts.svm, &mallory.token_b).unwrap(); + assert_eq!(acme_taken, 44_444_444); + assert_eq!(usdc_taken, 100 * one); + let pool_a = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + assert_eq!(pool_a, 444_444_444); + assert_eq!(pool_b, 1_000 * one); + // The price is still 2.25 USDC per ACME, to the cent. + let price_in_cents = (pool_b as u128) * 100 / (pool_a as u128); + assert_eq!(price_in_cents, 225); + // Mallory is minted her share and nothing more: the LP supply is + // sqrt(400 * 900) = 600 (floor included), and 100 USDC is a ninth of + // the 900 in the pool, so min(44.444444 * 600 / 400, 100 * 600 / 900) + // = 66.666666 LP tokens. + assert_eq!( + get_token_account_balance(&ts.svm, &mallory.lp_token).unwrap(), + 66_666_666 ); - send_transaction_from_instructions( + + // The pool can never be in this state through the program, so put it + // there by hand: zero the token-A reserve's balance (an SPL token + // account; the `amount` is the u64 at bytes 64..72) and show the handler + // refuses to price a pool with an empty reserve. + let mut reserve = ts.svm.get_account(&ts.pool_a).unwrap(); + reserve.data[64..72].copy_from_slice(&0u64.to_le_bytes()); + ts.svm.set_account(ts.pool_a, reserve).unwrap(); + // The balance check runs first, so give Mallory the USDC she offers + // (a new blockhash, or this mint is byte-identical to the one in + // `fund_depositor` and litesvm rejects it as already processed). + ts.svm.expire_blockhash(); + mint_tokens_to_token_account( &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), + &ts.mint_b, + &mallory.token_b, + 100 * one, + &ts.admin, ) .unwrap(); + assert_error_code( + send_deposit_from(&mut ts, &mallory, 300 * one, 100 * one), + AmmError::EmptyPoolReserve, + "EmptyPoolReserve", + ); +} + +#[test] +fn test_swap_a_to_b() { + let mut ts = setup_pool(4_000_000, 1_000_000); // Get balances before swap let before_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); @@ -417,43 +605,7 @@ fn test_swap_a_to_b() { #[test] fn test_withdraw_liquidity() { - let mut ts = full_setup(); - - // Deposit liquidity - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: 4_000_000, - amount_b: 4_000_000, - // 0 = no slippage floor for this setup deposit - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); + let mut ts = setup_pool(4_000_000, 4_000_000); // Get liquidity token balance let liq_amount = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); @@ -501,85 +653,6 @@ fn test_withdraw_liquidity() { assert_eq!(liq_amount, 0, "Liquidity should be fully withdrawn"); } -/// Helper: do a deposit and one A->B swap on top of `full_setup`. -/// Returns the swap input amount (token A base units) for fee-arithmetic checks. -fn deposit_and_swap_a_to_b( - ts: &mut TestSetup, - deposit_a: u64, - deposit_b: u64, - swap_in_a: u64, -) -> u64 { - let deposit_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: deposit_a, - amount_b: deposit_b, - // 0 = no slippage floor for setup deposits in helpers - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: ts.admin.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: ts.liquidity_account, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![deposit_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); - - let swap_ix = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::SwapTokens { - input_is_token_a: true, - input_amount: swap_in_a, - min_output_amount: 1, - } - .data(), - swap_example::accounts::SwapTokensAccountConstraints { - config: ts.config_key, - pool_config: ts.pool_config_key, - trader: ts.admin.pubkey(), - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - token_a: ts.holder_account_a, - token_b: ts.holder_account_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![swap_ix], - &[&ts.payer, &ts.admin], - &ts.payer.pubkey(), - ) - .unwrap(); - - swap_in_a -} - /// Helper: build a `claim_admin_fees` instruction for the standard setup. fn claim_admin_fees_ix(ts: &TestSetup) -> Instruction { Instruction::new_with_bytes( @@ -639,13 +712,13 @@ fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { #[test] fn test_claim_admin_fees() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); // fee = 500 bps (5%), admin_share_bps = 1667 (~1/6). // Per the swap: fee_amount = input * 500 / 10_000 = input * 5 / 100 // admin_portion = fee_amount * 1667 / 10_000 // Swap input is on the A side, so admin's claim accumulates in mint A. let swap_in = 1_000_000u64; - deposit_and_swap_a_to_b(&mut ts, 4_000_000, 1_000_000, swap_in); + swap_a_to_b(&mut ts, swap_in); let fee_amount = swap_in * 500 / 10_000; let expected_admin_a = fee_amount * 1667 / 10_000; @@ -740,12 +813,7 @@ fn test_claim_admin_fees() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - assert!( - result.is_err(), - "claim with both accumulators at zero must revert" - ); - let err_msg = format!("{:?}", result.unwrap_err()); - assert_program_error(&err_msg, AmmError::NothingToClaim, "NothingToClaim"); + assert_error_code(result, AmmError::NothingToClaim, "NothingToClaim"); // Balance unchanged - the revert rolled back any partial state. let balance_a_after_third_claim = @@ -758,10 +826,10 @@ fn test_claim_admin_fees() { #[test] fn test_claim_admin_fees_rejects_non_admin() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); // Need at least one swap so the program has a reason to reach the claim // handler (not strictly required, but matches the realistic flow). - deposit_and_swap_a_to_b(&mut ts, 4_000_000, 1_000_000, 1_000_000); + swap_a_to_b(&mut ts, 1_000_000); // Create a non-admin actor with their own ATAs and try to claim. let attacker = create_wallet(&mut ts.svm, 100_000_000_000).unwrap(); @@ -798,10 +866,7 @@ fn test_claim_admin_fees_rejects_non_admin() { &[&ts.payer, &attacker], &ts.payer.pubkey(), ); - assert!( - result.is_err(), - "claim_admin_fees by a non-admin signer must fail" - ); + assert_custom_error(result, 2012, "ConstraintAddress"); } /// Helper: issue a `deposit_liquidity` ix with the given amounts. Lets a test @@ -860,11 +925,9 @@ fn send_deposit(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), /// matching amounts unchanged and that LP tokens are minted. #[test] fn test_deposit_into_funded_pool_at_correct_ratio() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Seed the pool at a 4:1 ratio. This hits the pool-creation branch, which - // is unchanged by the fix. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + // The pool opened at a 4:1 ratio. let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -897,10 +960,7 @@ fn test_deposit_into_funded_pool_at_correct_ratio() { /// should be clamped down; `amount_a` should be used in full. #[test] fn test_deposit_clamps_excess_amount_b() { - let mut ts = full_setup(); - - // Seed at 4:1. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -936,10 +996,7 @@ fn test_deposit_clamps_excess_amount_b() { /// should be clamped down; `amount_b` should be used in full. #[test] fn test_deposit_clamps_excess_amount_a() { - let mut ts = full_setup(); - - // Seed at 4:1. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -969,12 +1026,10 @@ fn test_deposit_clamps_excess_amount_a() { /// effective-reserves subtraction works under real swap fees. #[test] fn test_deposit_after_swap_uses_shifted_effective_ratio() { - let mut ts = full_setup(); + let mut ts = setup_pool(10_000_000, 10_000_000); - // Seed at 100:100 (1:1) so the post-swap ratio is dramatic and easy to + // Opened at 1:1 so the post-swap ratio is dramatic and easy to // sanity-check. - send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); - // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps: // fee_amount = 50_000 // admin_portion = 50_000 * 1667 / 10_000 = 8_335 (accrues on side A) @@ -1076,18 +1131,16 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { /// zero contribution. #[test] fn test_deposit_too_small_for_ratio_reverts() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Seed at 4M:1M (A is "cheaper" - 4 A per 1 B). To force amount_b to + // Opened at 4M:1M (A is "cheaper" - 4 A per 1 B). To force amount_b to // round down to zero, the depositor must offer < 4 base units of A // (so amount_b_required = amount_a * 1M / 4M = 0). We offer 1 base unit // of A and a large amount_b. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); - - let result = send_deposit(&mut ts, 1, 1_000_000); - assert!( - result.is_err(), - "sub-ratio deposit should revert (clamped amount rounds to zero)" + assert_error_code( + send_deposit(&mut ts, 1, 1_000_000), + AmmError::DepositAmountTooSmall, + "DepositAmountTooSmall", ); } @@ -1100,18 +1153,16 @@ fn test_deposit_too_small_for_ratio_reverts() { /// proportionality. #[test] fn test_lp_mint_proportional_to_share_of_pool() { - let mut ts = full_setup(); + let mut ts = setup_pool(4_000_000, 1_000_000); - // Initial deposit: 4M : 1M. sqrt(4M * 1M) = 2_000_000. Minus - // MINIMUM_LIQUIDITY (100) → depositor LP balance = 1_999_900, which is + // The pool opened with 4M : 1M. sqrt(4M * 1M) = 2_000_000. Minus + // MINIMUM_LIQUIDITY (100) → creator LP balance = 1_999_900, which is // also the total LP supply (we don't mint the locked floor anywhere). - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("initial deposit"); - let lp_supply_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); let expected_initial: u64 = 2_000_000 - 100; assert_eq!( lp_supply_initial, expected_initial, - "initial LP supply should equal sqrt(a*b) - MINIMUM_LIQUIDITY" + "the creator's LP balance should equal sqrt(a*b) - MINIMUM_LIQUIDITY" ); // Second deposit at the same 4:1 ratio doubles the pool. The proportional @@ -1119,10 +1170,6 @@ fn test_lp_mint_proportional_to_share_of_pool() { // LP: the first deposit's whole `sqrt(a*b)`, floor included, because the // second depositor owns exactly as much of the pool as it does. let lp_before_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - // Same depositor + same args as the first deposit → identical tx - // signature. Bump the blockhash so litesvm doesn't reject the second - // tx as `AlreadyProcessed`. - ts.svm.expire_blockhash(); send_deposit(&mut ts, 4_000_000, 1_000_000).expect("second deposit"); let lp_after_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); @@ -1162,10 +1209,9 @@ fn test_lp_mint_proportional_to_share_of_pool() { /// keep shares honest. #[test] fn test_lp_mint_after_swap_uses_effective_reserves() { - let mut ts = full_setup(); + let mut ts = setup_pool(10_000_000, 10_000_000); // Seed 10M : 10M, then swap A→B so the pool shifts off 1:1. - send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); let lp_after_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // The divisor counts the unminted MINIMUM_LIQUIDITY floor as supply. let total_supply_before_second = lp_after_initial + MINIMUM_LIQUIDITY; @@ -1226,10 +1272,9 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { /// 100 of the 101 units it was spread over belong to no one. #[test] fn test_donation_cannot_round_a_later_deposit_to_zero() { - let mut ts = full_setup(); + let mut ts = setup_pool(101, 101); // sqrt(101 * 101) = 101, minus the 100 floor: the attacker holds 1 LP. - send_deposit(&mut ts, 101, 101).expect("attacker opens the pool"); assert_eq!( get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), 1 @@ -1242,52 +1287,15 @@ fn test_donation_cannot_round_a_later_deposit_to_zero() { // The victim deposits exactly as much as was donated. Dividing by the // bare supply of 1 would mint 1_000_000 * 1 / 1_000_101 = 0 LP tokens. - let victim = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); - let victim_a = - create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_a, &ts.payer) - .unwrap(); - let victim_b = - create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_b, &ts.payer) - .unwrap(); - mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &victim_a, donation, &ts.admin).unwrap(); - mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &victim_b, donation, &ts.admin).unwrap(); - let victim_lp = derive_ata(&victim.pubkey(), &ts.liquidity_provider_mint); - let victim_deposit = Instruction::new_with_bytes( - ts.program_id, - &swap_example::instruction::DepositLiquidity { - amount_a: donation, - amount_b: donation, - minimum_lp_tokens_out: 0, - } - .data(), - swap_example::accounts::DepositLiquidityAccountConstraints { - pool_config: ts.pool_config_key, - depositor: victim.pubkey(), - liquidity_provider_mint: ts.liquidity_provider_mint, - mint_a: ts.mint_a, - mint_b: ts.mint_b, - pool_a: ts.pool_a, - pool_b: ts.pool_b, - liquidity_provider_token: victim_lp, - token_a: victim_a, - token_b: victim_b, - payer: ts.payer.pubkey(), - token_program: token_program_id(), - associated_token_program: ata_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ts.svm, - vec![victim_deposit], - &[&ts.payer, &victim], - &ts.payer.pubkey(), - ) - .expect("victim deposit mints LP tokens"); + let victim = fund_depositor(&mut ts, donation, donation); + send_deposit_from(&mut ts, &victim, donation, donation) + .expect("victim deposit mints LP tokens"); // 1_000_000 * 101 / 1_000_101 = 100 LP tokens. - assert_eq!(get_token_account_balance(&ts.svm, &victim_lp).unwrap(), 100); + assert_eq!( + get_token_account_balance(&ts.svm, &victim.lp_token).unwrap(), + 100 + ); // The attacker exits. Their 1 LP token is 1 of 201 units of supply over a // 2_000_101 reserve: 9_950 of each token, back from a 1_000_101 outlay. @@ -1310,13 +1318,12 @@ fn test_donation_cannot_round_a_later_deposit_to_zero() { } /// Test I: once every LP token is burned, the floor's share of the reserves -/// is still in the pool, so the next deposit takes the subsequent-deposit -/// branch and mints against the floor alone. Dividing by the bare supply of +/// is still in the pool, so the next deposit is clamped to the floor's ratio +/// and mints against the floor alone. Dividing by the bare supply of /// zero would mint nothing and leave the pool unable to take deposits again. #[test] fn test_deposit_after_every_lp_token_is_burned() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 4_000_000).expect("initial deposit"); + let mut ts = setup_pool(4_000_000, 4_000_000); let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Burn all 3_999_900 LP tokens: 3_999_900 * 4_000_000 / 4_000_000 of @@ -1333,7 +1340,6 @@ fn test_deposit_after_every_lp_token_is_burned() { assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), 100); // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. - ts.svm.expire_blockhash(); send_deposit(&mut ts, 1_000_000, 1_000_000).expect("deposit into the emptied pool"); assert_eq!( get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), @@ -1450,9 +1456,8 @@ fn withdraw_ix_with_min( /// at a worse rate. #[test] fn test_swap_reverts_when_output_below_min() { - let mut ts = full_setup(); - // Seed a 4:1 pool so 1M of A out gives ~237k of B after a 5% fee. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); + // A 4:1 pool, so 1M of A in gives ~237k of B after a 5% fee. // First: prove the swap *would* succeed with a permissive floor. let baseline_ix = swap_a_to_b_ix(&ts, 1_000_000, 1); @@ -1471,8 +1476,7 @@ fn test_swap_reverts_when_output_below_min() { // Reset and try the same swap with `min_output_amount = actual + 1`. It // must revert because the pool can't beat the previous output (in fact // it can't even match it - the first swap shifted the ratio). - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let too_high = actual_output + 1; let strict_ix = swap_a_to_b_ix(&ts, 1_000_000, too_high); let result = send_transaction_from_instructions( @@ -1481,8 +1485,7 @@ fn test_swap_reverts_when_output_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert")); - assert_program_error(&err, AmmError::SlippageExceeded, "SlippageExceeded"); + assert_error_code(result, AmmError::SlippageExceeded, "SlippageExceeded"); } /// Slippage test: a deposit with `minimum_lp_tokens_out` strictly higher @@ -1490,9 +1493,7 @@ fn test_swap_reverts_when_output_below_min() { /// `DepositBelowMinimum`. #[test] fn test_deposit_reverts_when_lp_below_min() { - let mut ts = full_setup(); - // Seed pool so the second deposit goes through the proportional branch. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); // Compute the LP that a `(4M, 1M)` deposit at the current ratio would // mint, using the same formula as the program (no probe tx needed): @@ -1515,8 +1516,7 @@ fn test_deposit_reverts_when_lp_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert")); - assert_program_error(&err, AmmError::DepositBelowMinimum, "DepositBelowMinimum"); + assert_error_code(result, AmmError::DepositBelowMinimum, "DepositBelowMinimum"); // Sanity: the same deposit with `achievable_lp` as the floor succeeds. let ok_ix = deposit_ix_with_min_lp(&ts, 4_000_000, 1_000_000, achievable_lp); @@ -1534,8 +1534,7 @@ fn test_deposit_reverts_when_lp_below_min() { /// revert with `WithdrawalBelowMinimum`. #[test] fn test_withdraw_reverts_when_below_min() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 4_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 4_000_000); let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Burning half the LP at a 4M:4M pool returns ~2M of each side, but the @@ -1548,9 +1547,8 @@ fn test_withdraw_reverts_when_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err = format!("{:?}", result.expect_err("must revert (A side)")); - assert_program_error( - &err, + assert_error_code( + result, AmmError::WithdrawalBelowMinimum, "WithdrawalBelowMinimum", ); @@ -1563,9 +1561,8 @@ fn test_withdraw_reverts_when_below_min() { &[&ts.payer, &ts.admin], &ts.payer.pubkey(), ); - let err_b = format!("{:?}", result_b.expect_err("must revert (B side)")); - assert_program_error( - &err_b, + assert_error_code( + result_b, AmmError::WithdrawalBelowMinimum, "WithdrawalBelowMinimum", ); @@ -1576,8 +1573,7 @@ fn test_withdraw_reverts_when_below_min() { /// hatch and must still succeed. #[test] fn test_swap_with_zero_min_output_still_succeeds() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let before_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); let ix = swap_a_to_b_ix(&ts, 1_000_000, 0); @@ -1628,10 +1624,9 @@ fn swap_b_to_a_ix(ts: &TestSetup, input_amount: u64, min_output_amount: u64) -> /// `input_amount`, so the trader underpaid for every B→A swap). #[test] fn test_swap_b_to_a_trader_pays_full_input() { - let mut ts = full_setup(); - // Seed at 4:1 so the B side is the scarce asset and a B→A swap returns a - // multiple of its input in A. - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); + // Opened at 4:1 so the B side is the scarce asset and a B→A swap returns + // a multiple of its input in A. let holder_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); @@ -1680,8 +1675,7 @@ fn test_swap_b_to_a_trader_pays_full_input() { /// variant so both directions of the symmetric flow are exercised. #[test] fn test_invariant_holds_after_b_to_a_swap() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_before = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); @@ -1723,8 +1717,7 @@ fn test_invariant_holds_after_b_to_a_swap() { /// gave away too much" bugs - verify the happy path doesn't trip it. #[test] fn test_invariant_holds_after_normal_swap() { - let mut ts = full_setup(); - send_deposit(&mut ts, 4_000_000, 1_000_000).expect("seed"); + let mut ts = setup_pool(4_000_000, 1_000_000); // Read effective reserves before. let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); diff --git a/finance/token-swap/kani-proofs/README.md b/finance/token-swap/kani-proofs/README.md index edc0b1da..12fe6417 100644 --- a/finance/token-swap/kani-proofs/README.md +++ b/finance/token-swap/kani-proofs/README.md @@ -14,9 +14,9 @@ assertion held or the input that breaks one. The on-chain instructions hand token movement to the SPL token program through CPIs that Kani cannot symbolically execute, but the *interesting* part, the -constant-product curve, the fee split, the integer square root used for the -initial LP mint, and the proportional deposit and withdraw math, is pure integer -arithmetic. This crate reproduces those formulas faithfully (same `u128` +constant-product curve, the fee split, the integer square root `initialize_pool` +uses for the creator's LP mint, and the proportional deposit and withdraw math, +is pure integer arithmetic. This crate reproduces those formulas faithfully (same `u128` widening, multiply-before-divide, floor rounding) and checks their invariants: - `proof_fee_split_bounds`: `fee <= input`, `admin_portion <= fee`, and `taxed_input + fee == input`. @@ -27,7 +27,7 @@ widening, multiply-before-divide, floor rounding) and checks their invariants: - `proof_withdraw_never_exceeds_reserve`: An LP can never withdraw more than the reserve holds (the `MINIMUM_LIQUIDITY` floor guarantees it). - `proof_deposit_withdraw_round_trip_is_fair`: Burning the LP tokens a deposit just minted returns at most the deposit, and less than one minor unit plus one LP token's worth short of it. The lower bound holds only because deposit and withdraw both divide by `lp_supply + MINIMUM_LIQUIDITY`; a deposit divided by the bare supply fails it. - `proof_rounding_a_deposit_to_zero_needs_floor_times_donation`: For a later deposit to mint zero LP tokens, the reserve must exceed the deposit times `attacker_lp + MINIMUM_LIQUIDITY`, so an attacker holding one LP token must donate about `MINIMUM_LIQUIDITY + 1` times the victim's deposit. -- `proof_deposit_clamp_never_exceeds_request`: The ratio clamp never spends more of either token than the caller offered. +- `proof_deposit_clamp_never_exceeds_request`: The ratio clamp never spends more of either token than the caller offered. The model refuses an empty reserve as `deposit_liquidity` does with `EmptyPoolReserve`; every pool opens with its creator's deposit in `initialize_pool`. ## Bounded model checking @@ -77,7 +77,8 @@ require!( so the drained state is unreachable on-chain regardless of any reachability argument. Reaching `effective_reserve == 0` was already a degenerate state the -deposit path prevents (the `MINIMUM_LIQUIDITY` floor keeps the bootstrap product +rest of the program prevents (`initialize_pool` opens every pool with both +reserves positive, the `MINIMUM_LIQUIDITY` floor keeps the bootstrap product positive, and `proof_swap_preserves_constant_product` shows ordinary swaps keep both sides positive), so this was a latent edge, not a live exploit, but the guard means solvency no longer *depends* on that argument. diff --git a/finance/token-swap/kani-proofs/src/lib.rs b/finance/token-swap/kani-proofs/src/lib.rs index 100352fe..cb9d0bb4 100644 --- a/finance/token-swap/kani-proofs/src/lib.rs +++ b/finance/token-swap/kani-proofs/src/lib.rs @@ -8,12 +8,12 @@ //! `kani-proofs` and the harnesses are named `proof_*`; each one is a model //! check, which tries every value of its declared inputs, not a formal proof. //! -//! The on-chain instructions (`swap_tokens`, `deposit_liquidity`, -//! `withdraw_liquidity`) hand the actual token movement to the SPL token -//! program via CPIs that Kani cannot symbolically execute. But the *interesting* -//! part — the constant-product curve, the fee split, the integer square root -//! used for the initial LP mint, and the proportional deposit and withdraw -//! math — is pure integer arithmetic. This crate reproduces those formulas +//! The on-chain instructions (`initialize_pool`, `swap_tokens`, +//! `deposit_liquidity`, `withdraw_liquidity`) hand the actual token movement +//! to the SPL token program via CPIs that Kani cannot symbolically execute. +//! But the *interesting* part — the constant-product curve, the fee split, the +//! integer square root `initialize_pool` uses for the creator's LP mint, and +//! the proportional deposit and withdraw math — is pure integer arithmetic. This crate reproduces those formulas //! faithfully (same `u128` widening, same multiply-before-divide, same floor //! rounding) and checks the invariants the program depends on. //! @@ -205,10 +205,12 @@ fn proof_swap_at_zero_reserve_drains_whole_pool() { } // =========================================================================== -// 3. Integer square root (deposit_liquidity.rs :: integer_sqrt) +// 3. Integer square root (liquidity.rs :: integer_sqrt) // =========================================================================== -/// Verbatim copy of `deposit_liquidity::integer_sqrt` (Newton's method, floor). +/// Verbatim copy of `liquidity::integer_sqrt` (Newton's method, floor), which +/// `liquidity::initial_lp_amount` calls for the deposit that opens a pool in +/// `initialize_pool`. /// /// Only the `#[cfg(kani)]` harness and the unit tests call it, so a plain /// `cargo build` of the library sees no caller. @@ -227,8 +229,9 @@ fn integer_sqrt(n: u128) -> u128 { } /// `integer_sqrt` returns the exact floor of the real square root: -/// `r*r <= n < (r+1)*(r+1)`. This is what makes the initial-deposit LP mint -/// (`sqrt(a*b) - MINIMUM_LIQUIDITY`) correct and program-favouring. +/// `r*r <= n < (r+1)*(r+1)`. This is what makes the creator's LP mint in +/// `initialize_pool` (`sqrt(a*b) - MINIMUM_LIQUIDITY`, in +/// `liquidity::initial_lp_amount`) correct and program-favouring. /// /// `n` is bounded so `(r+1)^2` cannot overflow `u128` and so the Newton /// iteration's unwind stays tractable; the property is value-general within the @@ -388,15 +391,17 @@ fn proof_rounding_a_deposit_to_zero_needs_floor_times_donation() { /// Models the Uniswap-V2 ratio clamp in `handle_deposit_liquidity`: given the /// caller's upper-bound `(amount_a, amount_b)` and the current effective /// reserves, return the clamped pair actually deposited. `None` on the overflow -/// paths. +/// paths and, as the program's `EmptyPoolReserve` check, on an empty reserve: +/// every pool opens with its creator's deposit in `initialize_pool`, so +/// `deposit_liquidity` never sets a price. pub fn clamp_to_ratio( amount_a: u64, amount_b: u64, effective_pool_a: u64, effective_pool_b: u64, ) -> Option<(u64, u64)> { - if effective_pool_a == 0 && effective_pool_b == 0 { - return Some((amount_a, amount_b)); // pool creation: take as-is + if effective_pool_a == 0 || effective_pool_b == 0 { + return None; } let amount_b_required = (amount_a as u128) .checked_mul(effective_pool_b as u128)? @@ -429,8 +434,8 @@ fn proof_deposit_clamp_never_exceeds_request() { // i.e. symbolic-÷-symbolic 128-bit division, over four symbolic variables. // Bound them tightly to stay tractable; the clamp identity is scale-free. kani::assume(amount_a <= 31 && amount_b <= 31); - // Existing pool: both reserves non-zero (the pool-creation branch is the - // trivial identity, so this harness does not check it). + // Both reserves non-zero, as they are in every pool `initialize_pool` + // opened. kani::assume(pool_a >= 1 && pool_a <= 31); kani::assume(pool_b >= 1 && pool_b <= 31); @@ -517,4 +522,10 @@ mod tests { // Pool 1:2, offer (10, 100) -> needs 20 B for 10 A; B is plentiful. assert_eq!(clamp_to_ratio(10, 100, 1_000, 2_000).unwrap(), (10, 20)); } + + #[test] + fn clamp_refuses_an_empty_reserve() { + assert_eq!(clamp_to_ratio(10, 100, 0, 2_000), None); + assert_eq!(clamp_to_ratio(10, 100, 1_000, 0), None); + } } diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index 397608b3..61cf9ead 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## Unreleased (2026-10-04) + +### Changed + +- `initialize_pool` now takes the creator's first deposit: it gains `amount_a` + and `amount_b` arguments and the `creator`, `creator_token_a`, + `creator_token_b` and `liquidity_provider_token` accounts, moves both + amounts into the reserves it creates, and mints the creator + `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens. A zero on either + side fails with the new `EmptyInitialDeposit`. A pool created empty let + whoever deposited first set its price, and clamped the creator's own + deposit to that ratio. `deposit_liquidity` no longer has a pool-creation + branch: it refuses an empty effective reserve with `EmptyPoolReserve`, the + error `swap_tokens` already returns for one. The square-root arithmetic (`initial_lp_amount`) and the transfers + and LP mint both handlers end with (`deposit_and_mint_lp_tokens`) live in + the new `liquidity` module, so there is one copy of each. New tests: + `initialize_pool_takes_first_deposit`, + `initialize_pool_rejects_zero_amount_a`, + `initialize_pool_rejects_zero_amount_b` and + `pool_creation_cannot_be_front_run`, which runs the front-run (a hostile + ratio deposited right after the pool opens is clamped to the creator's + price) and checks that a deposit against an empty reserve is refused; every + other test opens its pool through `initialize_pool`. + `swap_rejects_empty_reserve` now empties a reserve by hand, since no pool + exists without a deposit any more. + ## [2026-10-04] - Mint order and empty reserves ### Fixed diff --git a/finance/token-swap/quasar/README.md b/finance/token-swap/quasar/README.md index 4b7d68a6..2f96d7f9 100644 --- a/finance/token-swap/quasar/README.md +++ b/finance/token-swap/quasar/README.md @@ -43,8 +43,9 @@ the runtime hands the program the second slot as a duplicate of the first. failing with `EmptyPoolReserve`. Against an empty input side the constant-product formula pays out the whole opposite reserve, and the invariant check cannot catch it because the pre-trade product is zero. -`swap_rejects_empty_reserve` runs that swap against a pool whose `pool_b` was -funded directly before any deposit. +`swap_rejects_empty_reserve` runs that swap against a pool whose `pool_a` was +emptied by hand, a state no instruction produces now that every pool opens +with its creator's deposit. ## Setup diff --git a/finance/token-swap/quasar/src/error.rs b/finance/token-swap/quasar/src/error.rs index 440e9a14..9286a1d9 100644 --- a/finance/token-swap/quasar/src/error.rs +++ b/finance/token-swap/quasar/src/error.rs @@ -12,8 +12,11 @@ pub enum AmmError { /// share is a basis-points fraction of the trading fee, so the admin /// cannot take more than the whole fee. AdminShareTooHigh, - /// The initial deposit's geometric mean is below `MINIMUM_LIQUIDITY`, or a - /// subsequent deposit is too small to mint any LP tokens. + /// Depositing too little liquidity. Returned by `initialize_pool` when + /// `sqrt(amount_a * amount_b)` is below `MINIMUM_LIQUIDITY`, so withholding + /// the floor would leave the creator nothing, and by `deposit_liquidity` + /// when a later deposit is too small a share of the pool to mint a single + /// LP token. DepositTooSmall, /// Clamping the caller's amounts to the current pool ratio rounded one /// side down to zero; the pool cannot issue meaningful LP shares. @@ -50,9 +53,17 @@ pub enum AmmError { /// pool PDA; without it an (X, Y) pool and a (Y, X) pool would both be /// valid, fragmenting liquidity. InvalidMintOrder, - /// `swap_tokens` found an LP-claimable (effective) reserve of zero. - /// Swapping against an empty reserve would let the constant-product curve - /// pay out the whole opposite side while the invariant check passes - /// vacuously (k = 0 >= 0), so the swap is rejected outright. + /// `swap_tokens` or `deposit_liquidity` found an LP-claimable (effective) + /// reserve of zero. Swapping against an empty reserve would let the + /// constant-product curve pay out the whole opposite side while the + /// invariant check passes vacuously (k = 0 >= 0), and a deposit into an + /// empty pool would set its price, so both are rejected outright. Every + /// pool opens with its creator's deposit, and neither a withdrawal nor a + /// swap can empty a reserve, so the state is not reachable. EmptyPoolReserve, + /// A pool cannot open empty: the first deposit sets its price. Returned + /// by `initialize_pool` when either amount is zero. The deposit that opens + /// a pool sets its price (the ratio of its reserves), so a pool is never + /// created without one. + EmptyInitialDeposit, } diff --git a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs index a7611a3f..594665ed 100644 --- a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -1,10 +1,10 @@ use { crate::{ error::AmmError, + liquidity::{deposit_and_mint_lp_tokens, LiquidityDepositAccounts}, state::{Config, PoolConfig}, ConfigPda, LiquidityMintPda, PoolPda, }, - quasar_lang::cpi::Seed, quasar_lang::prelude::*, quasar_spl::prelude::*, }; @@ -22,7 +22,7 @@ pub struct DepositLiquidityAccountConstraints { has_one(pool_b) @ AmmError::InvalidPoolVault, )] pub pool_config: Account, - /// Depositor (must be signer to authorise transfers). + /// Makes the deposit and receives the LP tokens. pub depositor: Signer, /// LP mint at the LiquidityMintPda. /// @@ -61,22 +61,6 @@ pub struct DepositLiquidityAccountConstraints { pub system_program: Program, } -/// Integer square root via Newton's method. Operates on and returns `u128`; -/// callers narrow with `try_from` so an out-of-range result is a named error -/// instead of a silent truncation. -fn isqrt(n: u128) -> u128 { - if n == 0 { - return 0; - } - let mut x = n; - let mut y = x.div_ceil(2); - while y < x { - x = y; - y = (x + n / x) / 2; - } - x -} - #[inline(always)] pub fn handle_deposit_liquidity( accounts: &mut DepositLiquidityAccountConstraints, @@ -109,7 +93,18 @@ pub fn handle_deposit_liquidity( .amount() .checked_sub(accounts.pool_config.admin_fees_owed_b()) .ok_or(AmmError::MathOverflow)?; - let pool_creation = pool_a_amount == 0 && pool_b_amount == 0; + + // Every pool opens with its creator's deposit in `initialize_pool`, and + // neither a withdrawal nor a swap can empty a reserve (the + // `MINIMUM_LIQUIDITY` floor stays behind, and a swap's output is always + // less than the reserve it comes from), so both effective reserves are + // positive here. The check keeps the ratio math from dividing by zero if + // that ever stopped being true, rather than letting a depositor set the + // price of a pool that has none. + require!( + pool_a_amount > 0 && pool_b_amount > 0, + AmmError::EmptyPoolReserve + ); // Clamp the caller's (amount_a, amount_b) to the current pool ratio. // @@ -125,85 +120,65 @@ pub fn handle_deposit_liquidity( // All ratio math is u128 with checked arithmetic: `amount * reserve` can // overflow u64, and the final narrowing uses try_from so an oversized // result is a named error, not a truncation. - let (amount_a, amount_b) = if pool_creation { - // First deposit sets the initial price; both amounts are used as is. - (amount_a, amount_b) + // Round down: this can only ask the depositor for *less* of the other + // token than perfect-ratio, which favours the pool by a sub-minor-unit + // amount and matches Uniswap V2. + let amount_b_required = (amount_a as u128) + .checked_mul(pool_b_amount as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(pool_a_amount as u128) + .ok_or(AmmError::MathOverflow)?; + let (amount_a, amount_b) = if amount_b_required <= amount_b as u128 { + // The caller's `amount_b` covers the ratio: use the full `amount_a` + // and clamp `amount_b` down. + let amount_b_required = + u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a, amount_b_required) } else { - // Round down: this can only ask the depositor for *less* of the other - // token than perfect-ratio, which favours the pool by a sub-minor-unit - // amount and matches Uniswap V2. - let amount_b_required = (amount_a as u128) - .checked_mul(pool_b_amount as u128) + // `amount_b` is the binding side: use it in full and clamp `amount_a` + // down to what the ratio needs. + let amount_a_required = (amount_b as u128) + .checked_mul(pool_a_amount as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(pool_a_amount as u128) + .checked_div(pool_b_amount as u128) .ok_or(AmmError::MathOverflow)?; - if amount_b_required <= amount_b as u128 { - // The caller's `amount_b` covers the ratio: use the full - // `amount_a` and clamp `amount_b` down. - let amount_b_required = - u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a, amount_b_required) - } else { - // `amount_b` is the binding side: use it in full and clamp - // `amount_a` down to what the ratio needs. - let amount_a_required = (amount_b as u128) - .checked_mul(pool_a_amount as u128) - .ok_or(AmmError::MathOverflow)? - .checked_div(pool_b_amount as u128) - .ok_or(AmmError::MathOverflow)?; - let amount_a_required = - u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; - (amount_a_required, amount_b) - } + let amount_a_required = + u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; + (amount_a_required, amount_b) }; // After clamping, both sides must contribute something. If either side // rounds to zero the deposit is too small to register at the current // ratio. Fail rather than mint zero-priced LP shares. - if !pool_creation && (amount_a == 0 || amount_b == 0) { + if amount_a == 0 || amount_b == 0 { return Err(AmmError::DepositAmountTooSmall.into()); } - // LP-mint math, two branches: - // - First deposit: liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY. The - // geometric mean bootstraps the pool; the floor is never minted to - // anyone, so its share of the reserves stays in the pool for good. - // - Subsequent deposit: liquidity = min(a * total / pool_a, - // b * total / pool_b) with total = supply + MINIMUM_LIQUIDITY, - // proportional to the depositor's share of each reserve. The geometric - // mean must not be used here: it breaks proportionality once the pool - // has existing supply. `total` is the divisor withdraw_liquidity uses, - // so a deposit is minted exactly the share it can later redeem, and an - // attacker who donates to the vaults to round a later deposit down to - // zero must donate at least MINIMUM_LIQUIDITY + 1 times that deposit, - // most of which then belongs to the floor. Dividing by the bare supply - // would let a donation as large as the deposit do it. - let liquidity: u64 = if pool_creation { - let product = (amount_a as u128) - .checked_mul(amount_b as u128) - .ok_or(AmmError::MathOverflow)?; - let sqrt = u64::try_from(isqrt(product)).map_err(|_| AmmError::MathOverflow)?; - if sqrt < crate::MINIMUM_LIQUIDITY { - return Err(AmmError::DepositTooSmall.into()); - } - sqrt.checked_sub(crate::MINIMUM_LIQUIDITY) - .ok_or(AmmError::MathOverflow)? - } else { - let total_supply = (accounts.liquidity_provider_mint.supply() as u128) - .checked_add(crate::MINIMUM_LIQUIDITY as u128) - .ok_or(AmmError::MathOverflow)?; - let from_a = (amount_a as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(pool_a_amount as u128) - .ok_or(AmmError::MathOverflow)?; - let from_b = (amount_b as u128) - .checked_mul(total_supply) - .ok_or(AmmError::MathOverflow)? - .checked_div(pool_b_amount as u128) - .ok_or(AmmError::MathOverflow)?; - u64::try_from(from_a.min(from_b)).map_err(|_| AmmError::MathOverflow)? - }; + // LP-mint math: liquidity = min(a * total / pool_a, b * total / pool_b) + // with total = supply + MINIMUM_LIQUIDITY, proportional to the depositor's + // share of each reserve. The geometric mean must not be used here: it + // breaks proportionality once the pool has existing supply. `total` is + // the divisor withdraw_liquidity uses, so a deposit is minted exactly the + // share it can later redeem, and an attacker who donates to the vaults to + // round a later deposit down to zero must donate at least + // MINIMUM_LIQUIDITY + 1 times that deposit, most of which then belongs to + // the floor. Dividing by the bare supply would let a donation as large as + // the deposit do it. The floor itself is withheld from the creator's + // deposit in `initialize_pool` (see `liquidity::initial_lp_amount`). + let total_supply = (accounts.liquidity_provider_mint.supply() as u128) + .checked_add(crate::MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; + let from_a = (amount_a as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(pool_a_amount as u128) + .ok_or(AmmError::MathOverflow)?; + let from_b = (amount_b as u128) + .checked_mul(total_supply) + .ok_or(AmmError::MathOverflow)? + .checked_div(pool_b_amount as u128) + .ok_or(AmmError::MathOverflow)?; + let liquidity: u64 = u64::try_from(from_a.min(from_b)).map_err(|_| AmmError::MathOverflow)?; // Reject deposits too small to mint any LP tokens (skill: never mint // zero-priced shares). @@ -222,51 +197,23 @@ pub fn handle_deposit_liquidity( AmmError::DepositBelowMinimum ); - // Transfer token A to the pool. - accounts - .token_program - .transfer_checked( - &accounts.token_a, - &accounts.mint_a, - &accounts.pool_a, - &accounts.depositor, - amount_a, - accounts.mint_a.decimals(), - ) - .invoke()?; - - // Transfer token B to the pool. - accounts - .token_program - .transfer_checked( - &accounts.token_b, - &accounts.mint_b, - &accounts.pool_b, - &accounts.depositor, - amount_b, - accounts.mint_b.decimals(), - ) - .invoke()?; - - // Mint LP tokens to the depositor, signed by `pool_config` as the LP - // mint's authority. Seed order matches PoolPda: [config, mint_a, mint_b, bump]. - let bump = [bumps.pool_config]; - let seeds: &[Seed] = &[ - Seed::from(accounts.config.address().as_ref()), - Seed::from(accounts.mint_a.address().as_ref()), - Seed::from(accounts.mint_b.address().as_ref()), - Seed::from(&bump as &[u8]), - ]; - - accounts - .token_program - .mint_to( - &accounts.liquidity_provider_mint, - &accounts.liquidity_provider_token, - &accounts.pool_config, - liquidity, - ) - .invoke_signed(seeds)?; - - Ok(()) + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &accounts.token_program, + pool_config: &accounts.pool_config, + pool_config_bump: bumps.pool_config, + mint_a: &accounts.mint_a, + mint_b: &accounts.mint_b, + pool_a: &accounts.pool_a, + pool_b: &accounts.pool_b, + depositor: &accounts.depositor, + depositor_token_a: &accounts.token_a, + depositor_token_b: &accounts.token_b, + liquidity_provider_mint: &accounts.liquidity_provider_mint, + liquidity_provider_token: &accounts.liquidity_provider_token, + }, + amount_a, + amount_b, + liquidity, + ) } diff --git a/finance/token-swap/quasar/src/instructions/initialize_pool.rs b/finance/token-swap/quasar/src/instructions/initialize_pool.rs index bfdaa3f0..8b96e786 100644 --- a/finance/token-swap/quasar/src/instructions/initialize_pool.rs +++ b/finance/token-swap/quasar/src/instructions/initialize_pool.rs @@ -1,6 +1,7 @@ use { crate::{ error::AmmError, + liquidity::{deposit_and_mint_lp_tokens, initial_lp_amount, LiquidityDepositAccounts}, state::{Config, PoolConfig, PoolConfigInner}, ConfigPda, LiquidityMintPda, PoolAPda, PoolBPda, PoolPda, }, @@ -65,6 +66,23 @@ pub struct InitializePoolAccountConstraints { token(mint = mint_b, authority = pool_config, token_program = token_program), )] pub pool_b: Account, + /// Makes the first deposit and receives the first LP tokens. + pub creator: Signer, + /// The creator's token A account, the source of the first deposit. + #[account(mut)] + pub creator_token_a: Account, + /// The creator's token B account, the source of the first deposit. + #[account(mut)] + pub creator_token_b: Account, + /// The creator's LP token account, created here because the LP mint did + /// not exist before this instruction. + #[account( + mut, + init, + payer = payer, + token(mint = liquidity_provider_mint, authority = creator, token_program = token_program), + )] + pub liquidity_provider_token: Account, #[account(mut)] pub payer: Signer, pub token_program: Program, @@ -72,10 +90,28 @@ pub struct InitializePoolAccountConstraints { pub rent: Sysvar, } +/// Creates the pool and takes the creator's first deposit in the same +/// instruction. The first deposit sets the pool's price (the ratio of its +/// reserves), so a pool that existed empty between two transactions would let +/// whoever deposited first set the price for its creator. Taking the deposit +/// here means every pool has both reserves positive from the moment it +/// exists, and `deposit_liquidity` only ever clamps to a price the creator +/// chose. #[inline(always)] pub fn handle_initialize_pool( accounts: &mut InitializePoolAccountConstraints, + amount_a: u64, + amount_b: u64, + bumps: &InitializePoolAccountConstraintsBumps, ) -> Result<(), ProgramError> { + require!(amount_a > 0 && amount_b > 0, AmmError::EmptyInitialDeposit); + // Fail fast if the creator lacks the balance, as `deposit_liquidity` does. + if amount_a > accounts.creator_token_a.amount() || amount_b > accounts.creator_token_b.amount() + { + return Err(AmmError::InsufficientBalance.into()); + } + let lp_amount = initial_lp_amount(amount_a, amount_b)?; + accounts.pool_config.set_inner(PoolConfigInner { config: *accounts.config.address(), mint_a: *accounts.mint_a.address(), @@ -90,5 +126,24 @@ pub fn handle_initialize_pool( admin_fees_owed_a: 0, admin_fees_owed_b: 0, }); - Ok(()) + + deposit_and_mint_lp_tokens( + LiquidityDepositAccounts { + token_program: &accounts.token_program, + pool_config: &accounts.pool_config, + pool_config_bump: bumps.pool_config, + mint_a: &accounts.mint_a, + mint_b: &accounts.mint_b, + pool_a: &accounts.pool_a, + pool_b: &accounts.pool_b, + depositor: &accounts.creator, + depositor_token_a: &accounts.creator_token_a, + depositor_token_b: &accounts.creator_token_b, + liquidity_provider_mint: &accounts.liquidity_provider_mint, + liquidity_provider_token: &accounts.liquidity_provider_token, + }, + amount_a, + amount_b, + lp_amount, + ) } diff --git a/finance/token-swap/quasar/src/lib.rs b/finance/token-swap/quasar/src/lib.rs index 9638e030..4f0fed89 100644 --- a/finance/token-swap/quasar/src/lib.rs +++ b/finance/token-swap/quasar/src/lib.rs @@ -5,13 +5,15 @@ use quasar_lang::prelude::*; pub mod error; pub mod instructions; use instructions::*; +pub mod liquidity; pub mod state; #[cfg(test)] mod tests; declare_id!("GahM6PrXesrBkHiGJ5no4EskLNnVBCaSwVKbM4UtzyK6"); -/// Minimum liquidity locked on first deposit to prevent manipulation. +/// Minimum liquidity withheld from the creator's deposit in `initialize_pool` +/// to prevent manipulation. pub const MINIMUM_LIQUIDITY: u64 = 100; /// Basis-points denominator (1 bp = 1/10_000). Fees and the admin's fee share /// are stored in basis points; dividing by this converts a bp value to a @@ -64,8 +66,10 @@ pub struct PoolBPda; /// Six instructions: /// 1. `initialize_config` - initialise the singleton AMM config (admin, fee, /// admin share) -/// 2. `initialize_pool` - create a liquidity pool for a token pair -/// 3. `deposit_liquidity` - add liquidity and receive LP tokens +/// 2. `initialize_pool` - create a liquidity pool for a token pair, funded +/// and priced by its creator's first deposit +/// 3. `deposit_liquidity` - add liquidity at the pool's price and receive LP +/// tokens /// 4. `withdraw_liquidity` - burn LP tokens and receive pool tokens /// 5. `swap_tokens` - swap one token for another /// 6. `claim_admin_fees` - admin sweeps accumulated fee slice from a pool @@ -83,8 +87,12 @@ mod quasar_token_swap { } #[instruction(discriminator = 1)] - pub fn initialize_pool(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_initialize_pool(&mut ctx.accounts) + pub fn initialize_pool( + ctx: Ctx, + amount_a: u64, + amount_b: u64, + ) -> Result<(), ProgramError> { + instructions::handle_initialize_pool(&mut ctx.accounts, amount_a, amount_b, &ctx.bumps) } #[instruction(discriminator = 2)] diff --git a/finance/token-swap/quasar/src/liquidity.rs b/finance/token-swap/quasar/src/liquidity.rs new file mode 100644 index 00000000..4eb73dd4 --- /dev/null +++ b/finance/token-swap/quasar/src/liquidity.rs @@ -0,0 +1,140 @@ +//! The two things every liquidity deposit ends with, shared by +//! `initialize_pool` (the creator's first deposit) and `deposit_liquidity` +//! (every later one): moving both tokens into the reserves and minting LP +//! tokens signed by `pool_config`. Only the arithmetic that decides the +//! amounts differs between the two handlers, and the first deposit's +//! arithmetic lives here too so there is one copy of it. + +use { + crate::{error::AmmError, state::PoolConfig, MINIMUM_LIQUIDITY}, + quasar_lang::cpi::Seed, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Integer sqrt via Newton's method. Operates on `u128` so it can handle the +/// product `amount_a * amount_b` (each is a `u64`, product can fill the full +/// `u128`). Floors the result, which matches Uniswap V2's `Math.sqrt` and +/// keeps the first deposit's LP-mint rounding in the pool's favour. +pub fn integer_sqrt(n: u128) -> u128 { + if n < 2 { + return n; + } + let mut x = n; + let mut y = x.div_ceil(2); + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + +/// LP tokens minted to the creator for the deposit that opens a pool: +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// +/// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on +/// `deposit_liquidity` and `withdraw_liquidity` divide by +/// `lp_supply + MINIMUM_LIQUIDITY`, so the floor counts as supply that nobody +/// holds. Its share of the reserves stays in the pool, claimable by nobody. An +/// attacker who inflates the value behind each share by donating to the vaults +/// inflates the value behind those units too, and loses that part of the +/// donation. (Uniswap V2 instead mints the floor to the zero address; here, if +/// every LP token is burned, the floor's leftover reserves stay behind and the +/// next depositor mints against them through `deposit_liquidity`'s +/// proportional formula.) +/// +/// `u128` with checked arithmetic: `amount_a * amount_b` can fill the full +/// `u128`. +pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result { + let product = (amount_a as u128) + .checked_mul(amount_b as u128) + .ok_or(AmmError::MathOverflow)?; + let sqrt_product = u64::try_from(integer_sqrt(product)).map_err(|_| AmmError::MathOverflow)?; + if sqrt_product < MINIMUM_LIQUIDITY { + return Err(AmmError::DepositTooSmall.into()); + } + sqrt_product + .checked_sub(MINIMUM_LIQUIDITY) + .ok_or_else(|| AmmError::MathOverflow.into()) +} + +/// The accounts a deposit moves tokens between. `pool_config` owns both +/// reserves and is the LP mint's authority, so it signs the mint with its own +/// seeds `[config, mint_a, mint_b, bump]`; the bump comes from the handler's +/// `Bumps`, since `PoolConfig` does not store it. +/// +/// Generic over the LP mint's wrapper because `initialize_pool` creates it +/// (`Account`) and `deposit_liquidity` reads an existing one +/// (`InterfaceAccount`); the CPI only needs the account view. +pub struct LiquidityDepositAccounts<'a, LpMint: AsAccountView> { + pub token_program: &'a Program, + pub pool_config: &'a Account, + pub pool_config_bump: u8, + pub mint_a: &'a Account, + pub mint_b: &'a Account, + pub pool_a: &'a Account, + pub pool_b: &'a Account, + pub depositor: &'a Signer, + pub depositor_token_a: &'a Account, + pub depositor_token_b: &'a Account, + pub liquidity_provider_mint: &'a LpMint, + pub liquidity_provider_token: &'a Account, +} + +/// Moves `amount_a` and `amount_b` from the depositor's token accounts into +/// the reserves, then mints `lp_amount` LP tokens to the depositor. The +/// caller has already decided all three amounts. +/// +/// `transfer_checked` carries the mint and decimals through the CPI, so a +/// wrong-mint or wrong-decimals account fails at the token program instead of +/// moving the wrong quantity. +#[inline(always)] +pub fn deposit_and_mint_lp_tokens( + accounts: LiquidityDepositAccounts<'_, LpMint>, + amount_a: u64, + amount_b: u64, + lp_amount: u64, +) -> Result<(), ProgramError> { + accounts + .token_program + .transfer_checked( + accounts.depositor_token_a, + accounts.mint_a, + accounts.pool_a, + accounts.depositor, + amount_a, + accounts.mint_a.decimals(), + ) + .invoke()?; + accounts + .token_program + .transfer_checked( + accounts.depositor_token_b, + accounts.mint_b, + accounts.pool_b, + accounts.depositor, + amount_b, + accounts.mint_b.decimals(), + ) + .invoke()?; + + // Seed order matches PoolPda: [config, mint_a, mint_b, bump], read from + // the pool's own record rather than from the mint accounts handed in. + let pool_config = accounts.pool_config; + let bump = [accounts.pool_config_bump]; + let seeds: &[Seed] = &[ + Seed::from(pool_config.config().as_ref()), + Seed::from(pool_config.mint_a().as_ref()), + Seed::from(pool_config.mint_b().as_ref()), + Seed::from(&bump as &[u8]), + ]; + accounts + .token_program + .mint_to( + accounts.liquidity_provider_mint, + accounts.liquidity_provider_token, + pool_config, + lp_amount, + ) + .invoke_signed(seeds) +} diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index ed77c758..58608019 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -1,6 +1,8 @@ //! quasar-test integration tests for the constant-product AMM: config and -//! pool creation, deposits (including the ratio-clamp regression tests), -//! withdrawals, swaps, admin-fee claims, and every slippage guard rail. +//! pool creation (which takes the creator's first deposit), deposits +//! (including the ratio-clamp regression tests and the front-run that the +//! first deposit closes), withdrawals, swaps, admin-fee claims, and every +//! slippage guard rail. use { crate::{ @@ -48,11 +50,11 @@ const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); const PAYER: Pubkey = Pubkey::new_from_array([2; 32]); const MINT_A: Pubkey = Pubkey::new_from_array([3; 32]); const MINT_B: Pubkey = Pubkey::new_from_array([4; 32]); -// The pool-seeding depositor. -const SEEDER: Pubkey = Pubkey::new_from_array([7; 32]); -const SEEDER_TOKEN_A: Pubkey = Pubkey::new_from_array([8; 32]); -const SEEDER_TOKEN_B: Pubkey = Pubkey::new_from_array([9; 32]); -const SEEDER_LP: Pubkey = Pubkey::new_from_array([10; 32]); +// The pool's creator, whose first deposit opens it in `initialize_pool`. +const CREATOR: Pubkey = Pubkey::new_from_array([7; 32]); +const CREATOR_TOKEN_A: Pubkey = Pubkey::new_from_array([8; 32]); +const CREATOR_TOKEN_B: Pubkey = Pubkey::new_from_array([9; 32]); +const CREATOR_LP: Pubkey = Pubkey::new_from_array([10; 32]); // A second, independent depositor. const DEPOSITOR: Pubkey = Pubkey::new_from_array([11; 32]); const DEPOSITOR_TOKEN_A: Pubkey = Pubkey::new_from_array([12; 32]); @@ -101,22 +103,48 @@ fn initialize_config(test: &mut Test, fee: u16, admin_share_bps: u16) -> Outcome }) } -/// Creates config + two mints + pool. -fn setup_pool(test: &mut Test) -> PoolEnv { +/// Creates the config and the two mints: everything a pool needs except +/// itself, so a test can build an `initialize_pool` the pool refuses. +fn setup_config_and_mints(test: &mut Test) { initialize_config(test, POOL_FEE_BPS as u16, ADMIN_SHARE_BPS).succeeds(); // Pre-populate mint accounts (no onchain minting needed for tests). test.add(Mint::new(PAYER).at(MINT_A).decimals(6)); test.add(Mint::new(PAYER).at(MINT_B).decimals(6)); +} - // initialize_pool: the pool_config, LP-mint and reserve PDAs are all - // derived by the builder; the reserves are owned by pool_config. +/// `initialize_pool` with the creator's first deposit of `amount_a` / +/// `amount_b`, taken from `CREATOR_TOKEN_A` / `CREATOR_TOKEN_B`, which must +/// already hold them. The pool_config, LP-mint and reserve PDAs are all +/// derived by the builder; the reserves are owned by pool_config. +fn send_initialize_pool(test: &mut Test, amount_a: u64, amount_b: u64) -> Outcome { test.send(InitializePoolInstruction { mint_a: MINT_A, mint_b: MINT_B, + creator: CREATOR, + creator_token_a: CREATOR_TOKEN_A, + creator_token_b: CREATOR_TOKEN_B, + liquidity_provider_token: CREATOR_LP, payer: PAYER, + amount_a, + amount_b, }) - .succeeds(); +} + +/// Creates config + two mints + a pool opened with the creator's deposit of +/// exactly `amount_a` / `amount_b`. The creator's LP tokens are at +/// `CREATOR_LP`. +fn setup_pool(test: &mut Test, amount_a: u64, amount_b: u64) -> PoolEnv { + setup_config_and_mints(test); + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + amount_a, + amount_b, + ); + send_initialize_pool(test, amount_a, amount_b).succeeds(); let config = test.derive_pda(ConfigPda::seeds()); PoolEnv { @@ -125,6 +153,14 @@ fn setup_pool(test: &mut Test) -> PoolEnv { } } +/// Overwrites an SPL token account's balance in place (the `amount` is the +/// u64 at bytes 64..72), for states no instruction can produce. +fn set_token_balance(test: &mut Test, address: Pubkey, amount: u64) { + let mut account = test.account(address).expect("token account missing"); + account.data[64..72].copy_from_slice(&amount.to_le_bytes()); + test.set_account(account); +} + /// Fund a depositor wallet with token A/B accounts holding the given amounts. fn fund( test: &mut Test, @@ -174,31 +210,6 @@ fn deposit( }) } -/// Fund the seeding depositor and deposit `amount_a` / `amount_b`, with no LP -/// floor (pool-setup helper, not a slippage test). Returns the LP balance. -fn seed_pool(test: &mut Test, amount_a: u64, amount_b: u64) -> u64 { - fund( - test, - SEEDER, - SEEDER_TOKEN_A, - SEEDER_TOKEN_B, - amount_a, - amount_b, - ); - deposit( - test, - SEEDER, - SEEDER_TOKEN_A, - SEEDER_TOKEN_B, - SEEDER_LP, - amount_a, - amount_b, - 0, - ) - .succeeds(); - test.tokens(SEEDER_LP) -} - fn swap( test: &mut Test, trader: Pubkey, @@ -277,7 +288,7 @@ fn initialize_config_rejects_invalid_admin_share(test: &mut Test) { #[quasar_test] fn initialize_pool_creates_pool_config_and_lp_mint(test: &mut Test) { - let env = setup_pool(test); + let env = setup_pool(test, 4_000_000, 1_000_000); // The pool_config PDA must now exist and be owned by our program. let pc = test .account(env.pool_config) @@ -312,20 +323,86 @@ fn initialize_pool_creates_pool_config_and_lp_mint(test: &mut Test) { } } +/// The pool opens with the creator's deposit: both reserves hold it, the +/// creator's balances fell by it, the creator holds +/// `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and that is the whole LP +/// supply, so the floor is minted to nobody. +#[quasar_test] +fn initialize_pool_takes_first_deposit(test: &mut Test) { + setup_config_and_mints(test); + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + 5_000_000, + 2_000_000, + ); + let config = test.derive_pda(ConfigPda::seeds()); + let lp_mint = test.derive_pda(LiquidityMintPda::seeds(&config, &MINT_A, &MINT_B)); + + send_initialize_pool(test, 4_000_000, 1_000_000) + .succeeds() + .has_tokens(pool_a(test), 4_000_000) + .has_tokens(pool_b(test), 1_000_000) + .has_tokens(CREATOR_TOKEN_A, 5_000_000 - 4_000_000) + .has_tokens(CREATOR_TOKEN_B, 2_000_000 - 1_000_000) + // sqrt(4_000_000 * 1_000_000) = 2_000_000, minus the 100 floor. + .has_tokens(CREATOR_LP, 2_000_000 - crate::MINIMUM_LIQUIDITY) + .has_supply(lp_mint, 2_000_000 - crate::MINIMUM_LIQUIDITY); +} + +/// A pool cannot open empty on either side: the first deposit sets the +/// price, and a zero reserve has none. Nothing is created when the +/// instruction fails. +#[quasar_test] +fn initialize_pool_rejects_zero_amount_a(test: &mut Test) { + setup_config_and_mints(test); + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + 4_000_000, + 1_000_000, + ); + send_initialize_pool(test, 0, 1_000_000).fails_with(AmmError::EmptyInitialDeposit); + assert!( + test.account(pool_config_address(test)).is_none(), + "no pool must exist after the refused initialize_pool" + ); +} + /// Pools are keyed by an ordered pair: creating the (B, A) pool beside the /// (A, B) pool would split the pair's liquidity across two pools, so the /// reversed order is refused. A pool of a mint against itself never reaches /// the order check: the same address in the `mint_a` and `mint_b` slots is a /// duplicate account, which Quasar's account parsing refuses with -/// `AccountBorrowFailed` before any handler code runs. +/// `AccountBorrowFailed` before any handler code runs. The creator holds +/// enough of both tokens for the deposit, so the order check is what fails. #[quasar_test] fn initialize_pool_rejects_unordered_mints(test: &mut Test) { - setup_pool(test); + setup_pool(test, 4_000_000, 1_000_000); + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + 4_000_000, + 1_000_000, + ); + let reversed_lp = Pubkey::new_from_array([28; 32]); test.send(InitializePoolInstruction { mint_a: MINT_B, mint_b: MINT_A, + creator: CREATOR, + creator_token_a: CREATOR_TOKEN_B, + creator_token_b: CREATOR_TOKEN_A, + liquidity_provider_token: reversed_lp, payer: PAYER, + amount_a: 1_000_000, + amount_b: 4_000_000, }) .fails_with(AmmError::InvalidMintOrder); let config = test.derive_pda(ConfigPda::seeds()); @@ -338,34 +415,132 @@ fn initialize_pool_rejects_unordered_mints(test: &mut Test) { test.send(InitializePoolInstruction { mint_a: MINT_A, mint_b: MINT_A, + creator: CREATOR, + creator_token_a: CREATOR_TOKEN_A, + creator_token_b: CREATOR_TOKEN_A, + liquidity_provider_token: reversed_lp, payer: PAYER, + amount_a: 1_000_000, + amount_b: 1_000_000, }) .fails(ProgramError::Runtime("AccountBorrowFailed".into())); } +#[quasar_test] +fn initialize_pool_rejects_zero_amount_b(test: &mut Test) { + setup_config_and_mints(test); + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + 4_000_000, + 1_000_000, + ); + send_initialize_pool(test, 4_000_000, 0).fails_with(AmmError::EmptyInitialDeposit); + assert!( + test.account(pool_config_address(test)).is_none(), + "no pool must exist after the refused initialize_pool" + ); +} + // ─── deposit_liquidity ─────────────────────────────────────────────────────── +/// The front-run. Maria (the creator) opens an ACME/USDC pool with 400 ACME +/// and 900 USDC, a price of 2.25 USDC per ACME, in one `initialize_pool` +/// call; the pool is created, funded and priced before anyone else can +/// touch it. Mallory then calls `deposit_liquidity` at once with a hostile +/// ratio, 400 ACME and 100 USDC (0.25 USDC per ACME). Her deposit is clamped +/// to Maria's ratio: her 100 USDC binds, only 44.444444 of her 400 ACME is +/// taken, and the pool still prices at 2.25 afterwards. The last part of the +/// test checks the refusal in code: a `deposit_liquidity` against a pool whose +/// effective reserves are zero fails with `EmptyPoolReserve`, so no +/// instruction lets a pool with no reserves take a depositor's ratio as its +/// price. (`initialize_pool` is the only instruction that creates a pool, and +/// `initialize_pool_rejects_zero_amount_a` / `_b` show it refuses a zero +/// side.) #[quasar_test] -fn deposit_liquidity_initial(test: &mut Test) { - setup_pool(test); - - let amount_a = 1_000_000u64; - let amount_b = 4_000_000u64; - let lp_balance = seed_pool(test, amount_a, amount_b); - - // LP token account must exist with a non-zero balance, and the pool - // reserves must have received the tokens. - assert!(lp_balance > 0, "expected LP tokens, got 0"); - assert_eq!(test.tokens(pool_a(test)), amount_a); - assert_eq!(test.tokens(pool_b(test)), amount_b); +fn pool_creation_cannot_be_front_run(test: &mut Test) { + let one: u64 = 10u64.pow(6); // both mints have 6 decimals + setup_config_and_mints(test); + // ACME is `MINT_A`, USDC is `MINT_B`. + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + 400 * one, + 900 * one, + ); + send_initialize_pool(test, 400 * one, 900 * one) + .succeeds() + .has_tokens(pool_a(test), 400 * one) + .has_tokens(pool_b(test), 900 * one); + + // Mallory, with 400 ACME and 100 USDC, deposits all of both at once. + fund( + test, + DEPOSITOR, + DEPOSITOR_TOKEN_A, + DEPOSITOR_TOKEN_B, + 400 * one, + 100 * one, + ); + deposit( + test, + DEPOSITOR, + DEPOSITOR_TOKEN_A, + DEPOSITOR_TOKEN_B, + DEPOSITOR_LP, + 400 * one, + 100 * one, + 0, + ) + .succeeds(); + + // 400 ACME would need 900 USDC at Maria's price, more than Mallory + // offered, so her 100 USDC binds: 100 * 400 / 900 = 44.444444 ACME. + let acme_taken = 400 * one - test.tokens(DEPOSITOR_TOKEN_A); + let usdc_taken = 100 * one - test.tokens(DEPOSITOR_TOKEN_B); + assert_eq!(acme_taken, 44_444_444); + assert_eq!(usdc_taken, 100 * one); + let pool_a_amount = test.tokens(pool_a(test)); + let pool_b_amount = test.tokens(pool_b(test)); + assert_eq!(pool_a_amount, 444_444_444); + assert_eq!(pool_b_amount, 1_000 * one); + // The price is still 2.25 USDC per ACME, to the cent. + let price_in_cents = (pool_b_amount as u128) * 100 / (pool_a_amount as u128); + assert_eq!(price_in_cents, 225); + // Mallory is minted her share and nothing more: the LP supply is + // sqrt(400 * 900) = 600 (floor included), and 100 USDC is a ninth of + // the 900 in the pool, so min(44.444444 * 600 / 400, 100 * 600 / 900) + // = 66.666666 LP tokens. + assert_eq!(test.tokens(DEPOSITOR_LP), 66_666_666); + + // The pool can never be in this state through the program, so put it + // there by hand: zero the token-A reserve's balance and show the handler + // refuses to price a pool with an empty reserve. The balance check runs + // first, so give Mallory the USDC she offers. + set_token_balance(test, pool_a(test), 0); + set_token_balance(test, DEPOSITOR_TOKEN_B, 100 * one); + deposit( + test, + DEPOSITOR, + DEPOSITOR_TOKEN_A, + DEPOSITOR_TOKEN_B, + DEPOSITOR_LP, + 300 * one, + 100 * one, + 0, + ) + .fails_with(AmmError::EmptyPoolReserve); } #[quasar_test] fn deposit_liquidity_subsequent_proportional(test: &mut Test) { - setup_pool(test); - - // Initial deposit: 1:4 ratio. - let lp1_bal = seed_pool(test, 1_000_000, 4_000_000); + // The pool opens at a 1:4 ratio. + setup_pool(test, 1_000_000, 4_000_000); + let lp1_bal = test.tokens(CREATOR_LP); // Second depositor with the same 1:4 ratio gets proportional LP tokens. fund( @@ -389,7 +564,7 @@ fn deposit_liquidity_subsequent_proportional(test: &mut Test) { .succeeds(); let lp2_bal = test.tokens(DEPOSITOR_LP); - // Half the first deposit mints half of the first deposit's sqrt(a * b): + // Half the creator's deposit mints half of that deposit's sqrt(a * b): // the LP it received plus the unminted floor, since deposits divide by // the same `supply + MINIMUM_LIQUIDITY` that withdrawals do. assert_eq!(lp1_bal, 2_000_000 - crate::MINIMUM_LIQUIDITY); @@ -397,17 +572,17 @@ fn deposit_liquidity_subsequent_proportional(test: &mut Test) { } /// Once every LP token is burned the floor's share of the reserves is still -/// in the pool, so the next deposit takes the subsequent-deposit branch and -/// mints against the floor alone. Dividing by the bare supply of zero would -/// mint nothing and leave the pool unable to take deposits again. +/// in the pool, so the next deposit is clamped to the floor's ratio and mints +/// against the floor alone. Dividing by the bare supply of zero would mint +/// nothing and leave the pool unable to take deposits again. #[quasar_test] fn deposit_after_every_lp_token_is_burned(test: &mut Test) { - setup_pool(test); - let lp_balance = seed_pool(test, 4_000_000, 4_000_000); + setup_pool(test, 4_000_000, 4_000_000); + let lp_balance = test.tokens(CREATOR_LP); // 3_999_900 * 4_000_000 / 4_000_000 of each side leaves; the floor's 100 // of each side stays. - withdraw(test, SEEDER, SEEDER_LP, RECV_A, RECV_B, lp_balance, 0, 0) + withdraw(test, CREATOR, CREATOR_LP, RECV_A, RECV_B, lp_balance, 0, 0) .succeeds() .has_tokens(pool_a(test), crate::MINIMUM_LIQUIDITY) .has_tokens(pool_b(test), crate::MINIMUM_LIQUIDITY); @@ -437,7 +612,7 @@ fn deposit_after_every_lp_token_is_burned(test: &mut Test) { #[quasar_test] fn deposit_insufficient_funds_rejected(test: &mut Test) { - setup_pool(test); + setup_pool(test, 1_000_000, 1_000_000); // Fund with only 100 of each but request 1_000_000. fund( @@ -468,12 +643,11 @@ fn deposit_insufficient_funds_rejected(test: &mut Test) { /// balance check. The correct try-A-then-B clamp scales token B DOWN instead. #[quasar_test] fn deposit_clamps_down_never_up(test: &mut Test) { - setup_pool(test); - - // Seed at a 4:1 ratio so pool_a > pool_b. + // Open at a 4:1 ratio so pool_a > pool_b. let (pool_seed_a, pool_seed_b) = (4_000_000u64, 1_000_000u64); + setup_pool(test, pool_seed_a, pool_seed_b); // Later deposits divide by the LP supply plus the unminted floor. - let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; + let total_supply = test.tokens(CREATOR_LP) + crate::MINIMUM_LIQUIDITY; // Depositor offers 1_000_000 of each and holds exactly that much. The // old logic would try to pull 4_000_000 token A (scaling A UP); the @@ -516,12 +690,11 @@ fn deposit_clamps_down_never_up(test: &mut Test) { /// `amount_b` is used and `amount_a` is the side that covers the ratio. #[quasar_test] fn deposit_clamps_down_other_side(test: &mut Test) { - setup_pool(test); - - // Seed at a 1:4 ratio so pool_b > pool_a. + // Open at a 1:4 ratio so pool_b > pool_a. let (pool_seed_a, pool_seed_b) = (1_000_000u64, 4_000_000u64); + setup_pool(test, pool_seed_a, pool_seed_b); // Later deposits divide by the LP supply plus the unminted floor. - let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; + let total_supply = test.tokens(CREATOR_LP) + crate::MINIMUM_LIQUIDITY; let (stated_a, stated_b) = (1_000_000u64, 1_000_000u64); fund( @@ -559,11 +732,10 @@ fn deposit_clamps_down_other_side(test: &mut Test) { #[quasar_test] fn deposit_slippage_rejected(test: &mut Test) { - setup_pool(test); - let (pool_seed_a, pool_seed_b) = (1_000_000u64, 1_000_000u64); + setup_pool(test, pool_seed_a, pool_seed_b); // Later deposits divide by the LP supply plus the unminted floor. - let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; + let total_supply = test.tokens(CREATOR_LP) + crate::MINIMUM_LIQUIDITY; let (stated_a, stated_b) = (500_000u64, 500_000u64); fund( @@ -643,10 +815,10 @@ fn withdraw( #[quasar_test] fn withdraw_liquidity_pays_the_proportional_share(test: &mut Test) { - setup_pool(test); let amount_a = 2_000_000u64; let amount_b = 2_000_000u64; - let lp_balance = seed_pool(test, amount_a, amount_b); + setup_pool(test, amount_a, amount_b); + let lp_balance = test.tokens(CREATOR_LP); assert!(lp_balance > 0); // Withdraw half the LP tokens. @@ -654,7 +826,7 @@ fn withdraw_liquidity_pays_the_proportional_share(test: &mut Test) { // Expected proportional share, mirroring the program's formula: // amount_out = lp_amount * reserve / (lp_supply + MINIMUM_LIQUIDITY) - // The depositor holds the entire LP supply, so supply == lp_balance. + // The creator holds the entire LP supply, so supply == lp_balance. let divisor = lp_balance .checked_add(crate::MINIMUM_LIQUIDITY) .expect("divisor overflow"); @@ -666,8 +838,8 @@ fn withdraw_liquidity_pays_the_proportional_share(test: &mut Test) { // the quote, so the floors must be met. withdraw( test, - SEEDER, - SEEDER_LP, + CREATOR, + CREATOR_LP, RECV_A, RECV_B, withdraw_amount, @@ -679,13 +851,13 @@ fn withdraw_liquidity_pays_the_proportional_share(test: &mut Test) { .has_tokens(RECV_A, expected_a) .has_tokens(RECV_B, expected_b) // LP tokens were burned. - .has_tokens(SEEDER_LP, lp_balance - withdraw_amount); + .has_tokens(CREATOR_LP, lp_balance - withdraw_amount); } #[quasar_test] fn withdraw_slippage_rejected(test: &mut Test) { - setup_pool(test); - let lp_balance = seed_pool(test, 2_000_000, 2_000_000); + setup_pool(test, 2_000_000, 2_000_000); + let lp_balance = test.tokens(CREATOR_LP); let withdraw_amount = lp_balance / 2; let divisor = lp_balance @@ -696,8 +868,8 @@ fn withdraw_slippage_rejected(test: &mut Test) { // Floor on token A set just above what the pool will pay out. withdraw( test, - SEEDER, - SEEDER_LP, + CREATOR, + CREATOR_LP, RECV_A, RECV_B, withdraw_amount, @@ -718,7 +890,7 @@ fn withdraw_slippage_rejected(test: &mut Test) { "pool_b must be untouched after revert" ); assert_eq!( - test.tokens(SEEDER_LP), + test.tokens(CREATOR_LP), lp_balance, "LP balance must be untouched after revert" ); @@ -728,11 +900,8 @@ fn withdraw_slippage_rejected(test: &mut Test) { #[quasar_test] fn swap_a_to_b_conserves_balances(test: &mut Test) { - setup_pool(test); - - // Seed the pool with liquidity first. let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); - seed_pool(test, pool_seed_a, pool_seed_b); + setup_pool(test, pool_seed_a, pool_seed_b); // Trader swaps 100_000 token A for token B (the output account is created // by init(idempotent)). @@ -767,9 +936,8 @@ fn swap_a_to_b_conserves_balances(test: &mut Test) { #[quasar_test] fn swap_b_to_a_conserves_balances(test: &mut Test) { - setup_pool(test); let (pool_seed_a, pool_seed_b) = (10_000_000u64, 10_000_000u64); - seed_pool(test, pool_seed_a, pool_seed_b); + setup_pool(test, pool_seed_a, pool_seed_b); let trader_funding = 1_000_000u64; test.add(Wallet::new().at(TRADER)); @@ -800,8 +968,7 @@ fn swap_b_to_a_conserves_balances(test: &mut Test) { #[quasar_test] fn swap_slippage_rejected(test: &mut Test) { - setup_pool(test); - seed_pool(test, 10_000_000, 10_000_000); + setup_pool(test, 10_000_000, 10_000_000); test.add(Wallet::new().at(TRADER)); test.add( @@ -848,8 +1015,7 @@ fn swap_slippage_rejected(test: &mut Test) { /// the trader), and pay out nearly all of `pool_b` from the real reserve. #[quasar_test] fn swap_rejects_substituted_pool_vault(test: &mut Test) { - setup_pool(test); - seed_pool(test, 10_000_000, 10_000_000); + setup_pool(test, 10_000_000, 10_000_000); test.add(Wallet::new().at(BAD_ACTOR)); test.add( @@ -888,20 +1054,17 @@ fn swap_rejects_substituted_pool_vault(test: &mut Test) { ); } -/// Tokens sent straight to `pool_b` before anyone deposits leave a pool with -/// one funded reserve and one empty one. Pricing a swap from that would pay -/// the trader the whole of `pool_b` for any input (`input * b / (0 + input) -/// = b`), and the invariant check would pass because the pre-trade product is -/// zero, so the swap is refused. +/// A pool with one funded reserve and one empty one cannot be reached through +/// the program (every pool opens with its creator's deposit of both tokens, +/// and no swap or withdrawal empties a side), so the test empties `pool_a` by +/// hand. Pricing a swap from that would pay the trader the whole of `pool_b` +/// for any input (`input * b / (0 + input) = b`), and the invariant check +/// would pass because the pre-trade product is zero, so the swap is refused. #[quasar_test] fn swap_rejects_empty_reserve(test: &mut Test) { - let env = setup_pool(test); + setup_pool(test, 5_000_000, 5_000_000); let pool_b = pool_b(test); - test.add( - TokenAccount::new(MINT_B, env.pool_config) - .at(pool_b) - .amount(5_000_000), - ); + set_token_balance(test, pool_a(test), 0); fund(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, 1_000_000, 0); swap( @@ -928,8 +1091,7 @@ fn swap_rejects_empty_reserve(test: &mut Test) { /// priced from those accounts' balances, and the deposit would land in them. #[quasar_test] fn deposit_rejects_substituted_pool_vaults(test: &mut Test) { - setup_pool(test); - seed_pool(test, 10_000_000, 10_000_000); + setup_pool(test, 10_000_000, 10_000_000); fund( test, @@ -973,10 +1135,8 @@ fn deposit_rejects_substituted_pool_vaults(test: &mut Test) { #[quasar_test] fn claim_admin_fees_pays_the_admin(test: &mut Test) { - setup_pool(test); - - // Seed pool and do a swap so fees accumulate. - seed_pool(test, 10_000_000, 10_000_000); + // Open the pool and do a swap so fees accumulate. + setup_pool(test, 10_000_000, 10_000_000); test.add(Wallet::new().at(TRADER)); test.add( TokenAccount::new(MINT_A, TRADER) @@ -1011,8 +1171,7 @@ fn claim_admin_fees_pays_the_admin(test: &mut Test) { #[quasar_test] fn claim_admin_fees_rejects_non_admin(test: &mut Test) { - setup_pool(test); - seed_pool(test, 10_000_000, 10_000_000); + setup_pool(test, 10_000_000, 10_000_000); // Swap to accumulate some fees. test.add(Wallet::new().at(TRADER));