diff --git a/.gitignore b/.gitignore index 357e1aad..94ff54c0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,4 @@ node_modules/ /target deploy .claude/* -# Exception: skills are shared team resources -!.claude/skills/ -!.claude/skills/** +!.claude/skills/ \ No newline at end of file diff --git a/tokens/token-swap/README.md b/tokens/token-swap/README.md index 0f753ccf..926230fb 100644 --- a/tokens/token-swap/README.md +++ b/tokens/token-swap/README.md @@ -1,8 +1,22 @@ # Token Swap (AMM) -A Constant Product Automated Market Maker (AMM) in [Anchor](https://solana.com/docs/terminology#anchor) — the model popularized by Uniswap V2. +A Constant Product [Automated Market Maker (AMM)](https://www.investopedia.com/terms/a/automated-market-maker-amm.asp) in [Anchor](https://solana.com/docs/terminology#anchor) — the model popularized by Uniswap V2. -The pool keeps `x * y = K` invariant: if `x` is the reserve of token A and `y` is the reserve of token B, then `x * y` stays constant for a given liquidity quantity. +The pool keeps `x * y = K` invariant: if `x` is the reserve of token A and `y` is the reserve of token B, then `x * y` stays constant for a given [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) quantity. + +## What this example includes + +- 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. +- 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`. +- Admin protocol fees accrued onchain as virtual claims on the pool reserves, swept on demand by `claim_admin_fees`. +- Withdrawals proportional to LP-token share of the **effective reserves** (raw reserve minus admin's owed slice), so the admin's accrued fees don't dilute exiting LPs. +- **Caller-supplied [slippage](https://www.investopedia.com/terms/s/slippage.asp) floors on every state-changing [instruction](https://solana.com/docs/terminology#instruction):** swaps revert with `SlippageExceeded` if the output falls below `min_output_amount`, deposits revert with `DepositBelowMinimum` if the LP mint amount falls below `minimum_lp_tokens_out`, withdrawals revert with `WithdrawalBelowMinimum` if either side falls below its floor. +- **Defence-in-depth invariant check:** every swap re-verifies `effective_pool_a * effective_pool_b` doesn't decrease after the transfers, so a bug in the curve math fails the transaction instead of silently giving the trader too much. +- All financial math in `u128` with checked arithmetic, matching how production Solana AMMs (Orca, Raydium, Meteora, Saber) do it. +- Anchor 1.0 Rust [program](https://solana.com/docs/terminology#program) with LiteSVM integration tests. ## Why a CPAMM @@ -19,21 +33,21 @@ A CPAMM is the simplest and the cheapest to keep in [account](https://solana.com Requirements: -- **Fee distribution.** Every pool charges a trading fee, paid in the traded token, that rewards LPs. To stay consistent across pools, the fee is shared. -- **Single pool per asset pair.** Avoids liquidity fragmentation. -- **LP accounting.** The [program](https://solana.com/docs/terminology#program) tracks each LP's deposits. +- **Fee distribution.** Every pool charges a trading fee, paid in the traded token, that rewards [liquidity providers (LPs)](https://www.investopedia.com/terms/l/liquidity-provider.asp). To stay consistent across pools, the fee is shared. +- **Single pool per asset pair.** Avoids liquidity fragmentation. Without a single canonical pool per pair, a [decentralised exchange (DEX)](https://www.investopedia.com/terms/d/decentralized-exchange-dex.asp) would fragment volume across multiple pools, widening spreads — the same problem that motivated the shift away from [order books](https://www.investopedia.com/terms/o/order-book.asp) on-chain. +- **LP accounting.** The program tracks each LP's deposits. Implementation choices: -- **Shared parameters.** A single AMM account stores the shared trading-fee config and admin. Each pool then has its own account. -- **Unique pools.** Each pool is a [PDA](https://solana.com/docs/terminology#program-derived-address-pda) seeded from the AMM, `mint_a`, and `mint_b` (in that order, with `mint_a < mint_b`). -- **LP accounting via tokens.** LP positions are tracked as tokens (the `mint_liquidity` mint), so they're composable with any wallet or downstream protocol. +- **Singleton config.** A single `Config` account stores the shared trading-fee config and admin. It is a global singleton: one per deployed program, derived at the fixed seed `[b"config"]`. This matches how real DEXs are deployed in practice (Phoenix, Raydium, etc. ship one program per market/AMM), and keeps the example simpler than parameterising the config by an id. +- **Unique pools.** Each pool is a PDA seeded from the `Config`, `mint_a`, and `mint_b` (in that order, with `mint_a < mint_b`). +- **LP accounting via tokens.** LP positions are tracked as tokens (the `liquidity_provider_mint`), so they're composable with any wallet or downstream program. ## Onchain-design principles applied here - **Store keys in the account.** Even for PDAs, storing the parent keys in the account state makes lookups easier (you can rebuild the PDA without consulting external data) and works well with Anchor's `has_one` constraint. -- **Keep seeds simple.** Start with the parent's seeds, then the current object's identifiers in alphabetical order. For the pool, that means `[amm, mint_a, mint_b]`. -- **Keep [instruction](https://solana.com/docs/terminology#instruction) scope small.** Smaller instructions touch fewer accounts, leaving room in the transaction and improving composability and security. +- **Keep seeds simple.** Start with the parent's seeds, then the current object's identifiers in alphabetical order. For the pool, that means `[config, mint_a, mint_b]`. +- **Keep instruction scope small.** Smaller instructions touch fewer accounts, leaving room in the transaction and improving composability and security. ## File structure @@ -42,61 +56,327 @@ programs/token-swap/src/ ├── constants.rs ├── errors.rs ├── instructions -│ ├── create_amm.rs +│ ├── claim_admin_fees.rs +│ ├── create_config.rs │ ├── create_pool.rs │ ├── deposit_liquidity.rs │ ├── mod.rs -│ ├── swap_exact_tokens_for_tokens.rs +│ ├── swap_tokens.rs │ └── withdraw_liquidity.rs ├── lib.rs -└── state.rs +└── state/ + ├── config.rs + ├── mod.rs + └── pool_config.rs ``` ## State -### `Amm` +### `Config` + +Shared configuration for the AMM. **Singleton** — one per deployed program, at PDA seeds `[b"config"]`. -- `id: Pubkey` — the primary key of the AMM (used as a seed). -- `admin: Pubkey` — the admin authority. -- `fee: u16` — LP fee in basis points (must be < 10000). +- `admin: Pubkey` — the admin authority. Only this address can call `claim_admin_fees`. +- `fee: u16` — total trading fee in [basis points (bps)](https://www.investopedia.com/terms/b/basispoint.asp) (must be < 10000). Split between LPs and the admin according to `admin_share_bps`. +- `admin_share_bps: u16` — fraction of the trading fee that goes to the admin, in basis points (must be < 10000). The remainder goes to LPs. Modelled on Uniswap V2 / Raydium: the AMM operator takes a slice of every fee, LPs keep the rest. -### `Pool` +### `PoolConfig` -- `amm: Pubkey` — the parent AMM. +Per-pool configuration / identity record. Identifies a single pool by which `Config` it belongs to and which two mints it trades, and tracks the admin's accumulated trading-fee claim for each side. The actual pool reserves live in separate token accounts (`pool_a`, `pool_b`) owned by `pool_authority` — they are *not* stored here. + +- `config: Pubkey` — the parent `Config` account. - `mint_a: Pubkey` — mint of token A. - `mint_b: Pubkey` — mint of token B. +- `admin_fees_owed_a: u64` — admin's accumulated fee claim on token A, in base units. Sits physically in `pool_a` but is excluded from the LP curve and from LP-withdrawable amounts. Swept by `claim_admin_fees`. +- `admin_fees_owed_b: u64` — same for token B. + +The admin's fees are tracked as *virtual* claims on the existing `pool_a` / `pool_b` reserves rather than as separate vaults. LP-facing math uses **effective reserves** = `pool_X.amount - admin_fees_owed_X` so the admin's owed slice doesn't grow LP [yield](https://www.investopedia.com/terms/y/yield.asp). -`Pool` PDA seeds: `[amm, mint_a, mint_b]` with `mint_a < mint_b`. +`PoolConfig` PDA seeds: `[config, mint_a, mint_b]` with `mint_a < mint_b`. ## Instruction handlers -### `create_amm` +### `create_config` -Initializes an `Amm` account with the supplied `id`, `admin`, and `fee`. Enforces `fee < 10000`. +Initializes the singleton `Config` account with the supplied `admin`, `fee`, and `admin_share_bps`. The `Config` PDA is derived from the fixed seed `[b"config"]`, so this can only succeed once per deployed program. Enforces `fee < 10000` and `admin_share_bps < 10000`. ### `create_pool` -Initializes a `Pool` account, an LP mint (`mint_liquidity`), and the two pool [ATAs](https://solana.com/docs/terminology#associated-token-account-ata) (`pool_account_a`, `pool_account_b`). Enforces `mint_a < mint_b` for canonical pool addressing. +Initializes a `PoolConfig` account, an LP mint (`liquidity_provider_mint`), and the two pool reserve token accounts (`pool_a`, `pool_b`) owned by `pool_authority`. Enforces `mint_a < mint_b` for canonical pool addressing. ### `deposit_liquidity` -Transfers `amount_a` and `amount_b` from the depositor to the pool, then mints LP tokens to the depositor. +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, the LP amount is `sqrt(amount_a * amount_b)`, with `MINIMUM_LIQUIDITY` locked away forever (to prevent the empty-pool edge case). -- For later deposits, the amounts are scaled to match the current pool ratio. +- 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): + 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. +- 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 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 off-chain quote time and tx landing. -### `swap_exact_tokens_for_tokens` +### `swap_tokens` -Swaps a fixed `input_amount` of one token for as much of the other as possible (subject to `min_output_amount`). +Swaps a fixed `input_amount` of one token for as much of the other as possible (subject to `min_output_amount`). The `input_is_token_a` flag selects the input side (`true` = trader sends token A and receives token B; `false` = the reverse). -- The trading fee is taken off the input first (`taxed_input = input * (10_000 - fee) / 10_000`). -- The output is computed against the current `pool_a` and `pool_b` balances. -- After the swap, the invariant `pool_a * pool_b` is checked to ensure it has not decreased. +- The total trading fee is taken off the input first: `fee_amount = input * fee / 10_000`. +- The fee is split between LPs and the admin: + - `admin_portion = fee_amount * admin_share_bps / 10_000` — accumulates as a virtual claim on the input-side reserve (`admin_fees_owed_a` or `admin_fees_owed_b`). Not transferred immediately, swept later by `claim_admin_fees`. Saves a CPI per swap. + - `lp_portion = fee_amount - admin_portion` — stays physically in the reserves and boosts LP yield ("less output for the same input"). +- `taxed_input = input - fee_amount` is what enters the curve. +- The output is computed against the **effective reserves** (`pool_X.amount - admin_fees_owed_X`), so the admin's outstanding fees do not contribute to the price. The curve math runs in `u128` with checked arithmetic, multiplying before dividing to keep precision; floor rounding favours the pool (Uniswap V2 convention). +- The [price impact](https://www.investopedia.com/terms/p/price-impact.asp) of a swap — the difference between the quoted mid-price and the effective execution price — is determined by the size of the trade relative to the pool's effective reserves. Larger trades move the curve further, resulting in higher price impact. +- If `output < min_output_amount`, the handler reverts with `SlippageExceeded`. This is the trader's slippage guard for cases where the pool shifted between quote time and tx landing. +- After the transfers, the handler reloads the pool accounts and re-verifies that `effective_pool_a * effective_pool_b` is at least as high as before the trade. This is defence in depth: if the curve math were ever wrong in a way that gave the trader too much, the invariant check would fail and revert the trade. Reverts with `InvariantViolated`. ### `withdraw_liquidity` -Burns LP tokens and returns the proportional share of `pool_a` and `pool_b` to the LP. The proportion is `amount / (mint_liquidity.supply + MINIMUM_LIQUIDITY)`. +Burns LP tokens and returns a proportional share of the **effective reserves** (`pool_X.amount - admin_fees_owed_X`) to the LP. The proportion is `amount / (liquidity_provider_mint.supply + MINIMUM_LIQUIDITY)`. The admin's owed slice physically remains in the vaults but is not distributed to exiting LPs — it's claimed separately via `claim_admin_fees`. All math is `u128` with checked arithmetic, multiplying before dividing; floor rounding leaves sub-base-unit dust with the pool (grows LP value for everyone still in). + +- `minimum_token_a_out` and `minimum_token_b_out` are the LP's per-side slippage floors. If either computed amount falls below its floor, the handler reverts with `WithdrawalBelowMinimum` *before* any tokens move. Pass `0` on either side to opt out. This protects LPs from withdrawing during a pool imbalance (e.g. a large swap landed just before this tx and skewed the mix). + +### `claim_admin_fees` + +Lets the address stored in `Config.admin` sweep their accumulated trading-fee claim out of a pool. Transfers `admin_fees_owed_a` from `pool_a` to the admin's token-A account and `admin_fees_owed_b` from `pool_b` to the admin's token-B account, signed by `pool_authority`. Then resets both accumulators to zero. + +- Authorisation: enforced by Anchor's `has_one = admin` constraint on `config` plus the `Signer` constraint on `admin`. Calls from any other signer are rejected. +- The admin's token accounts (`admin_token_a`, `admin_token_b`) must already exist — this handler doesn't auto-create them (keeps the example small). +- Idempotent: calling again with the accumulators at zero is a successful no-op (transfers are skipped when owed = 0). + +## Program flow: Alice, Bob, Carol, and Dave + +A worked example, end to end, using this program. The example uses three tokens: + +- **NVDAx** — a [tokenised](https://www.investopedia.com/terms/t/tokenization.asp) NVIDIA share, priced at ~5 USDC offchain. +- **TSLAx** — a tokenised Tesla share, priced at ~180 USDC offchain. +- **USDC** — a USD-pegged [stablecoin](https://www.investopedia.com/terms/s/stablecoin.asp) used as the quote currency in both pools. + +**Cast:** + +- **Alice** — AMM operator. Deploys and runs the exchange. Earns a slice of every trading fee via the admin protocol-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 `create_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. +- **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 (a tokenised NVIDIA share) 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. + +### Step 1 — Alice creates the `Config` + +The singleton `Config` account is set once per deployed program. Every pool inherits its `fee` and `admin_share_bps`. + +- **Handler:** `create_config` +- **Accounts (`CreateConfigAccounts`):** + - `config` (PDA, created) — seeds `[b"config"]`; stores `admin`, `fee`, `admin_share_bps`, `bump` + - `admin` = Alice + - `payer` = Alice + - `system_program` +- **Args:** `fee = 30` (0.3%), `admin_share_bps = 1667` (Uniswap V2's classic 1/6 default — Alice keeps 1/6 of the trading fee; LPs keep 5/6) + +`Config` exists. No pools yet, no liquidity yet. + +### Step 2 — Alice creates the NVDAx/USDC pool + +- **Handler:** `create_pool` +- **Accounts (`CreatePoolAccounts`):** + - `config` — Alice's `Config` + - `pool_config` (PDA, created) — seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a`, `mint_b`, `bump` + - `pool_authority` (PDA) — signs for the pool reserves + - `liquidity_provider_mint` (created) — the LP-token mint, authority = `pool_authority` + - `mint_a` = NVDAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) + - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) — the NVDAx and USDC reserves + - `payer` = Alice + - token, ATA, system programs +- **Args:** none + +NVDAx/USDC pool exists; reserves are empty. No one can swap yet. + +### Step 2b — Alice creates the TSLAx/USDC pool + +Alice immediately creates a second pool for TSLAx (tokenised Tesla shares, ~180 USDC each). The handler and account shape are identical to Step 2; only the mints differ. + +- **Handler:** `create_pool` +- **Accounts (`CreatePoolAccounts`):** + - `config` — Alice's `Config` (same singleton) + - `pool_config` (PDA, created) — seeds `[config, mint_a, mint_b]`; stores `config`, `mint_a` = TSLAx mint, `mint_b` = USDC mint, `bump` + - `pool_authority` (PDA) — signs for this pool's reserves + - `liquidity_provider_mint` (created) — a separate LP-token mint for this pool + - `mint_a` = TSLAx mint, `mint_b` = USDC mint (with `mint_a < mint_b`) + - `pool_a`, `pool_b` (created, ATAs owned by `pool_authority`) — the TSLAx and USDC reserves + - `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. + +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. + +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 + +Alice picks a 1:5 ratio so the NVDAx/USDC pool launches at ~5 USDC per NVDAx. She deposits **20 NVDAx and 100 USDC**. + +- **Handler:** `deposit_liquidity` +- **Accounts (`DepositLiquidityAccounts`):** + - `pool_config`, `pool_authority`, `liquidity_provider_mint` + - `depositor` = Alice (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 + - 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 off-chain 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 = min(100/20, 500/100) × 44.72 ≈ 223.6` LP tokens. + +NVDAx/USDC pool state: **120 NVDAx, 600 USDC**. LP supply ~268.32. Bob owns ~83%, Alice ~17%. + +### Step 5 — Carol buys NVDAx with USDC + +- **Handler:** `swap_tokens` +- **Accounts (`SwapTokensAccounts`):** + - `config` — for the fee + - `pool_config`, `pool_authority` + - `trader` = Carol (signer) + - `mint_a`, `mint_b` + - `pool_a`, `pool_b` (the pool's reserves) + - `token_a` — Carol's NVDAx ATA (created if missing), `token_b` — Carol's USDC ATA + - `payer` = Carol + - token, ATA, system programs +- **Args:** `input_is_token_a = false` (input is token B = USDC), `input_amount = 11`, `min_output_amount = 1.9` + +Math (constant product, 0.3% fee from `Config.fee`, fee split per `Config.admin_share_bps`): + +- Total fee on the input: `11 × 0.003 = 0.033 USDC`. +- Fee split: + - Admin slice (`admin_share_bps = 1667`): `0.033 × 0.1667 ≈ 0.0055 USDC` — added to `admin_fees_owed_b`. + - LP slice: `0.033 − 0.0055 ≈ 0.0275 USDC` — stays in the reserves, boosts LP yield. +- Input into the curve: `11 − 0.033 = 10.967 USDC`. +- Effective reserves before the trade: `effective_pool_a = 120`, `effective_pool_b = 600` (admin owes nothing yet). +- New effective B: `600 + 10.967 = 610.967` (raw `pool_b.amount` is `611`, minus the new admin slice `0.0055`). +- New effective A: `(120 × 600) / 610.967 ≈ 117.844`. +- NVDAx out: `120 − 117.844 ≈ 2.156`. + +Carol gets ~2.156 NVDAx. Effective price ~5.10 USDC/NVDAx — worse than mid-price because of the fee plus her own price impact. + +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 + +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 +- **Args:** `input_is_token_a = true` (input is token A = NVDAx), `input_amount = 2.15`, `min_output_amount = 10.5` + +Math: + +- Total fee on the input: `2.15 × 0.003 ≈ 0.00645 NVDAx`. +- Fee split: + - Admin slice: `0.00645 × 0.1667 ≈ 0.001075 NVDAx` — added to `admin_fees_owed_a`. + - LP slice: `≈ 0.005375 NVDAx` — stays in the reserves. +- Input into the curve: `2.15 − 0.00645 ≈ 2.14355 NVDAx`. +- Effective reserves before the trade: `effective_pool_a = 117.844` (no A-side admin claim yet), `effective_pool_b ≈ 611 − 0.0055 ≈ 610.9945`. +- New effective A: `117.844 + 2.14355 ≈ 119.9876`. +- New effective B: `(117.844 × 610.9945) / 119.9876 ≈ 600.07`. +- USDC out: `610.9945 − 600.07 ≈ 10.92`. + +Dave paid ~10.75 USDC offchain for 2.15 NVDAx, sold into the pool for ~10.92 USDC. Profit ~0.17 USDC, minus gas. + +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 + +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. + +- **Handler:** `swap_tokens` +- **Accounts (`SwapTokensAccounts`):** + - `config` — the same singleton `Config` (fee and admin_share_bps apply to all pools) + - `pool_config` — the TSLAx/USDC `PoolConfig` PDA + - `pool_authority` — the TSLAx/USDC pool authority 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) + - `token_a` — Carol's TSLAx ATA (created if missing), `token_b` — Carol's USDC ATA + - `payer` = Carol + - token, ATA, system programs +- **Args:** `input_is_token_a = false` (input is token B = USDC), `input_amount = 180`, `min_output_amount = 0.9` + +Math (constant product, same 0.3% fee and 1667 bps admin share): + +- Total fee on the input: `180 × 0.003 = 0.54 USDC`. +- Fee split: + - Admin slice: `0.54 × 0.1667 ≈ 0.09 USDC` — added to `admin_fees_owed_b` on this pool. + - LP slice: `0.54 − 0.09 ≈ 0.45 USDC` — stays in the TSLAx/USDC reserves. +- Input into the curve: `180 − 0.54 = 179.46 USDC`. +- Effective reserves before the trade: `effective_pool_a = 1 TSLAx`, `effective_pool_b = 180 USDC`. +- New effective B: `180 + 179.46 = 359.46` (raw `pool_b.amount` ≈ `360`, minus the new admin slice `≈ 0.09`). +- New effective A: `(1 × 180) / 359.46 ≈ 0.5008`. +- TSLAx out: `1 − 0.5008 ≈ 0.4992`. + +Carol gets ~0.4992 TSLAx. The large price impact (~50% of the pool's TSLAx reserve) reflects the shallow pool depth at this early stage — in a real deployment, Alice would seed the pool with more liquidity to reduce price impact for traders of this size. + +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 + +After trading activity on both pools, Alice sweeps her accumulated slice from the NVDAx/USDC pool. + +- **Handler:** `claim_admin_fees` +- **Accounts (`ClaimAdminFeesAccounts`):** + - `config` — Alice's `Config` (the `has_one = admin` constraint enforces that only she can call this) + - `pool_config`, `pool_authority` + - `mint_a`, `mint_b` + - `pool_a`, `pool_b` (the pool's reserves — the source of the transfers) + - `admin` = Alice (signer) + - `admin_token_a` — Alice's NVDAx ATA (must already exist) + - `admin_token_b` — Alice's USDC ATA (must already exist) + - `token_program` +- **Args:** none + +She receives her accumulated `admin_fees_owed_a` of NVDAx and `admin_fees_owed_b` of USDC from the NVDAx/USDC pool. Both accumulators reset to zero on the same instruction. From this example's two swaps that's only `~0.001075 NVDAx` and `~0.0055 USDC` — small, because the fee is small and only two trades have happened, but real volume would compound it. She can call `claim_admin_fees` again against the TSLAx/USDC pool (same handler, different `pool_config`) to sweep her ~0.09 USDC slice from Carol's TSLAx trade. + +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 + +Later on, Bob exits. + +- **Handler:** `withdraw_liquidity` +- **Accounts (`WithdrawLiquidityAccounts`):** same shape as deposit, `depositor` = Bob +- **Args:** `amount ≈ 223.6` (burn all his LP tokens); `minimum_token_a_out` and `minimum_token_b_out` set to his quoted-out amounts minus a small tolerance, so a sandwich swap before his tx lands can't drain one side below his floor without reverting his exit + +He receives his proportional share of the **effective reserves** (`pool_X.amount - admin_fees_owed_X`), so Alice's accumulated slice doesn't dilute his withdrawal. Because the effective reserves grew faster than LP supply (LP supply only changes on deposits and withdrawals; the LP slice of every fee accrues into the effective reserves), he gets back slightly more NVDAx and slightly more USDC than he put in. The difference is his fee income. + +### Recap + +- **Alice** calls `create_config` → `create_pool` (NVDAx/USDC) → `create_pool` (TSLAx/USDC) → `deposit_liquidity` on NVDAx/USDC → `deposit_liquidity` on TSLAx/USDC (admin, pool creator, initial 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 `create_pool` handler and the same `swap_tokens` handler work identically for both the NVDAx/USDC and TSLAx/USDC pools — only the mint accounts differ. ## Tests -Run `pnpm test` from the example directory. +Run `cargo test` from the `anchor/` directory (or `anchor test`, which `Anchor.toml` wires to the same command). Tests are Rust + LiteSVM, in `programs/token-swap/tests/`. diff --git a/tokens/token-swap/anchor/programs/token-swap/Cargo.toml b/tokens/token-swap/anchor/programs/token-swap/Cargo.toml index caa9e13f..864990b9 100644 --- a/tokens/token-swap/anchor/programs/token-swap/Cargo.toml +++ b/tokens/token-swap/anchor/programs/token-swap/Cargo.toml @@ -21,8 +21,10 @@ custom-panic = [] [dependencies] anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } -anchor-spl = { version = "1.0.0", features = ["metadata"] } -fixed = "1.27.0" +anchor-spl = { version = "1.0.0", features = ["metadata", "spl-token-interface"] } +# `fixed` removed: all financial math is now u128 + checked_*, matching how +# production Solana AMMs (Orca, Raydium, Meteora, Saber) do it. Floats / +# fixed-point types are not used for money in this program. [dev-dependencies] litesvm = "0.11.0" diff --git a/tokens/token-swap/anchor/programs/token-swap/src/constants.rs b/tokens/token-swap/anchor/programs/token-swap/src/constants.rs index 5193c4c8..09e43a1b 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/constants.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/constants.rs @@ -3,6 +3,16 @@ use anchor_lang::prelude::*; #[constant] pub const MINIMUM_LIQUIDITY: u64 = 100; +/// Basis-points denominator. Fees and the admin's fee share are stored in +/// basis points (1 bp = 1/10_000), so dividing by this converts a bp value to +/// a fraction. Using the named constant keeps the 10_000 out of the math as a +/// bare literal. +#[constant] +pub const BASIS_POINTS_DIVISOR: u64 = 10_000; + +#[constant] +pub const CONFIG_SEED: &[u8] = b"config"; + #[constant] pub const AUTHORITY_SEED: &[u8] = b"authority"; diff --git a/tokens/token-swap/anchor/programs/token-swap/src/errors.rs b/tokens/token-swap/anchor/programs/token-swap/src/errors.rs index c821b019..69d67a63 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/errors.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/errors.rs @@ -1,15 +1,51 @@ use anchor_lang::prelude::*; #[error_code] -pub enum TutorialError { +pub enum AmmError { #[msg("Invalid fee value")] InvalidFee, + // Returned when `create_config` is called with `admin_share_bps >= 10_000`. + // The admin share is a basis-points fraction of the trading fee, so values + // at or above 10_000 are nonsensical (the admin can't take more than the + // whole fee). + #[msg("Admin share must be less than 10000 basis points")] + AdminShareTooHigh, + #[msg("Depositing too little liquidity")] DepositTooSmall, - #[msg("Output is below the minimum expected")] - OutputTooSmall, + // Returned by `deposit_liquidity` when clamping the caller's amounts to the + // current pool ratio rounds one side down to zero. That happens when the + // deposit is so small (or so lopsided) that the pool can't issue meaningful + // LP shares without rounding one of the contributions away. We fail rather + // than mint zero-priced LP tokens. + #[msg("Deposit amount too small for current pool ratio")] + DepositAmountTooSmall, + + // Returned by `swap_tokens` when the computed `output_amount` is strictly + // below the trader's `min_output_amount`. This is the trader's slippage + // guard: between quoting and landing, the pool can shift (other traders, + // sandwich attempts), so the trader passes the lowest output they're + // willing to accept and the program reverts if reality is worse. + #[msg("Swap output below minimum (slippage exceeded)")] + SlippageExceeded, + + // Returned by `withdraw_liquidity` when either side of the proportional + // withdrawal falls below the LP's specified minimum. This is the LP's + // slippage guard: if a big swap drains one side of the pool between the + // LP quoting their exit and the tx landing, the LP gets a different + // mix than expected and can bail. + #[msg("Withdrawal amount below minimum (slippage exceeded)")] + WithdrawalBelowMinimum, + + // Returned by `deposit_liquidity` when the computed LP-token amount + // falls below the depositor's specified minimum. This is the + // *lower-bound* slippage guard on what the depositor receives. The + // ratio clamp is the *upper-bound* guard (don't over-spend either + // token); both are needed for full deposit slippage protection. + #[msg("LP tokens minted below minimum (slippage exceeded)")] + DepositBelowMinimum, #[msg("Invariant does not hold")] InvariantViolated, @@ -20,4 +56,26 @@ pub enum TutorialError { // amount used). We now fail fast so callers can react. #[msg("Requested amount exceeds available balance")] InsufficientBalance, + + // Returned by `claim_admin_fees` when both accumulators are zero. Reverting + // (rather than silently no-op'ing) gives the admin a clear signal that the + // call was wasted, and avoids the litesvm gotcha where two byte-identical + // claim txs share a signature and the runtime rejects the second as + // `AlreadyProcessed`. Callers should check the accumulators offchain + // before submitting a claim. + #[msg("No admin fees to claim")] + NothingToClaim, + + // Returned by arithmetic helpers when a checked_* operation overflows or + // underflows. We treat these as hard failures rather than masking them + // with `.unwrap()` so the onchain logs name the failure mode. + #[msg("Math overflow")] + MathOverflow, + + // Returned by `create_pool` when `mint_a >= mint_b`. Requiring a strict + // ascending order ensures each (mint_a, mint_b) pair has exactly one + // canonical pool PDA — without it, a (X, Y) pool and a (Y, X) pool would + // both be valid, fragmenting liquidity. + #[msg("mint_a must be less than mint_b for canonical pool ordering")] + InvalidMintOrder, } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/claim_admin_fees.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/claim_admin_fees.rs new file mode 100644 index 00000000..78ddb179 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/claim_admin_fees.rs @@ -0,0 +1,184 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::{ + constants::{AUTHORITY_SEED, CONFIG_SEED}, + errors::AmmError, + state::{Config, PoolConfig}, +}; + +/// Sweep the admin's accumulated trading-fee claims for both sides of a pool +/// into the admin's token accounts. +/// +/// During each swap, the admin's slice of the fee accumulates as a virtual +/// claim on the input-side reserve (`pool_config.admin_fees_owed_a` / +/// `admin_fees_owed_b`). This handler transfers those amounts out of the +/// pool reserves into the admin's ATAs and resets the accumulators to zero. +/// +/// Authorisation: the `has_one = admin` constraint on `config` plus the +/// `Signer` constraint on `admin` together mean only the address stored in +/// `Config.admin` can call this. Any other signer will be rejected by +/// Anchor's built-in `has_one` check. +pub fn handle_claim_admin_fees(context: Context) -> Result<()> { + let owed_a = context.accounts.pool_config.admin_fees_owed_a; + let owed_b = context.accounts.pool_config.admin_fees_owed_b; + + // Revert if there's nothing to claim. Two reasons: + // 1. It tells the admin offchain that the call did nothing - silent + // no-ops mask wasted txs. + // 2. Under litesvm, two byte-identical claim txs (same payer, same + // accounts, same recent_blockhash) produce the same signature and + // the runtime rejects the second as `AlreadyProcessed`. Failing + // explicitly here gives callers a real error to handle. + if owed_a == 0 && owed_b == 0 { + return err!(AmmError::NothingToClaim); + } + + // Pre-copy seed bytes before the mutable borrow of pool_config. + let authority_bump = context.bumps.pool_authority; + 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(); + + // Effects: zero the accumulators before the CPIs (Checks-Effects-Interactions). + // If a CPI fails the whole transaction reverts, so the state reset is safe. + { + let pool_config = &mut context.accounts.pool_config; + pool_config.admin_fees_owed_a = 0; + pool_config.admin_fees_owed_b = 0; + } + + // Interactions: transfer the owed fees out of the pool reserves. + let authority_seeds = &[ + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), + AUTHORITY_SEED, + &[authority_bump], + ]; + let signer_seeds = &[&authority_seeds[..]]; + + if owed_a > 0 { + token_interface::transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.pool_a.to_account_info(), + mint: context.accounts.mint_a.to_account_info(), + to: context.accounts.admin_token_a.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + owed_a, + context.accounts.mint_a.decimals, + )?; + } + + if owed_b > 0 { + token_interface::transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.pool_b.to_account_info(), + mint: context.accounts.mint_b.to_account_info(), + to: context.accounts.admin_token_b.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + owed_b, + context.accounts.mint_b.decimals, + )?; + } + + msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b); + + Ok(()) +} + +#[derive(Accounts)] +pub struct ClaimAdminFeesAccountConstraints<'info> { + #[account( + seeds = [CONFIG_SEED], + bump, + has_one = admin, + )] + pub config: Account<'info, Config>, + + #[account( + mut, + seeds = [ + pool_config.config.as_ref(), + pool_config.mint_a.key().as_ref(), + pool_config.mint_b.key().as_ref(), + ], + bump, + has_one = config, + has_one = mint_a, + has_one = mint_b, + )] + pub pool_config: Account<'info, PoolConfig>, + + /// CHECK: PDA that owns the pool reserves; signs the outbound transfers. + #[account( + seeds = [ + pool_config.config.as_ref(), + mint_a.key().as_ref(), + mint_b.key().as_ref(), + AUTHORITY_SEED, + ], + bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + pub mint_a: Box>, + + pub mint_b: Box>, + + /// The pool's token-A reserve. The admin's owed token-A fees are paid out + /// of this account. + #[account( + mut, + associated_token::mint = mint_a, + associated_token::authority = pool_authority, + associated_token::token_program = token_program, + )] + pub pool_a: Box>, + + /// The pool's token-B reserve. The admin's owed token-B fees are paid out + /// of this account. + #[account( + mut, + associated_token::mint = mint_b, + associated_token::authority = pool_authority, + associated_token::token_program = token_program, + )] + pub pool_b: Box>, + + /// Must match the address stored in `Config.admin` (enforced by + /// `has_one = admin` above). + pub admin: Signer<'info>, + + /// Admin's token-A receiving account. Must already exist; the admin is + /// expected to create it themselves before calling. Keeps this handler + /// small (no `init_if_needed`). + #[account( + mut, + token::mint = mint_a, + token::authority = admin, + token::token_program = token_program, + )] + pub admin_token_a: Box>, + + /// Admin's token-B receiving account. Same constraints as `admin_token_a`. + #[account( + mut, + token::mint = mint_b, + token::authority = admin, + token::token_program = token_program, + )] + pub admin_token_b: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/mod.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/mod.rs new file mode 100644 index 00000000..0c1d5c22 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/mod.rs @@ -0,0 +1,3 @@ +mod claim_admin_fees; + +pub use claim_admin_fees::*; diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_amm.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_amm.rs deleted file mode 100644 index 2dd4d31e..00000000 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_amm.rs +++ /dev/null @@ -1,41 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{errors::*, state::Amm}; - -pub fn handle_create_amm(mut context: Context, id: Pubkey, fee: u16) -> Result<()> { - let bump = context.bumps.amm; - let amm = &mut context.accounts.amm; - amm.id = id; - amm.admin = context.accounts.admin.key(); - amm.fee = fee; - amm.bump = bump; - - Ok(()) -} - -#[derive(Accounts)] -#[instruction(id: Pubkey, fee: u16)] -pub struct CreateAmm<'info> { - #[account( - init, - payer = payer, - space = Amm::DISCRIMINATOR.len() + Amm::INIT_SPACE, - seeds = [ - id.as_ref() - ], - bump, - constraint = fee < 10000 @ TutorialError::InvalidFee, - )] - pub amm: Account<'info, Amm>, - - /// The admin of the AMM - /// CHECK: Read only, delegatable creation - pub admin: AccountInfo<'info>, - - /// The account paying for all rents - #[account(mut)] - pub payer: Signer<'info>, - - /// Solana ecosystem accounts - pub system_program: Program<'info, System>, -} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs new file mode 100644 index 00000000..8673b2a9 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs @@ -0,0 +1,48 @@ +use anchor_lang::prelude::*; + +use crate::{ + constants::{BASIS_POINTS_DIVISOR, CONFIG_SEED}, + errors::*, + state::Config, +}; + +pub fn handle_create_config( + context: Context, + fee: u16, + admin_share_bps: u16, +) -> Result<()> { + let bump = context.bumps.config; + let config = &mut context.accounts.config; + config.admin = context.accounts.admin.key(); + config.fee = fee; + config.admin_share_bps = admin_share_bps; + config.bump = bump; + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(fee: u16, admin_share_bps: u16)] +pub struct CreateConfigAccountConstraints<'info> { + #[account( + init, + payer = payer, + space = Config::DISCRIMINATOR.len() + Config::INIT_SPACE, + seeds = [CONFIG_SEED], + bump, + constraint = (fee as u64) < BASIS_POINTS_DIVISOR @ AmmError::InvalidFee, + constraint = (admin_share_bps as u64) < BASIS_POINTS_DIVISOR @ AmmError::AdminShareTooHigh, + )] + pub config: Account<'info, Config>, + + /// The admin of the AMM + /// CHECK: Read only, delegatable creation + pub admin: UncheckedAccount<'info>, + + /// The account paying for all rents + #[account(mut)] + pub payer: Signer<'info>, + + /// Solana ecosystem accounts + pub system_program: Program<'info, System>, +} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs index 3d423a04..a35db330 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs @@ -1,65 +1,65 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token::{Mint, Token, TokenAccount}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ - constants::{AUTHORITY_SEED, LIQUIDITY_SEED}, - state::{Amm, Pool}, + constants::{AUTHORITY_SEED, CONFIG_SEED, LIQUIDITY_SEED}, + errors::AmmError, + state::{Config, PoolConfig}, }; -pub fn handle_create_pool(mut context: Context) -> Result<()> { - let bump = context.bumps.pool; - let pool = &mut context.accounts.pool; - pool.amm = context.accounts.amm.key(); - pool.mint_a = context.accounts.mint_a.key(); - pool.mint_b = context.accounts.mint_b.key(); - pool.bump = bump; +pub fn handle_create_pool(context: Context) -> Result<()> { + let bump = context.bumps.pool_config; + let pool_config = &mut context.accounts.pool_config; + pool_config.config = context.accounts.config.key(); + pool_config.mint_a = context.accounts.mint_a.key(); + pool_config.mint_b = context.accounts.mint_b.key(); + pool_config.bump = bump; Ok(()) } #[derive(Accounts)] -pub struct CreatePool<'info> { +pub struct CreatePoolAccountConstraints<'info> { #[account( - seeds = [ - amm.id.as_ref() - ], + seeds = [CONFIG_SEED], bump, )] - pub amm: Box>, + pub config: Box>, #[account( init, payer = payer, - space = Pool::DISCRIMINATOR.len() + Pool::INIT_SPACE, + space = PoolConfig::DISCRIMINATOR.len() + PoolConfig::INIT_SPACE, seeds = [ - amm.key().as_ref(), + config.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), ], bump, + constraint = mint_a.key() < mint_b.key() @ AmmError::InvalidMintOrder, )] - pub pool: Box>, + pub pool_config: Box>, /// CHECK: Read only authority #[account( seeds = [ - amm.key().as_ref(), + config.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), AUTHORITY_SEED, ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, #[account( init, payer = payer, seeds = [ - amm.key().as_ref(), + config.key().as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), LIQUIDITY_SEED, @@ -68,34 +68,36 @@ pub struct CreatePool<'info> { mint::decimals = 6, mint::authority = pool_authority, )] - pub mint_liquidity: Box>, + pub liquidity_provider_mint: Box>, - pub mint_a: Box>, + pub mint_a: Box>, - pub mint_b: Box>, + pub mint_b: Box>, #[account( init, payer = payer, associated_token::mint = mint_a, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( init, payer = payer, associated_token::mint = mint_b, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_b: Box>, + pub pool_b: Box>, /// The account paying for all rents #[account(mut)] pub payer: Signer<'info>, /// Solana ecosystem accounts - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs index d0d48c33..d894414c 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -1,103 +1,215 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token::{self, Mint, MintTo, Token, TokenAccount, TransferChecked}, + token_interface::{self, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked}, }; -use fixed::types::I64F64; use crate::{ constants::{AUTHORITY_SEED, LIQUIDITY_SEED, MINIMUM_LIQUIDITY}, - errors::TutorialError, - state::Pool, + 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 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 + 1) / 2; + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} + pub fn handle_deposit_liquidity( - context: Context, + context: Context, amount_a: u64, amount_b: u64, + minimum_lp_tokens_out: u64, ) -> Result<()> { // Fail fast if the depositor lacks the requested balance. Previously this // silently clamped to the available balance, which broke slippage protection // for callers building on top - they expected their input amount to be the // amount actually deposited. - if amount_a > context.accounts.depositor_account_a.amount - || amount_b > context.accounts.depositor_account_b.amount + if amount_a > context.accounts.token_a.amount + || amount_b > context.accounts.token_b.amount { - return err!(TutorialError::InsufficientBalance); + return err!(AmmError::InsufficientBalance); } let mut amount_a = amount_a; let mut amount_b = amount_b; - // Making sure they are provided in the same proportion as existing liquidity - let pool_a = &context.accounts.pool_account_a; - let pool_b = &context.accounts.pool_account_b; + // Clamp the caller's (amount_a, amount_b) to the current pool ratio. + // + // Callers pass `amount_a` / `amount_b` as *upper bounds* (their available + // balance, or the most they want to commit). The pool is at a fixed + // ratio, so at most one of the two amounts can be used in full; the other + // is scaled down to match the current price. This mirrors Uniswap V2's + // `mint()` pattern (UniswapV2Router._addLiquidity): try the first side at + // its requested amount, compute what the other side needs at the current + // ratio, and if it fits we're done — otherwise swap roles and try the + // other side. + // + // We use the *effective* (LP-claimable) reserves, not the raw vault + // balances, so the admin's accumulated fees don't drag the deposit ratio + // off the LP-relevant price. + // + // All ratio math is in u128 with checked arithmetic — no floats for + // money. The intermediate `amount_a * pool_b` can overflow u64 (both + // factors are u64), but u128 absorbs that with room to spare. + let pool_a = &context.accounts.pool_a; + let pool_b = &context.accounts.pool_b; + let pool_config = &context.accounts.pool_config; + // checked_sub: admin_fees_owed is an invariant subset of the vault balance; + // a raw `-` would wrap silently on a BPF release build if that ever broke. + let effective_pool_a = pool_a + .amount + .checked_sub(pool_config.admin_fees_owed_a) + .ok_or(AmmError::MathOverflow)?; + let effective_pool_b = pool_b + .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 = pool_a.amount == 0 && pool_b.amount == 0; + 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 + // 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) } else { - let ratio = I64F64::from_num(pool_a.amount) - .checked_mul(I64F64::from_num(pool_b.amount)) - .unwrap(); - if pool_a.amount > pool_b.amount { - ( - I64F64::from_num(amount_b) - .checked_mul(ratio) - .unwrap() - .to_num::(), - amount_b, - ) + // 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)?; + 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_a, - I64F64::from_num(amount_a) - .checked_div(ratio) - .unwrap() - .to_num::(), - ) + // `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) } }; - // Computing the amount of liquidity about to be deposited - let mut liquidity = I64F64::from_num(amount_a) - .checked_mul(I64F64::from_num(amount_b)) - .unwrap() - .sqrt() - .to_num::(); - - // Lock some minimum liquidity on the first deposit - if pool_creation { - if liquidity < MINIMUM_LIQUIDITY { - return err!(TutorialError::DepositTooSmall); + // After clamping, both sides must contribute something. If either side + // rounds to zero the deposit is too small to register at the current + // 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) { + return err!(AmmError::DepositAmountTooSmall); + } + + // LP-mint math. Two branches: + // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. + // One-time bootstrap; the `MINIMUM_LIQUIDITY` floor is locked + // forever and prevents the first depositor from later draining the + // pool to a sub-base-unit ratio. + // - Subsequent deposit: `liquidity = min(a * supply / pool_a, b * supply / pool_b)`. + // 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. + // + // All math is in `u128` with checked arithmetic. `amount * supply` can + // overflow `u64` (both are `u64`), but `u128` absorbs it: max product + // is `(2^64 - 1)^2 < 2^128`. We multiply before dividing to keep + // precision, then round down (floor) so the pool keeps any sub-unit + // rounding dust - protocol-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 { + let total_supply = context.accounts.liquidity_provider_mint.supply as u128; + 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)? + }; - liquidity -= MINIMUM_LIQUIDITY; + if liquidity == 0 { + // Subsequent deposit too small relative to existing LP supply. + // (Initial deposits hit the `MINIMUM_LIQUIDITY` check above.) + return err!(AmmError::DepositTooSmall); } + // Depositor's slippage protection: the caller passes the lowest LP + // amount they're willing to receive (computed offchain at quote time). + // If the pool ratio shifted between quoting and landing, the clamp will + // have used a smaller pair of amounts and the LP-mint amount drops. + // Revert rather than mint fewer LP tokens than the caller expects. + // + // This is the *lower-bound* slippage guard. The ratio clamp above is + // the *upper-bound* guard (caps how much of each token can be spent). + require!( + liquidity >= minimum_lp_tokens_out, + 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::transfer_checked( + token_interface::transfer_checked( CpiContext::new( context.accounts.token_program.key(), TransferChecked { - from: context.accounts.depositor_account_a.to_account_info(), + from: context.accounts.token_a.to_account_info(), mint: context.accounts.mint_a.to_account_info(), - to: context.accounts.pool_account_a.to_account_info(), + to: context.accounts.pool_a.to_account_info(), authority: context.accounts.depositor.to_account_info(), }, ), amount_a, context.accounts.mint_a.decimals, )?; - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new( context.accounts.token_program.key(), TransferChecked { - from: context.accounts.depositor_account_b.to_account_info(), + from: context.accounts.token_b.to_account_info(), mint: context.accounts.mint_b.to_account_info(), - to: context.accounts.pool_account_b.to_account_info(), + to: context.accounts.pool_b.to_account_info(), authority: context.accounts.depositor.to_account_info(), }, ), @@ -108,19 +220,19 @@ pub fn handle_deposit_liquidity( // Mint the liquidity to user let authority_bump = context.bumps.pool_authority; let authority_seeds = &[ - &context.accounts.pool.amm.to_bytes(), + &context.accounts.pool_config.config.to_bytes(), &context.accounts.mint_a.key().to_bytes(), &context.accounts.mint_b.key().to_bytes(), AUTHORITY_SEED, &[authority_bump], ]; let signer_seeds = &[&authority_seeds[..]]; - token::mint_to( + token_interface::mint_to( CpiContext::new_with_signer( context.accounts.token_program.key(), MintTo { - mint: context.accounts.mint_liquidity.to_account_info(), - to: context.accounts.depositor_account_liquidity.to_account_info(), + mint: context.accounts.liquidity_provider_mint.to_account_info(), + to: context.accounts.liquidity_provider_token.to_account_info(), authority: context.accounts.pool_authority.to_account_info(), }, signer_seeds, @@ -132,30 +244,30 @@ pub fn handle_deposit_liquidity( } #[derive(Accounts)] -pub struct DepositLiquidity<'info> { +pub struct DepositLiquidityAccountConstraints<'info> { #[account( seeds = [ - pool.amm.as_ref(), - pool.mint_a.key().as_ref(), - pool.mint_b.key().as_ref(), + pool_config.config.as_ref(), + pool_config.mint_a.key().as_ref(), + pool_config.mint_b.key().as_ref(), ], bump, has_one = mint_a, has_one = mint_b, )] - pub pool: Box>, + pub pool_config: Box>, /// CHECK: Read only authority #[account( seeds = [ - pool.amm.as_ref(), + pool_config.config.as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), AUTHORITY_SEED, ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, /// The account paying for all rents pub depositor: Signer<'info>, @@ -163,61 +275,66 @@ pub struct DepositLiquidity<'info> { #[account( mut, seeds = [ - pool.amm.as_ref(), + pool_config.config.as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), LIQUIDITY_SEED, ], bump, )] - pub mint_liquidity: Box>, + pub liquidity_provider_mint: Box>, - pub mint_a: Box>, + pub mint_a: Box>, - pub mint_b: Box>, + pub mint_b: Box>, #[account( mut, associated_token::mint = mint_a, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_b: Box>, + pub pool_b: Box>, #[account( init_if_needed, payer = payer, - associated_token::mint = mint_liquidity, + associated_token::mint = liquidity_provider_mint, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub depositor_account_liquidity: Box>, + pub liquidity_provider_token: Box>, #[account( mut, associated_token::mint = mint_a, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub depositor_account_a: Box>, + pub token_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub depositor_account_b: Box>, + pub token_b: Box>, /// The account paying for all rents #[account(mut)] pub payer: Signer<'info>, /// Solana ecosystem accounts - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/mod.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/mod.rs index 3c822791..74505d5e 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/mod.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/mod.rs @@ -1,11 +1,13 @@ -mod create_amm; +mod admin; +mod create_config; mod create_pool; mod deposit_liquidity; -mod swap_exact_tokens_for_tokens; +mod swap_tokens; mod withdraw_liquidity; -pub use create_amm::*; +pub use admin::*; +pub use create_config::*; pub use create_pool::*; pub use deposit_liquidity::*; -pub use swap_exact_tokens_for_tokens::*; +pub use swap_tokens::*; pub use withdraw_liquidity::*; diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs deleted file mode 100644 index c210bc46..00000000 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs +++ /dev/null @@ -1,239 +0,0 @@ -use anchor_lang::prelude::*; -use anchor_spl::{ - associated_token::AssociatedToken, - token::{self, Mint, Token, TokenAccount, TransferChecked}, -}; -use fixed::types::I64F64; - -use crate::{ - constants::AUTHORITY_SEED, - errors::*, - state::{Amm, Pool}, -}; - -pub fn handle_swap_exact_tokens_for_tokens( - context: Context, - swap_a: bool, - input_amount: u64, - min_output_amount: u64, -) -> Result<()> { - // Fail fast if the trader lacks the requested input balance. Previously this - // silently clamped to the available balance, which broke slippage protection - // for callers - their min_output_amount is computed against the requested - // input, not the clamped one, so the trade could succeed with worse terms - // than expected. - if swap_a && input_amount > context.accounts.trader_account_a.amount { - return err!(TutorialError::InsufficientBalance); - } - if !swap_a && input_amount > context.accounts.trader_account_b.amount { - return err!(TutorialError::InsufficientBalance); - } - let input = input_amount; - - // Apply trading fee, used to compute the output - let amm = &context.accounts.amm; - let taxed_input = input - input * amm.fee as u64 / 10000; - - let pool_a = &context.accounts.pool_account_a; - let pool_b = &context.accounts.pool_account_b; - let output = if swap_a { - I64F64::from_num(taxed_input) - .checked_mul(I64F64::from_num(pool_b.amount)) - .unwrap() - .checked_div( - I64F64::from_num(pool_a.amount) - .checked_add(I64F64::from_num(taxed_input)) - .unwrap(), - ) - .unwrap() - } else { - I64F64::from_num(taxed_input) - .checked_mul(I64F64::from_num(pool_a.amount)) - .unwrap() - .checked_div( - I64F64::from_num(pool_b.amount) - .checked_add(I64F64::from_num(taxed_input)) - .unwrap(), - ) - .unwrap() - } - .to_num::(); - - if output < min_output_amount { - return err!(TutorialError::OutputTooSmall); - } - - // Compute the invariant before the trade - let invariant = pool_a.amount * pool_b.amount; - - // Transfer tokens to the pool - let authority_bump = context.bumps.pool_authority; - let authority_seeds = &[ - &context.accounts.pool.amm.to_bytes(), - &context.accounts.mint_a.key().to_bytes(), - &context.accounts.mint_b.key().to_bytes(), - AUTHORITY_SEED, - &[authority_bump], - ]; - let signer_seeds = &[&authority_seeds[..]]; - // Use transfer_checked so the mint + decimals are verified at the token - // program. This protects callers from decimal-mismatch bugs and is the - // modern recommended path. - if swap_a { - token::transfer_checked( - CpiContext::new( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.trader_account_a.to_account_info(), - mint: context.accounts.mint_a.to_account_info(), - to: context.accounts.pool_account_a.to_account_info(), - authority: context.accounts.trader.to_account_info(), - }, - ), - input, - context.accounts.mint_a.decimals, - )?; - token::transfer_checked( - CpiContext::new_with_signer( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.pool_account_b.to_account_info(), - mint: context.accounts.mint_b.to_account_info(), - to: context.accounts.trader_account_b.to_account_info(), - authority: context.accounts.pool_authority.to_account_info(), - }, - signer_seeds, - ), - output, - context.accounts.mint_b.decimals, - )?; - } else { - token::transfer_checked( - CpiContext::new_with_signer( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.pool_account_a.to_account_info(), - mint: context.accounts.mint_a.to_account_info(), - to: context.accounts.trader_account_a.to_account_info(), - authority: context.accounts.pool_authority.to_account_info(), - }, - signer_seeds, - ), - input, - context.accounts.mint_a.decimals, - )?; - token::transfer_checked( - CpiContext::new( - context.accounts.token_program.key(), - TransferChecked { - from: context.accounts.trader_account_b.to_account_info(), - mint: context.accounts.mint_b.to_account_info(), - to: context.accounts.pool_account_b.to_account_info(), - authority: context.accounts.trader.to_account_info(), - }, - ), - output, - context.accounts.mint_b.decimals, - )?; - } - - msg!( - "Traded {} tokens ({} after fees) for {}", - input, - taxed_input, - output - ); - - // Verify the invariant still holds - // Reload accounts because of the CPIs - // We tolerate if the new invariant is higher because it means a rounding error for LPs - context.accounts.pool_account_a.reload()?; - context.accounts.pool_account_b.reload()?; - if invariant > context.accounts.pool_account_a.amount * context.accounts.pool_account_b.amount { - return err!(TutorialError::InvariantViolated); - } - - Ok(()) -} - -#[derive(Accounts)] -pub struct SwapExactTokensForTokens<'info> { - #[account( - seeds = [ - amm.id.as_ref() - ], - bump, - )] - pub amm: Account<'info, Amm>, - - #[account( - seeds = [ - pool.amm.as_ref(), - pool.mint_a.key().as_ref(), - pool.mint_b.key().as_ref(), - ], - bump, - has_one = amm, - has_one = mint_a, - has_one = mint_b, - )] - pub pool: Account<'info, Pool>, - - /// CHECK: Read only authority - #[account( - seeds = [ - pool.amm.as_ref(), - mint_a.key().as_ref(), - mint_b.key().as_ref(), - AUTHORITY_SEED, - ], - bump, - )] - pub pool_authority: AccountInfo<'info>, - - /// The account doing the swap - pub trader: Signer<'info>, - - pub mint_a: Box>, - - pub mint_b: Box>, - - #[account( - mut, - associated_token::mint = mint_a, - associated_token::authority = pool_authority, - )] - pub pool_account_a: Box>, - - #[account( - mut, - associated_token::mint = mint_b, - associated_token::authority = pool_authority, - )] - pub pool_account_b: Box>, - - #[account( - init_if_needed, - payer = payer, - associated_token::mint = mint_a, - associated_token::authority = trader, - )] - pub trader_account_a: Box>, - - #[account( - init_if_needed, - payer = payer, - associated_token::mint = mint_b, - associated_token::authority = trader, - )] - pub trader_account_b: Box>, - - /// The account paying for all rents - #[account(mut)] - pub payer: Signer<'info>, - - /// Solana ecosystem accounts - pub token_program: Program<'info, Token>, - pub associated_token_program: Program<'info, AssociatedToken>, - pub system_program: Program<'info, System>, -} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs new file mode 100644 index 00000000..11c35961 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs @@ -0,0 +1,346 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::{ + constants::{AUTHORITY_SEED, BASIS_POINTS_DIVISOR, CONFIG_SEED}, + errors::*, + state::{Config, PoolConfig}, +}; + +pub fn handle_swap_tokens( + context: Context, + input_is_token_a: bool, + input_amount: u64, + min_output_amount: u64, +) -> Result<()> { + // Fail fast if the trader lacks the requested input balance. Previously this + // silently clamped to the available balance, which broke slippage protection + // for callers - their min_output_amount is computed against the requested + // input, not the clamped one, so the trade could succeed with worse terms + // than expected. + if input_is_token_a && input_amount > context.accounts.token_a.amount { + return err!(AmmError::InsufficientBalance); + } + if !input_is_token_a && input_amount > context.accounts.token_b.amount { + return err!(AmmError::InsufficientBalance); + } + // Split the trading fee between LPs and the admin. The full fee is taken + // off the input first (this is the standard Uniswap V2 mechanic). The + // admin's slice is *not* transferred immediately - it accumulates as a + // virtual claim on the input-side reserve, swept later by + // `claim_admin_fees`. This saves a CPI per swap. + // + // u128 + checked arithmetic: `input * fee` can overflow u64 (both are + // u64-sized in practice; fee is u16 but the multiplication grows fast). + // Multiply before divide to preserve precision; floor on the divide is + // protocol-favouring (the trader pays slightly more fee on rounding, + // not less). + let config = &context.accounts.config; + let fee_amount = (input_amount as u128) + .checked_mul(config.fee as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(BASIS_POINTS_DIVISOR as u128) + .ok_or(AmmError::MathOverflow)?; + let admin_portion = fee_amount + .checked_mul(config.admin_share_bps as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(BASIS_POINTS_DIVISOR as u128) + .ok_or(AmmError::MathOverflow)?; + // Narrow back to u64 for storage / transfer. The fee can never exceed + // `input` (`fee_amount <= input * 9999 / 10_000 < input`, and `input` + // is u64), so the cast is safe — but use try_into anyway to make the + // invariant explicit in the type system. + let fee_amount: u64 = u64::try_from(fee_amount).map_err(|_| AmmError::MathOverflow)?; + let admin_portion: u64 = + u64::try_from(admin_portion).map_err(|_| AmmError::MathOverflow)?; + // The LP portion stays in the pool reserves (as today - it's "less output + // for the same input"), boosting the LP curve. The admin portion is + // accounted for separately so it does *not* grow LP yield. + let taxed_input = input_amount.checked_sub(fee_amount).ok_or(AmmError::MathOverflow)?; + + // Effective reserves = raw vault balance - admin's accumulated claim. + // The constant-product curve runs on the LP-claimable portion only, so + // the admin's outstanding fees do not contribute to LP yield and do not + // distort the price. + let pool_a = &context.accounts.pool_a; + let pool_b = &context.accounts.pool_b; + let pool_config = &context.accounts.pool_config; + // checked_sub: admin_fees_owed is an invariant subset of the vault balance, + // but a raw `-` would wrap silently on a BPF release build if that invariant + // were ever violated, handing the curve a giant effective reserve. + let effective_pool_a = pool_a + .amount + .checked_sub(pool_config.admin_fees_owed_a) + .ok_or(AmmError::MathOverflow)?; + let effective_pool_b = pool_b + .amount + .checked_sub(pool_config.admin_fees_owed_b) + .ok_or(AmmError::MathOverflow)?; + + // Constant-product output formula: + // output = taxed_input * other_reserve / (this_reserve + taxed_input) + // (where `this_reserve` is the input side, `other_reserve` the output + // side, both using *effective* reserves). + // + // u128 + checked: the numerator `taxed_input * reserve` can fill the + // full u128 (both factors are u64). Multiply before divide to keep + // precision. Floor on the divide is protocol-favouring (the pool keeps + // sub-base-unit rounding, the trader gets slightly less output) — same + // direction as Uniswap V2. + let (this_reserve, other_reserve) = if input_is_token_a { + (effective_pool_a, effective_pool_b) + } else { + (effective_pool_b, effective_pool_a) + }; + let numerator = (taxed_input as u128) + .checked_mul(other_reserve as u128) + .ok_or(AmmError::MathOverflow)?; + let denominator = (this_reserve as u128) + .checked_add(taxed_input as u128) + .ok_or(AmmError::MathOverflow)?; + let output_u128 = numerator + .checked_div(denominator) + .ok_or(AmmError::MathOverflow)?; + let output: u64 = u64::try_from(output_u128).map_err(|_| AmmError::MathOverflow)?; + + // Trader's slippage protection: the caller passes the lowest output + // they're willing to accept (computed offchain at quote time). If the + // pool shifted between quoting and landing, we revert rather than fill + // at the worse rate. + require!( + output >= min_output_amount, + AmmError::SlippageExceeded + ); + + // Compute the invariant on the *effective* reserves before the trade. + // Using raw balances here would let the admin's accumulated fees count + // toward LP yield (wrong) and would cause the invariant check to pass + // trivially even when the LP-claimable reserves shrunk. + // + // u128 + checked: each side is u64, so the product can fill the full + // u128. Raw `*` on u64 would overflow at ~1.8e19 base units. + let invariant = (effective_pool_a as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)?; + + // Pre-copy seed bytes before the mutable borrow of pool_config below. + // to_bytes() returns an owned [u8; 32] copy so there are no borrow conflicts. + let authority_bump = context.bumps.pool_authority; + 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(); + + // Effects: update admin_fees before CPIs (Checks-Effects-Interactions). + // The fee always comes off the input side, so the admin's claim accumulates + // in the same token. + { + let pool_config = &mut context.accounts.pool_config; + if input_is_token_a { + pool_config.admin_fees_owed_a = pool_config + .admin_fees_owed_a + .checked_add(admin_portion) + .ok_or(AmmError::MathOverflow)?; + } else { + pool_config.admin_fees_owed_b = pool_config + .admin_fees_owed_b + .checked_add(admin_portion) + .ok_or(AmmError::MathOverflow)?; + } + } + + // Interactions: CPIs after state has been updated. + let authority_seeds = &[ + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), + AUTHORITY_SEED, + &[authority_bump], + ]; + let signer_seeds = &[&authority_seeds[..]]; + if input_is_token_a { + 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.trader.to_account_info(), + }, + ), + input_amount, + context.accounts.mint_a.decimals, + )?; + token_interface::transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.pool_b.to_account_info(), + mint: context.accounts.mint_b.to_account_info(), + to: context.accounts.token_b.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + output, + context.accounts.mint_b.decimals, + )?; + } else { + token_interface::transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.pool_a.to_account_info(), + mint: context.accounts.mint_a.to_account_info(), + to: context.accounts.token_a.to_account_info(), + authority: context.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ), + output, + 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.trader.to_account_info(), + }, + ), + output, + context.accounts.mint_b.decimals, + )?; + } + + msg!( + "Traded {} tokens ({} after fees) for {} (admin slice {})", + input_amount, + taxed_input, + output, + admin_portion + ); + + // Verify the invariant still holds on the LP-claimable (effective) + // reserves. This is THE most important defensive check: it catches + // "I screwed up the swap math and accidentally gave the user too much" + // bugs that no other test would catch. Defence in depth — runs *after* + // the math (and after the transfers, once balances have been reloaded). + // + // We tolerate the new invariant being higher because it means a + // rounding gain for LPs (and/or the LP portion of the fee enriching + // the pool). + // + // u128 + checked: same overflow concern as the pre-trade invariant. + context.accounts.pool_a.reload()?; + context.accounts.pool_b.reload()?; + let pool_config = &context.accounts.pool_config; + let effective_pool_a_after = context + .accounts + .pool_a + .amount + .checked_sub(pool_config.admin_fees_owed_a) + .ok_or(AmmError::MathOverflow)?; + let effective_pool_b_after = context + .accounts + .pool_b + .amount + .checked_sub(pool_config.admin_fees_owed_b) + .ok_or(AmmError::MathOverflow)?; + let new_invariant = (effective_pool_a_after as u128) + .checked_mul(effective_pool_b_after as u128) + .ok_or(AmmError::MathOverflow)?; + require!(new_invariant >= invariant, AmmError::InvariantViolated); + + Ok(()) +} + +#[derive(Accounts)] +pub struct SwapTokensAccountConstraints<'info> { + #[account( + seeds = [CONFIG_SEED], + bump, + )] + pub config: Account<'info, Config>, + + #[account( + mut, + seeds = [ + pool_config.config.as_ref(), + pool_config.mint_a.key().as_ref(), + pool_config.mint_b.key().as_ref(), + ], + bump, + has_one = config, + has_one = mint_a, + has_one = mint_b, + )] + pub pool_config: Account<'info, PoolConfig>, + + /// CHECK: Read only authority + #[account( + seeds = [ + pool_config.config.as_ref(), + mint_a.key().as_ref(), + mint_b.key().as_ref(), + AUTHORITY_SEED, + ], + bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + /// The account doing the swap + pub trader: Signer<'info>, + + pub mint_a: Box>, + + pub mint_b: Box>, + + #[account( + mut, + associated_token::mint = mint_a, + associated_token::authority = pool_authority, + associated_token::token_program = token_program, + )] + pub pool_a: Box>, + + #[account( + mut, + associated_token::mint = mint_b, + associated_token::authority = pool_authority, + associated_token::token_program = token_program, + )] + pub pool_b: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = mint_a, + associated_token::authority = trader, + associated_token::token_program = token_program, + )] + pub token_a: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = mint_b, + associated_token::authority = trader, + associated_token::token_program = token_program, + )] + pub token_b: Box>, + + /// The account paying for all rents + #[account(mut)] + pub payer: Signer<'info>, + + /// Solana ecosystem accounts + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs index 51d5edff..f587c58d 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs @@ -1,19 +1,24 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token::{self, Burn, Mint, Token, TokenAccount, TransferChecked}, + token_interface::{self, Burn, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use fixed::types::I64F64; use crate::{ - constants::{AUTHORITY_SEED, LIQUIDITY_SEED, MINIMUM_LIQUIDITY}, - state::{Amm, Pool}, + constants::{AUTHORITY_SEED, CONFIG_SEED, LIQUIDITY_SEED, MINIMUM_LIQUIDITY}, + errors::AmmError, + state::{Config, PoolConfig}, }; -pub fn handle_withdraw_liquidity(context: Context, amount: u64) -> Result<()> { +pub fn handle_withdraw_liquidity( + context: Context, + amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, +) -> Result<()> { let authority_bump = context.bumps.pool_authority; let authority_seeds = &[ - &context.accounts.pool.amm.to_bytes(), + &context.accounts.pool_config.config.to_bytes(), &context.accounts.mint_a.key().to_bytes(), &context.accounts.mint_b.key().to_bytes(), AUTHORITY_SEED, @@ -21,24 +26,79 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 ]; let signer_seeds = &[&authority_seeds[..]]; - // Transfer tokens from the pool - let amount_a = I64F64::from_num(amount) - .checked_mul(I64F64::from_num(context.accounts.pool_account_a.amount)) - .unwrap() - .checked_div(I64F64::from_num( - context.accounts.mint_liquidity.supply + MINIMUM_LIQUIDITY, - )) - .unwrap() - .floor() - .to_num::(); + // LPs withdraw a proportional share of the *effective* reserves + // (vault balance minus the admin's accumulated fee claim). The admin's + // owed slice physically remains in the vaults but is not distributed to + // exiting LPs - it's claimed separately via `claim_admin_fees`. + let pool_config = &context.accounts.pool_config; + // checked_sub: admin_fees_owed is an invariant subset of the vault balance; + // a raw `-` would wrap silently on a BPF release build if that ever broke. + let effective_pool_a = context + .accounts + .pool_a + .amount + .checked_sub(pool_config.admin_fees_owed_a) + .ok_or(AmmError::MathOverflow)?; + let effective_pool_b = context + .accounts + .pool_b + .amount + .checked_sub(pool_config.admin_fees_owed_b) + .ok_or(AmmError::MathOverflow)?; + + // Proportional-withdraw formula: + // amount_out = lp_amount * effective_reserve / (lp_supply + MINIMUM_LIQUIDITY) + // The `+ MINIMUM_LIQUIDITY` accounts for the bootstrap floor that was + // locked away on the first deposit and is *not* part of the LP supply + // counter (mint::supply doesn't include it) but *is* part of the + // reserves — so the divisor needs the same adjustment to keep shares + // honest. + // + // u128 + checked: `lp_amount * reserve` can fill the full u128 (both + // factors are u64). Multiply before divide to preserve precision; floor + // is protocol-favouring (sub-base-unit rounding stays with the pool, + // grows LP value for everyone still in). + // + // Both amounts are computed up-front (before the slippage checks) so + // the LP gets a consistent error regardless of which side trips first, + // and so we don't transfer one side then revert. + let divisor = (context.accounts.liquidity_provider_mint.supply as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; + let amount_a_u128 = (amount as u128) + .checked_mul(effective_pool_a as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(divisor) + .ok_or(AmmError::MathOverflow)?; + let amount_a: u64 = u64::try_from(amount_a_u128).map_err(|_| AmmError::MathOverflow)?; + let amount_b_u128 = (amount as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(divisor) + .ok_or(AmmError::MathOverflow)?; + let amount_b: u64 = u64::try_from(amount_b_u128).map_err(|_| AmmError::MathOverflow)?; + + // LP's slippage protection: if the pool ratio shifted between the LP + // quoting their exit and this tx landing (e.g. a big swap drained one + // side), the proportional share comes back with a different mix than + // expected. Revert so the LP can bail / requote. + require!( + amount_a >= minimum_token_a_out, + AmmError::WithdrawalBelowMinimum + ); + require!( + amount_b >= minimum_token_b_out, + AmmError::WithdrawalBelowMinimum + ); + // transfer_checked verifies the mint + decimals at the token program. - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { - from: context.accounts.pool_account_a.to_account_info(), + from: context.accounts.pool_a.to_account_info(), mint: context.accounts.mint_a.to_account_info(), - to: context.accounts.depositor_account_a.to_account_info(), + to: context.accounts.token_a.to_account_info(), authority: context.accounts.pool_authority.to_account_info(), }, signer_seeds, @@ -47,22 +107,13 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 context.accounts.mint_a.decimals, )?; - let amount_b = I64F64::from_num(amount) - .checked_mul(I64F64::from_num(context.accounts.pool_account_b.amount)) - .unwrap() - .checked_div(I64F64::from_num( - context.accounts.mint_liquidity.supply + MINIMUM_LIQUIDITY, - )) - .unwrap() - .floor() - .to_num::(); - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { - from: context.accounts.pool_account_b.to_account_info(), + from: context.accounts.pool_b.to_account_info(), mint: context.accounts.mint_b.to_account_info(), - to: context.accounts.depositor_account_b.to_account_info(), + to: context.accounts.token_b.to_account_info(), authority: context.accounts.pool_authority.to_account_info(), }, signer_seeds, @@ -73,13 +124,13 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 // Burn the liquidity tokens // It will fail if the amount is invalid - token::burn( + token_interface::burn( CpiContext::new( context.accounts.token_program.key(), Burn { - mint: context.accounts.mint_liquidity.to_account_info(), - from: context.accounts.depositor_account_liquidity.to_account_info(), - authority: context.accounts.depositor.to_account_info(), + mint: context.accounts.liquidity_provider_mint.to_account_info(), + from: context.accounts.liquidity_provider_token.to_account_info(), + authority: context.accounts.withdrawer.to_account_info(), }, ), amount, @@ -89,103 +140,105 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 } #[derive(Accounts)] -pub struct WithdrawLiquidity<'info> { +pub struct WithdrawLiquidityAccountConstraints<'info> { #[account( - seeds = [ - amm.id.as_ref() - ], + seeds = [CONFIG_SEED], bump, )] - pub amm: Account<'info, Amm>, + pub config: Account<'info, Config>, #[account( seeds = [ - pool.amm.as_ref(), - pool.mint_a.key().as_ref(), - pool.mint_b.key().as_ref(), + pool_config.config.as_ref(), + pool_config.mint_a.key().as_ref(), + pool_config.mint_b.key().as_ref(), ], bump, has_one = mint_a, has_one = mint_b, )] - pub pool: Account<'info, Pool>, + pub pool_config: Account<'info, PoolConfig>, /// CHECK: Read only authority #[account( seeds = [ - pool.amm.as_ref(), + pool_config.config.as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), AUTHORITY_SEED, ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, - /// The account paying for all rents - pub depositor: Signer<'info>, + pub withdrawer: Signer<'info>, #[account( mut, seeds = [ - pool.amm.as_ref(), + pool_config.config.as_ref(), mint_a.key().as_ref(), mint_b.key().as_ref(), LIQUIDITY_SEED, ], bump, )] - pub mint_liquidity: Box>, + pub liquidity_provider_mint: Box>, #[account(mut)] - pub mint_a: Box>, + pub mint_a: Box>, #[account(mut)] - pub mint_b: Box>, + pub mint_b: Box>, #[account( mut, associated_token::mint = mint_a, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_account_b: Box>, + pub pool_b: Box>, #[account( mut, - associated_token::mint = mint_liquidity, - associated_token::authority = depositor, + associated_token::mint = liquidity_provider_mint, + associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub depositor_account_liquidity: Box>, + pub liquidity_provider_token: Box>, #[account( init_if_needed, payer = payer, associated_token::mint = mint_a, - associated_token::authority = depositor, + associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub depositor_account_a: Box>, + pub token_a: Box>, #[account( init_if_needed, payer = payer, associated_token::mint = mint_b, - associated_token::authority = depositor, + associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub depositor_account_b: Box>, + pub token_b: Box>, /// The account paying for all rents #[account(mut)] pub payer: Signer<'info>, /// Solana ecosystem accounts - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/lib.rs b/tokens/token-swap/anchor/programs/token-swap/src/lib.rs index e85da094..2f5d10ce 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/lib.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/lib.rs @@ -5,40 +5,68 @@ mod errors; mod instructions; mod state; -// Set the correct key here -declare_id!("QmzKmhyUQ9jbNKCPWQjqYNcrqek3FVjSSxc4sCtJeJL"); +declare_id!("GahM6PrXesrBkHiGJ5no4EskLNnVBCaSwVKbM4UtzyK6"); #[program] pub mod swap_example { pub use super::instructions::*; use super::*; - pub fn create_amm(context: Context, id: Pubkey, fee: u16) -> Result<()> { - instructions::handle_create_amm(context, id, fee) + pub fn create_config( + context: Context, + fee: u16, + admin_share_bps: u16, + ) -> Result<()> { + instructions::handle_create_config(context, fee, admin_share_bps) } - pub fn create_pool(context: Context) -> Result<()> { + pub fn create_pool(context: Context) -> Result<()> { instructions::handle_create_pool(context) } pub fn deposit_liquidity( - context: Context, + context: Context, amount_a: u64, amount_b: u64, + minimum_lp_tokens_out: u64, ) -> Result<()> { - instructions::handle_deposit_liquidity(context, amount_a, amount_b) + instructions::handle_deposit_liquidity( + context, + amount_a, + amount_b, + minimum_lp_tokens_out, + ) } - pub fn withdraw_liquidity(context: Context, amount: u64) -> Result<()> { - instructions::handle_withdraw_liquidity(context, amount) + pub fn withdraw_liquidity( + context: Context, + amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, + ) -> Result<()> { + instructions::handle_withdraw_liquidity( + context, + amount, + minimum_token_a_out, + minimum_token_b_out, + ) } - pub fn swap_exact_tokens_for_tokens( - context: Context, - swap_a: bool, + pub fn swap_tokens( + context: Context, + input_is_token_a: bool, input_amount: u64, min_output_amount: u64, ) -> Result<()> { - instructions::handle_swap_exact_tokens_for_tokens(context, swap_a, input_amount, min_output_amount) + instructions::handle_swap_tokens( + context, + input_is_token_a, + input_amount, + min_output_amount, + ) + } + + pub fn claim_admin_fees(context: Context) -> Result<()> { + instructions::handle_claim_admin_fees(context) } } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/state.rs b/tokens/token-swap/anchor/programs/token-swap/src/state.rs deleted file mode 100644 index 8a89414c..00000000 --- a/tokens/token-swap/anchor/programs/token-swap/src/state.rs +++ /dev/null @@ -1,33 +0,0 @@ -use anchor_lang::prelude::*; - -#[account] -#[derive(Default, InitSpace)] -pub struct Amm { - /// The primary key of the AMM - pub id: Pubkey, - - /// Account that has admin authority over the AMM - pub admin: Pubkey, - - /// The LP fee taken on each trade, in basis points - pub fee: u16, - - /// Canonical bump for this PDA. - pub bump: u8, -} - -#[account] -#[derive(Default, InitSpace)] -pub struct Pool { - /// Primary key of the AMM - pub amm: Pubkey, - - /// Mint of token A - pub mint_a: Pubkey, - - /// Mint of token B - pub mint_b: Pubkey, - - /// Canonical bump for this PDA. - pub bump: u8, -} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/state/config.rs b/tokens/token-swap/anchor/programs/token-swap/src/state/config.rs new file mode 100644 index 00000000..6272ecda --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/state/config.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +/// Shared configuration for the AMM (admin + trading fee). +/// +/// `Config` is a singleton: one account per deployed program, seeded by the +/// fixed byte string `b"config"`. This mirrors how real DEXs are deployed in +/// practice (e.g. Phoenix and Raydium ship one program per market/AMM, so the +/// program-level config is global by construction). Parameterising the config +/// by an `id` was leftover complexity from the original example; removing it +/// makes the onchain layout simpler and matches realistic deployment. +#[account] +#[derive(Default, InitSpace)] +pub struct Config { + /// Account that has admin authority over the AMM. + pub admin: Pubkey, + + /// The trading fee taken on each swap, in basis points (out of 10_000). + /// + /// This is the *total* fee charged on a swap. It is split between LPs and + /// the admin according to `admin_share_bps`. + pub fee: u16, + + /// Fraction of the trading fee that goes to the admin, in basis points + /// (out of 10_000). The remainder goes to LPs (it stays in the pool + /// reserves and grows the LP-claimable balance). + /// + /// Modelled on Uniswap V2 / Raydium: the AMM operator takes a slice of + /// every fee, LPs keep the rest. Set in `create_config`; fixed for the + /// lifetime of the program. Must be `< 10_000`. + pub admin_share_bps: u16, + + /// Canonical bump for this PDA. + pub bump: u8, +} diff --git a/tokens/token-swap/anchor/programs/token-swap/src/state/mod.rs b/tokens/token-swap/anchor/programs/token-swap/src/state/mod.rs new file mode 100644 index 00000000..b3086674 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod pool_config; + +pub use config::*; +pub use pool_config::*; diff --git a/tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs b/tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs new file mode 100644 index 00000000..60b8867c --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs @@ -0,0 +1,42 @@ +use anchor_lang::prelude::*; + +/// Per-pool configuration / identity record. +/// +/// Holds the metadata that identifies a single pool: which `Config` it belongs +/// to, which two mints it trades, and its canonical bump. The actual pool +/// reserves live in separate token accounts (`pool_a`, `pool_b`) owned by the +/// pool authority PDA — they are not stored here. This struct is the pool's +/// *configuration*, not its state. +/// +/// In addition to the identity fields, this account tracks the admin's +/// accumulated trading-fee claim on each side (`admin_fees_owed_a` / +/// `admin_fees_owed_b`). Those fees physically sit in the existing `pool_a` / +/// `pool_b` reserves; the accumulators are a *virtual* obligation against +/// those balances. LP-facing math (deposit, withdraw, swap curve) uses +/// `pool_X.amount - admin_fees_owed_X` so the admin's owed slice is not +/// counted toward LP yield. +#[account] +#[derive(Default, InitSpace)] +pub struct PoolConfig { + /// Address of the parent `Config` account this pool belongs to. + pub config: Pubkey, + + /// Mint of token A. + pub mint_a: Pubkey, + + /// Mint of token B. + pub mint_b: Pubkey, + + /// Admin's accumulated fee claim on token A, in base units. Sits + /// physically in `pool_a` but is excluded from the LP curve and from + /// LP-withdrawable amounts. Swept by `claim_admin_fees`. + pub admin_fees_owed_a: u64, + + /// Admin's accumulated fee claim on token B, in base units. Sits + /// physically in `pool_b` but is excluded from the LP curve and from + /// LP-withdrawable amounts. Swept by `claim_admin_fees`. + pub admin_fees_owed_b: u64, + + /// Canonical bump for this PDA. + pub bump: u8, +} diff --git a/tokens/token-swap/anchor/programs/token-swap/tests/test_swap.rs b/tokens/token-swap/anchor/programs/token-swap/tests/test_swap.rs index a8bcb6eb..a0adb092 100644 --- a/tokens/token-swap/anchor/programs/token-swap/tests/test_swap.rs +++ b/tokens/token-swap/anchor/programs/token-swap/tests/test_swap.rs @@ -59,14 +59,14 @@ struct TestSetup { program_id: Pubkey, payer: Keypair, admin: Keypair, - amm_key: Pubkey, + config_key: Pubkey, mint_a: Pubkey, mint_b: Pubkey, - pool_key: Pubkey, + pool_config_key: Pubkey, pool_authority: Pubkey, - mint_liquidity: Pubkey, - pool_account_a: Pubkey, - pool_account_b: Pubkey, + liquidity_provider_mint: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, holder_account_a: Pubkey, holder_account_b: Pubkey, liquidity_account: Pubkey, @@ -80,27 +80,30 @@ fn full_setup() -> TestSetup { let minted_amount: u64 = 100 * 10u64.pow(decimals as u32); let (mint_a, mint_b) = ordered_mints(&mut svm, &admin, decimals); - let amm_id = Keypair::new().pubkey(); let fee: u16 = 500; + // Uniswap V2's classic 1/6 split: admin keeps ~1/6 of the trading fee, + // LPs keep ~5/6 (1667 / 10_000 ≈ 0.1667). + let admin_share_bps: u16 = 1667; - // Derive PDAs - let (amm_key, _) = Pubkey::find_program_address(&[amm_id.as_ref()], &program_id); - let (pool_key, _) = Pubkey::find_program_address( - &[amm_key.as_ref(), mint_a.as_ref(), mint_b.as_ref()], + // Derive the singleton Config PDA (seeds = [b"config"]). One config per + // deployed program. + let (config_key, _) = Pubkey::find_program_address(&[b"config"], &program_id); + let (pool_config_key, _) = Pubkey::find_program_address( + &[config_key.as_ref(), mint_a.as_ref(), mint_b.as_ref()], &program_id, ); let (pool_authority, _) = Pubkey::find_program_address( &[ - amm_key.as_ref(), + config_key.as_ref(), mint_a.as_ref(), mint_b.as_ref(), b"authority", ], &program_id, ); - let (mint_liquidity, _) = Pubkey::find_program_address( + let (liquidity_provider_mint, _) = Pubkey::find_program_address( &[ - amm_key.as_ref(), + config_key.as_ref(), mint_a.as_ref(), mint_b.as_ref(), b"liquidity", @@ -108,9 +111,9 @@ fn full_setup() -> TestSetup { &program_id, ); - let pool_account_a = derive_ata(&pool_authority, &mint_a); - let pool_account_b = derive_ata(&pool_authority, &mint_b); - let liquidity_account = derive_ata(&admin.pubkey(), &mint_liquidity); + let pool_a = derive_ata(&pool_authority, &mint_a); + let pool_b = derive_ata(&pool_authority, &mint_b); + let liquidity_account = derive_ata(&admin.pubkey(), &liquidity_provider_mint); // Create ATAs for admin and mint tokens let holder_account_a = @@ -122,11 +125,11 @@ fn full_setup() -> TestSetup { mint_tokens_to_token_account(&mut svm, &mint_b, &holder_account_b, minted_amount, &admin).unwrap(); // Create AMM - let create_amm_ix = Instruction::new_with_bytes( + let create_config_ix = Instruction::new_with_bytes( program_id, - &swap_example::instruction::CreateAmm { id: amm_id, fee }.data(), - swap_example::accounts::CreateAmm { - amm: amm_key, + &swap_example::instruction::CreateConfig { fee, admin_share_bps }.data(), + swap_example::accounts::CreateConfigAccountConstraints { + config: config_key, admin: admin.pubkey(), payer: payer.pubkey(), system_program: system_program::id(), @@ -135,7 +138,7 @@ fn full_setup() -> TestSetup { ); send_transaction_from_instructions( &mut svm, - vec![create_amm_ix], + vec![create_config_ix], &[&payer], &payer.pubkey(), ) @@ -145,15 +148,15 @@ fn full_setup() -> TestSetup { let create_pool_ix = Instruction::new_with_bytes( program_id, &swap_example::instruction::CreatePool {}.data(), - swap_example::accounts::CreatePool { - amm: amm_key, - pool: pool_key, + swap_example::accounts::CreatePoolAccountConstraints { + config: config_key, + pool_config: pool_config_key, pool_authority, - mint_liquidity, + liquidity_provider_mint, mint_a, mint_b, - pool_account_a, - pool_account_b, + pool_a, + pool_b, payer: payer.pubkey(), token_program: token_program_id(), associated_token_program: ata_program_id(), @@ -174,14 +177,14 @@ fn full_setup() -> TestSetup { program_id, payer, admin, - amm_key, + config_key, mint_a, mint_b, - pool_key, + pool_config_key, pool_authority, - mint_liquidity, - pool_account_a, - pool_account_b, + liquidity_provider_mint, + pool_a, + pool_b, holder_account_a, holder_account_b, liquidity_account, @@ -189,19 +192,19 @@ fn full_setup() -> TestSetup { } #[test] -fn test_create_amm() { +fn test_create_config() { let (mut svm, program_id, payer) = setup(); - let amm_id = Keypair::new().pubkey(); let fee: u16 = 500; + let admin_share_bps: u16 = 1667; let admin = Keypair::new(); - let (amm_key, _) = Pubkey::find_program_address(&[amm_id.as_ref()], &program_id); + let (config_key, _) = Pubkey::find_program_address(&[b"config"], &program_id); - let create_amm_ix = Instruction::new_with_bytes( + let create_config_ix = Instruction::new_with_bytes( program_id, - &swap_example::instruction::CreateAmm { id: amm_id, fee }.data(), - swap_example::accounts::CreateAmm { - amm: amm_key, + &swap_example::instruction::CreateConfig { fee, admin_share_bps }.data(), + swap_example::accounts::CreateConfigAccountConstraints { + config: config_key, admin: admin.pubkey(), payer: payer.pubkey(), system_program: system_program::id(), @@ -211,15 +214,17 @@ fn test_create_amm() { send_transaction_from_instructions( &mut svm, - vec![create_amm_ix], + vec![create_config_ix], &[&payer], &payer.pubkey(), ) .unwrap(); - // Verify AMM account exists - let amm_account = svm.get_account(&amm_key).expect("AMM account should exist"); - assert!(!amm_account.data.is_empty()); + // Verify the Config account exists + let config_account = svm + .get_account(&config_key) + .expect("Config account should exist"); + assert!(!config_account.data.is_empty()); } #[test] @@ -233,20 +238,22 @@ fn test_deposit_liquidity() { &swap_example::instruction::DepositLiquidity { amount_a: deposit_amount_a, amount_b: deposit_amount_b, + // 0 = no slippage floor for this baseline test + minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidity { - pool: ts.pool_key, + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), - mint_liquidity: ts.mint_liquidity, + liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, - pool_account_a: ts.pool_account_a, - pool_account_b: ts.pool_account_b, - depositor_account_liquidity: ts.liquidity_account, - depositor_account_a: ts.holder_account_a, - depositor_account_b: ts.holder_account_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(), @@ -278,20 +285,22 @@ fn test_swap_a_to_b() { &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::DepositLiquidity { - pool: ts.pool_key, + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), - mint_liquidity: ts.mint_liquidity, + liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, - pool_account_a: ts.pool_account_a, - pool_account_b: ts.pool_account_b, - depositor_account_liquidity: ts.liquidity_account, - depositor_account_a: ts.holder_account_a, - depositor_account_b: ts.holder_account_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(), @@ -313,23 +322,23 @@ fn test_swap_a_to_b() { // Swap 1M of token A for token B let swap_ix = Instruction::new_with_bytes( ts.program_id, - &swap_example::instruction::SwapExactTokensForTokens { - swap_a: true, + &swap_example::instruction::SwapTokens { + input_is_token_a: true, input_amount: 1_000_000, min_output_amount: 100, } .data(), - swap_example::accounts::SwapExactTokensForTokens { - amm: ts.amm_key, - pool: ts.pool_key, + swap_example::accounts::SwapTokensAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, trader: ts.admin.pubkey(), mint_a: ts.mint_a, mint_b: ts.mint_b, - pool_account_a: ts.pool_account_a, - pool_account_b: ts.pool_account_b, - trader_account_a: ts.holder_account_a, - trader_account_b: ts.holder_account_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(), @@ -363,20 +372,22 @@ fn test_withdraw_liquidity() { &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::DepositLiquidity { - pool: ts.pool_key, + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), - mint_liquidity: ts.mint_liquidity, + liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, - pool_account_a: ts.pool_account_a, - pool_account_b: ts.pool_account_b, - depositor_account_liquidity: ts.liquidity_account, - depositor_account_a: ts.holder_account_a, - depositor_account_b: ts.holder_account_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(), @@ -401,21 +412,24 @@ fn test_withdraw_liquidity() { ts.program_id, &swap_example::instruction::WithdrawLiquidity { amount: liq_amount, + // 0 = no slippage floor for this baseline test + minimum_token_a_out: 0, + minimum_token_b_out: 0, } .data(), - swap_example::accounts::WithdrawLiquidity { - amm: ts.amm_key, - pool: ts.pool_key, + swap_example::accounts::WithdrawLiquidityAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, - depositor: ts.admin.pubkey(), - mint_liquidity: ts.mint_liquidity, + withdrawer: ts.admin.pubkey(), + liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, - pool_account_a: ts.pool_account_a, - pool_account_b: ts.pool_account_b, - depositor_account_liquidity: ts.liquidity_account, - depositor_account_a: ts.holder_account_a, - depositor_account_b: ts.holder_account_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(), @@ -435,3 +449,1000 @@ fn test_withdraw_liquidity() { let liq_amount = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); 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, + pool_authority: ts.pool_authority, + 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, + pool_authority: ts.pool_authority, + 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( + ts.program_id, + &swap_example::instruction::ClaimAdminFees {}.data(), + swap_example::accounts::ClaimAdminFeesAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + admin: ts.admin.pubkey(), + admin_token_a: ts.holder_account_a, + admin_token_b: ts.holder_account_b, + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + +/// Helper: do an A->B swap of `input_amount` on the standard setup. +fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { + let swap_ix = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::SwapTokens { + input_is_token_a: true, + input_amount, + min_output_amount: 1, + } + .data(), + swap_example::accounts::SwapTokensAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + 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(); +} + +#[test] +fn test_claim_admin_fees() { + let mut ts = full_setup(); + // 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); + + let fee_amount = swap_in * 500 / 10_000; + let expected_admin_a = fee_amount * 1667 / 10_000; + assert!(expected_admin_a > 0, "expected admin portion > 0"); + + // ---- Phase 1: first claim transfers the accumulated A-side fees ---- + let admin_balance_a_before = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let admin_balance_b_before = + get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + let claim_ix_first = claim_admin_fees_ix(&ts); + send_transaction_from_instructions( + &mut ts.svm, + vec![claim_ix_first], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .unwrap(); + + let admin_balance_a_after = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let admin_balance_b_after = + get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + assert_eq!( + admin_balance_a_after - admin_balance_a_before, + expected_admin_a, + "admin should receive the accumulated A-side fee" + ); + // No swap on the B side, so no B-side fees were owed. + assert_eq!( + admin_balance_b_after, admin_balance_b_before, + "admin should receive zero B-side fees (no B-input swaps happened)" + ); + + // ---- Phase 2: swap more, claim again, verify the new fee is paid ---- + // This proves the accumulators were truly reset (not just zeroed in + // memory): a fresh swap accrues new fees from a clean baseline, and the + // next claim transfers exactly that new amount. + let swap_in_2 = 500_000u64; + swap_a_to_b(&mut ts, swap_in_2); + let fee_amount_2 = swap_in_2 * 500 / 10_000; + let expected_admin_a_2 = fee_amount_2 * 1667 / 10_000; + assert!(expected_admin_a_2 > 0, "expected second admin portion > 0"); + + let balance_a_pre_claim_2 = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + + // Bump the blockhash so this claim-ix tx isn't byte-identical to the + // earlier one (same accounts + same payload → same signature → + // `AlreadyProcessed` in litesvm). + ts.svm.expire_blockhash(); + let claim_ix_second = claim_admin_fees_ix(&ts); + send_transaction_from_instructions( + &mut ts.svm, + vec![claim_ix_second], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .unwrap(); + + let balance_a_post_claim_2 = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + assert_eq!( + balance_a_post_claim_2 - balance_a_pre_claim_2, + expected_admin_a_2, + "second claim should transfer only the fees from the second swap" + ); + + // ---- Phase 3: third claim with zero owed reverts with NothingToClaim ---- + // Bump the blockhash so this tx isn't byte-identical to the previous + // claim - otherwise litesvm short-circuits with `AlreadyProcessed` + // before the program even runs and we'd never see our error. + ts.svm.expire_blockhash(); + let claim_ix_again = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::ClaimAdminFees {}.data(), + swap_example::accounts::ClaimAdminFeesAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + admin: ts.admin.pubkey(), + admin_token_a: ts.holder_account_a, + admin_token_b: ts.holder_account_b, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + let balance_a_before_third_claim = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let result = send_transaction_from_instructions( + &mut ts.svm, + vec![claim_ix_again], + &[&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}" + ); + + // Balance unchanged - the revert rolled back any partial state. + let balance_a_after_third_claim = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + assert_eq!( + balance_a_after_third_claim, balance_a_before_third_claim, + "failed claim must not move tokens" + ); +} + +#[test] +fn test_claim_admin_fees_rejects_non_admin() { + let mut ts = full_setup(); + // 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); + + // 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(); + let attacker_token_a = + create_associated_token_account(&mut ts.svm, &attacker.pubkey(), &ts.mint_a, &ts.payer) + .unwrap(); + let attacker_token_b = + create_associated_token_account(&mut ts.svm, &attacker.pubkey(), &ts.mint_b, &ts.payer) + .unwrap(); + + let claim_ix = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::ClaimAdminFees {}.data(), + swap_example::accounts::ClaimAdminFeesAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + admin: attacker.pubkey(), + admin_token_a: attacker_token_a, + admin_token_b: attacker_token_b, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + + // Should fail because the signer (attacker) does not match Config.admin + // (enforced by Anchor's `has_one = admin` constraint). + let result = send_transaction_from_instructions( + &mut ts.svm, + vec![claim_ix], + &[&ts.payer, &attacker], + &ts.payer.pubkey(), + ); + assert!( + result.is_err(), + "claim_admin_fees by a non-admin signer must fail" + ); +} + +/// Helper: issue a `deposit_liquidity` ix with the given amounts. Lets a test +/// fund the pool to any state without copy-pasting the full account list. +fn deposit_ix(ts: &TestSetup, amount_a: u64, amount_b: u64) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::DepositLiquidity { + amount_a, + amount_b, + // 0 = no slippage floor; slippage-specific tests build their + // own ix with a non-zero floor. + minimum_lp_tokens_out: 0, + } + .data(), + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + 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), + ) +} + +/// Wrap `send_transaction_from_instructions` so tests can call `?` / +/// `.expect()` on a deposit without re-stating the boilerplate. We don't +/// care about the success payload (`Ok` only signals "tx landed"), and the +/// concrete error type is `solana_kite::SolanaKiteError`. Returning a +/// `Result<(), String>` keeps tests insulated from the kite crate's error +/// type — they just need success/failure plus a message for `.expect()`. +fn send_deposit(ts: &mut TestSetup, amount_a: u64, amount_b: u64) -> Result<(), String> { + let ix = deposit_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:?}")) +} + +/// Test A: deposit into an already-funded pool at the *exact* current ratio +/// succeeds and both sides are pulled in full. Verifies the clamp leaves +/// matching amounts unchanged and that LP tokens are minted. +#[test] +fn test_deposit_into_funded_pool_at_correct_ratio() { + let mut ts = full_setup(); + + // 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"); + + 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(); + let lp_before = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + + // Second deposit at the same 4:1 ratio. Neither side should be clamped. + send_deposit(&mut ts, 8_000_000, 2_000_000).expect("ratio-matched deposit"); + + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let lp_after = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + + assert_eq!( + pool_a_after - pool_a_before, + 8_000_000, + "pool_a should grow by the full requested amount_a" + ); + assert_eq!( + pool_b_after - pool_b_before, + 2_000_000, + "pool_b should grow by the full requested amount_b" + ); + assert!(lp_after > lp_before, "LP tokens should be minted to depositor"); +} + +/// Test B: depositor offers more token B than the ratio needs. `amount_b` +/// 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 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(); + 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(); + + // Caller wants 8M A : 3M B, but at 4:1 only 2M B is needed for 8M A. + // amount_b should clamp from 3M → 2M. + send_deposit(&mut ts, 8_000_000, 3_000_000).expect("excess-b deposit"); + + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let holder_a_after = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_after = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + assert_eq!( + pool_a_after - pool_a_before, + 8_000_000, + "amount_a should be used in full" + ); + assert_eq!( + pool_b_after - pool_b_before, + 2_000_000, + "amount_b should clamp down to 2M (the ratio-matched amount)" + ); + // Cross-check via the depositor's balance: only the clamped amount left + // their wallet. + assert_eq!(holder_a_before - holder_a_after, 8_000_000); + assert_eq!(holder_b_before - holder_b_after, 2_000_000); +} + +/// Test C: depositor offers more token A than the ratio can absorb. `amount_a` +/// 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 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(); + + // Caller wants 12M A : 2M B, but at 4:1 only 8M A is needed for 2M B. + // amount_a should clamp from 12M → 8M. + send_deposit(&mut ts, 12_000_000, 2_000_000).expect("excess-a deposit"); + + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + + assert_eq!( + pool_a_after - pool_a_before, + 8_000_000, + "amount_a should clamp down to 8M (the ratio-matched amount)" + ); + assert_eq!( + pool_b_after - pool_b_before, + 2_000_000, + "amount_b should be used in full" + ); +} + +/// Test D: end-to-end. A swap shifts the pool away from its seeded ratio; a +/// subsequent deposit must use the *new, shifted* effective ratio (not the +/// raw vault ratio, which includes admin fees). Proves the +/// effective-reserves subtraction works under real swap fees. +#[test] +fn test_deposit_after_swap_uses_shifted_effective_ratio() { + let mut ts = full_setup(); + + // Seed at 100:100 (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) + // taxed_input = 950_000 + // Effective reserves pre-swap: (10M, 10M). Output b = 950_000 * 10M / + // (10M + 950_000) ≈ 867_579 (u128 integer division, exact value + // depends on the floor). + // Post-swap raw vault: pool_a ≈ 11M, pool_b ≈ 9.13M. + // Effective (LP) reserves: pool_a - 8_335, pool_b unchanged. + let swap_in = 1_000_000u64; + let swap_ix = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::SwapTokens { + input_is_token_a: true, + input_amount: swap_in, + min_output_amount: 1, + } + .data(), + swap_example::accounts::SwapTokensAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + 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(), + ) + .expect("swap a→b"); + + let pool_a_after_swap = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after_swap = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let admin_owed_a: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + // PoolConfig layout: 8-byte anchor discriminator, then Pubkey config + // (32), Pubkey mint_a (32), Pubkey mint_b (32), u64 + // admin_fees_owed_a (8), u64 admin_fees_owed_b (8), u8 bump. + let start = 8 + 32 * 3; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + assert!(admin_owed_a > 0, "swap should have accrued admin fees on A"); + let effective_pool_a = pool_a_after_swap - admin_owed_a; + let effective_pool_b = pool_b_after_swap; + // Sanity: pool moved meaningfully off 1:1. + assert!( + effective_pool_a > effective_pool_b, + "after A→B swap, effective A side should be larger" + ); + + // Now deposit using the *effective* ratio. We'll deposit at exactly that + // ratio so both sides should be pulled in full. Pick a base of 1M on the + // B side and compute the matching A side from effective reserves. + let deposit_b = 1_000_000u64; + let deposit_a = ((deposit_b as u128) * (effective_pool_a as u128) + / (effective_pool_b as u128)) as u64; + + 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(); + + // Give the depositor a small headroom on A so the clamp logic has the + // option to consume the full deposit_a. (We expect deposit_a to be fully + // used because it matches the effective ratio.) + send_deposit(&mut ts, deposit_a + 10, deposit_b).expect("post-swap deposit"); + + let holder_a_after = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + let holder_b_after = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + // The contract should have pulled exactly deposit_a (or deposit_a ± 1 + // base unit because of integer division rounding) and the full deposit_b. + let used_a = holder_a_before - holder_a_after; + let used_b = holder_b_before - holder_b_after; + assert_eq!(used_b, deposit_b, "amount_b should be fully used"); + // used_a must be close to deposit_a — never the unbounded raw value + // (deposit_a + 10). If the bug were still here we'd see something + // wildly off (or a transaction failure on transfer_checked). + assert!( + used_a <= deposit_a + 1 && used_a + 1 >= deposit_a, + "used_a {} should clamp to ~deposit_a {} (±1 for integer division)", + used_a, + deposit_a + ); +} + +/// Test E: a deposit so small that one clamped side rounds to zero must +/// revert with `DepositAmountTooSmall` rather than mint LP tokens against a +/// zero contribution. +#[test] +fn test_deposit_too_small_for_ratio_reverts() { + let mut ts = full_setup(); + + // Seed 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)" + ); +} + +/// Test F: LP-mint correctness for a subsequent deposit at the current ratio. +/// With the Uniswap V2 formula `min(a*supply/pool_a, b*supply/pool_b)`, an +/// equal-ratio deposit must mint LP tokens exactly proportional to its share +/// of the pool. Previously the program used `sqrt(a*b)` for *all* deposits, +/// which over- or under-minted depending on pool size and broke +/// proportionality. +#[test] +fn test_lp_mint_proportional_to_share_of_pool() { + let mut ts = full_setup(); + + // Initial deposit: 4M : 1M. sqrt(4M * 1M) = 2_000_000. Minus + // MINIMUM_LIQUIDITY (100) → depositor 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" + ); + + // Second deposit at the same 4:1 ratio doubles the pool. The proportional + // formula must mint exactly `lp_supply_initial` more LP (so the depositor + // doubles their stake). + 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(); + + let minted_on_second = lp_after_second - lp_before_second; + // min(4M * 1_999_900 / 4M, 1M * 1_999_900 / 1M) = 1_999_900. + let expected_second: u64 = 1_999_900; + assert_eq!( + minted_on_second, expected_second, + "second deposit (same ratio, same size) should mint the same LP \ + amount as the initial deposit minus the locked floor" + ); +} + +/// Test G: LP-mint correctness after a swap has shifted the pool ratio. The +/// effective reserves differ from the seeded ratio; LP minting must use +/// the post-swap effective reserves (vault balance minus admin fees) to +/// keep shares honest. +#[test] +fn test_lp_mint_after_swap_uses_effective_reserves() { + let mut ts = full_setup(); + + // 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(); + let total_supply_before_second = lp_after_initial; + + let swap_in = 1_000_000u64; + swap_a_to_b(&mut ts, swap_in); + + // Read post-swap effective reserves directly from onchain state. + let pool_a_after_swap = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after_swap = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let admin_owed_a: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + let start = 8 + 32 * 3; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + let effective_pool_a = pool_a_after_swap - admin_owed_a; + let effective_pool_b = pool_b_after_swap; + + // Deposit at exactly the effective ratio. Pick deposit_b, derive deposit_a. + let deposit_b: u64 = 1_000_000; + let deposit_a = ((deposit_b as u128) * (effective_pool_a as u128) + / (effective_pool_b as u128)) as u64; + + // Expected LP minted = min(a*supply/pool_a, b*supply/pool_b) using the + // *clamped* (a, b) the program actually transfers. After clamp at the + // exact ratio, the binding side is whichever clamp picks: in + // deposit_liquidity, `amount_b_required = amount_a * pool_b / pool_a`. + // We pass `deposit_a` exactly, so amount_b_required = deposit_a * pool_b + // / pool_a, which rounds down to ≤ deposit_b. The program then uses + // (deposit_a, amount_b_required). Compute the expected LP from that. + let amount_b_used = ((deposit_a as u128) * (effective_pool_b as u128) + / (effective_pool_a as u128)) as u64; + let expected_liquidity_from_a = (deposit_a as u128) * (total_supply_before_second as u128) + / (effective_pool_a as u128); + let expected_liquidity_from_b = (amount_b_used as u128) * (total_supply_before_second as u128) + / (effective_pool_b as u128); + let expected_liquidity = expected_liquidity_from_a.min(expected_liquidity_from_b) as u64; + + let lp_before = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + send_deposit(&mut ts, deposit_a, deposit_b).expect("post-swap deposit"); + let lp_after = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + + let minted = lp_after - lp_before; + assert_eq!( + minted, expected_liquidity, + "LP minted on post-swap deposit must match share-of-effective-pool math" + ); +} + +// --------------------------------------------------------------------------- +// Slippage protection + invariant tests +// --------------------------------------------------------------------------- + +/// Helper: build a `swap_tokens` ix with a custom `min_output_amount`. +fn swap_a_to_b_ix(ts: &TestSetup, input_amount: u64, min_output_amount: u64) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::SwapTokens { + input_is_token_a: true, + input_amount, + min_output_amount, + } + .data(), + swap_example::accounts::SwapTokensAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + 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), + ) +} + +/// Helper: build a `deposit_liquidity` ix with a custom `minimum_lp_tokens_out`. +fn deposit_ix_with_min_lp( + ts: &TestSetup, + amount_a: u64, + amount_b: u64, + minimum_lp_tokens_out: u64, +) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::DepositLiquidity { + amount_a, + amount_b, + minimum_lp_tokens_out, + } + .data(), + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + 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), + ) +} + +/// Helper: build a `withdraw_liquidity` ix with custom slippage floors. +fn withdraw_ix_with_min( + ts: &TestSetup, + amount: u64, + minimum_token_a_out: u64, + minimum_token_b_out: u64, +) -> Instruction { + Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::WithdrawLiquidity { + amount, + minimum_token_a_out, + minimum_token_b_out, + } + .data(), + swap_example::accounts::WithdrawLiquidityAccountConstraints { + config: ts.config_key, + pool_config: ts.pool_config_key, + pool_authority: ts.pool_authority, + withdrawer: 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), + ) +} + +/// Slippage test: a swap with `min_output_amount` strictly higher than the +/// achievable output must revert with `SlippageExceeded` rather than fill +/// 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"); + + // First: prove the swap *would* succeed with a permissive floor. + let baseline_ix = swap_a_to_b_ix(&ts, 1_000_000, 1); + let before_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + send_transaction_from_instructions( + &mut ts.svm, + vec![baseline_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("baseline swap should succeed"); + let after_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + let actual_output = after_b - before_b; + assert!(actual_output > 0, "baseline swap should produce some B"); + + // 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 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( + &mut ts.svm, + vec![strict_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ); + let err = format!("{:?}", result.expect_err("must revert")); + assert!( + err.contains("SlippageExceeded"), + "expected SlippageExceeded, got: {err}" + ); +} + +/// Slippage test: a deposit with `minimum_lp_tokens_out` strictly higher +/// than the achievable LP mint amount must revert with +/// `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"); + + // 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): + // liquidity = min(a*supply/pool_a, b*supply/pool_b) + // Effective reserves == raw reserves here because no swaps have happened. + let lp_supply = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + let pool_a_amount = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_amount = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + let lp_from_a = (4_000_000u128 * lp_supply as u128) / pool_a_amount as u128; + let lp_from_b = (1_000_000u128 * lp_supply as u128) / pool_b_amount as u128; + let achievable_lp = lp_from_a.min(lp_from_b) as u64; + + // Require *strictly more* than that — the deposit must revert. + let strict_ix = + deposit_ix_with_min_lp(&ts, 4_000_000, 1_000_000, achievable_lp + 1); + let result = send_transaction_from_instructions( + &mut ts.svm, + vec![strict_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ); + let err = format!("{:?}", result.expect_err("must revert")); + assert!( + err.contains("DepositBelowMinimum"), + "expected DepositBelowMinimum, got: {err}" + ); + + // 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); + send_transaction_from_instructions( + &mut ts.svm, + vec![ok_ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("deposit at exact LP floor should succeed"); +} + +/// Slippage test: a withdrawal with `minimum_token_a_out` or +/// `minimum_token_b_out` strictly higher than the achievable output must +/// 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 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 + // exact amount is `lp/2 * 4_000_000 / (lp_supply + MINIMUM_LIQUIDITY)`. + // Demand 4M of A out of a half-burn — clearly impossible, must revert. + let strict_ix = withdraw_ix_with_min(&ts, lp / 2, 4_000_000, 0); + let result = send_transaction_from_instructions( + &mut ts.svm, + vec![strict_ix], + &[&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}" + ); + + // Same on the B side. + let strict_ix_b = withdraw_ix_with_min(&ts, lp / 2, 0, 4_000_000); + let result_b = send_transaction_from_instructions( + &mut ts.svm, + vec![strict_ix_b], + &[&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}" + ); +} + +/// Slippage test: passing `min_output_amount = 0` is the explicit +/// "I accept any non-zero output" signal — this is the documented escape +/// 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 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); + send_transaction_from_instructions( + &mut ts.svm, + vec![ix], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("swap with min_output_amount=0 must succeed"); + let after_b = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + assert!(after_b > before_b, "B balance should increase"); +} + +/// Invariant-check test: a normal swap leaves the effective `k = x * y` +/// at least as high as before (LP fee adds to LP-claimable reserves; admin +/// slice is excluded). This is the runtime guard that catches "the math +/// 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"); + + // Read effective reserves before. + 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(); + // No swaps yet → admin_fees_owed_* = 0, so effective == raw. + let k_before = (pool_a_before as u128) * (pool_b_before as u128); + + // Do an A→B swap and verify it succeeds. + swap_a_to_b(&mut ts, 1_000_000); + + // Compute effective reserves after. We need to subtract admin_fees_owed_a + // (the swap was input_is_token_a = true, so the admin fee accrued on A). + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let pool_b_after = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); + // PoolConfig layout: 8 (discriminator) + 32*3 (config, mint_a, mint_b) → + // admin_fees_owed_a starts at byte 104. + let admin_owed_a: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + let start = 8 + 32 * 3; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + let effective_a_after = pool_a_after - admin_owed_a; + let effective_b_after = pool_b_after; + let k_after = (effective_a_after as u128) * (effective_b_after as u128); + + assert!( + k_after >= k_before, + "effective invariant must not decrease across a fee-paying swap: \ + before={k_before}, after={k_after}" + ); +} diff --git a/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs new file mode 100644 index 00000000..7c83f4c8 --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -0,0 +1,110 @@ +use { + crate::{ + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, PoolAuthorityPda, PoolPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Authorisation: `admin` is a `Signer` and must match `Config.admin`. We +/// enforce that explicitly in the handler since quasar doesn't have an +/// Anchor-style `has_one` constraint. +#[derive(Accounts)] +pub struct ClaimAdminFeesAccounts { + #[account(address = ConfigPda::seeds())] + pub config: Account, + #[account( + mut, + address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()), + )] + pub pool_config: Account, + /// Pool authority PDA — signs the outbound transfers. + #[account(address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub pool_authority: UncheckedAccount, + pub mint_a: Account, + pub mint_b: Account, + /// Pool's token-A reserve. The admin's owed token-A fees are paid out of + /// this account. + #[account(mut)] + pub pool_a: Account, + /// Pool's token-B reserve. The admin's owed token-B fees are paid out of + /// this account. + #[account(mut)] + pub pool_b: Account, + /// Must equal `Config.admin` (checked in the handler). + pub admin: Signer, + /// Admin's token-A receiving account. Must exist; not auto-created + /// (keeps this handler small). + #[account(mut)] + pub admin_token_a: Account, + /// Admin's token-B receiving account. Must exist; not auto-created. + #[account(mut)] + pub admin_token_b: Account, + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_claim_admin_fees( + accounts: &mut ClaimAdminFeesAccounts, + bumps: &ClaimAdminFeesAccountsBumps, +) -> Result<(), ProgramError> { + // Authorisation: only the address stored in `Config.admin` may call this. + if *accounts.admin.address() != *accounts.config.admin() { + return Err(ProgramError::Custom(6)); // Unauthorized + } + + let owed_a = accounts.pool_config.admin_fees_owed_a(); + let owed_b = accounts.pool_config.admin_fees_owed_b(); + + // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(crate::AUTHORITY_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]), + ]; + + // Effects: zero the accumulators before the transfer CPIs + // (Checks-Effects-Interactions). If a CPI fails the whole transaction + // reverts, so resetting the onchain bookkeeping first is safe. + let config_addr = *accounts.pool_config.config(); + let mint_a_addr = *accounts.pool_config.mint_a(); + let mint_b_addr = *accounts.pool_config.mint_b(); + accounts.pool_config.set_inner(PoolConfigInner { + config: config_addr, + mint_a: mint_a_addr, + mint_b: mint_b_addr, + admin_fees_owed_a: 0, + admin_fees_owed_b: 0, + }); + + // Interactions: transfer the owed fees out of the pool reserves. + if owed_a > 0 { + accounts + .token_program + .transfer( + &accounts.pool_a, + &accounts.admin_token_a, + &accounts.pool_authority, + owed_a, + ) + .invoke_signed(seeds)?; + } + + if owed_b > 0 { + accounts + .token_program + .transfer( + &accounts.pool_b, + &accounts.admin_token_b, + &accounts.pool_authority, + owed_b, + ) + .invoke_signed(seeds)?; + } + + Ok(()) +} diff --git a/tokens/token-swap/quasar/src/instructions/create_amm.rs b/tokens/token-swap/quasar/src/instructions/create_amm.rs deleted file mode 100644 index 5b797a3e..00000000 --- a/tokens/token-swap/quasar/src/instructions/create_amm.rs +++ /dev/null @@ -1,33 +0,0 @@ -use { - crate::{state::{Amm, AmmInner}, AmmPda}, - quasar_lang::prelude::*, -}; - -/// Accounts for creating a new AMM. -/// -/// The Anchor version derives the AMM PDA from an `id` instruction argument. -/// In Quasar, we use a simpler fixed seed `["amm"]` since the Quasar derive -/// macro seeds reference account addresses, not instruction data. -#[derive(Accounts)] -pub struct CreateAmm { - #[account(mut, init, payer = payer, address = AmmPda::seeds())] - pub amm: Account, - /// Admin authority for the AMM. - pub admin: UncheckedAccount, - #[account(mut)] - pub payer: Signer, - pub system_program: Program, -} - -#[inline(always)] -pub fn handle_create_amm(accounts: &mut CreateAmm, id: Address, fee: u16) -> Result<(), ProgramError> { - if fee >= 10000 { - return Err(ProgramError::InvalidArgument); - } - accounts.amm.set_inner(AmmInner { - id, - admin: *accounts.admin.address(), - fee: fee.into(), - }); - Ok(()) -} diff --git a/tokens/token-swap/quasar/src/instructions/create_config.rs b/tokens/token-swap/quasar/src/instructions/create_config.rs new file mode 100644 index 00000000..a5e7101d --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/create_config.rs @@ -0,0 +1,41 @@ +use { + crate::{state::{Config, ConfigInner}, ConfigPda, BASIS_POINTS_DIVISOR}, + quasar_lang::prelude::*, +}; + +/// `Config` is a global singleton: one account per deployed program, derived +/// at the fixed seed `b"config"`. There is no `id` parameter — calling this +/// twice for the same program will fail because the account already exists. +#[derive(Accounts)] +pub struct CreateConfigAccounts { + #[account(mut, init, payer = payer, address = ConfigPda::seeds())] + pub config: Account, + /// Admin authority for the AMM. + pub admin: UncheckedAccount, + #[account(mut)] + pub payer: Signer, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_create_config( + accounts: &mut CreateConfigAccounts, + fee: u16, + admin_share_bps: u16, +) -> Result<(), ProgramError> { + if fee as u64 >= BASIS_POINTS_DIVISOR { + return Err(ProgramError::InvalidArgument); + } + // `admin_share_bps` is the basis-points slice of the trading fee that + // goes to the admin (rest goes to LPs). Anything >= 10_000 is nonsensical + // (admin can't take more than the whole fee). + if admin_share_bps as u64 >= BASIS_POINTS_DIVISOR { + return Err(ProgramError::InvalidArgument); + } + accounts.config.set_inner(ConfigInner { + admin: *accounts.admin.address(), + fee: fee.into(), + admin_share_bps: admin_share_bps.into(), + }); + Ok(()) +} diff --git a/tokens/token-swap/quasar/src/instructions/create_pool.rs b/tokens/token-swap/quasar/src/instructions/create_pool.rs index c7ec5e67..1d37907d 100644 --- a/tokens/token-swap/quasar/src/instructions/create_pool.rs +++ b/tokens/token-swap/quasar/src/instructions/create_pool.rs @@ -1,36 +1,34 @@ use { crate::{ - state::{Amm, Pool, PoolInner}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, }; -/// Accounts for creating a new liquidity pool. +/// Seeds: +/// - `pool_config = [config, mint_a, mint_b]` +/// - `pool_authority = [b"authority", config, mint_a, mint_b]` +/// - `liquidity_provider_mint = [b"liquidity", config, mint_a, mint_b]` /// -/// Seeds are based on account addresses: pool = [amm, mint_a, mint_b], -/// pool_authority = [b"authority", amm, mint_a, mint_b], -/// mint_liquidity = [b"liquidity", amm, mint_a, mint_b]. -/// -/// Note: post-PR-#195 the seed prefix is always emitted first by -/// `#[derive(Seeds)]`, so pool_authority/mint_liquidity now derive with -/// the literal prefix in front (different on-chain addresses than the -/// Anchor sibling, but internally consistent within this program). +/// `pool_authority` and `liquidity_provider_mint` derive at different +/// on-chain addresses than the Anchor sibling because `#[derive(Seeds)]` +/// emits the literal prefix first. Internally consistent within this program. #[derive(Accounts)] -pub struct CreatePool { - #[account(address = AmmPda::seeds())] - pub amm: Account, +pub struct CreatePoolAccounts { + #[account(address = ConfigPda::seeds())] + pub config: Account, #[account( mut, init, payer = payer, - address = PoolPda::seeds(amm.address(), mint_a.address(), mint_b.address()), + address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()), )] - pub pool: Account, + pub pool_config: Account, /// Pool authority PDA — signs for pool token operations. #[account( - address = PoolAuthorityPda::seeds(amm.address(), mint_a.address(), mint_b.address()), + address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()), )] pub pool_authority: UncheckedAccount, /// Liquidity token mint — created at a PDA. @@ -38,28 +36,28 @@ pub struct CreatePool { mut, init, payer = payer, - address = LiquidityMintPda::seeds(amm.address(), mint_a.address(), mint_b.address()), + address = LiquidityMintPda::seeds(config.address(), mint_a.address(), mint_b.address()), mint(decimals = 6, authority = pool_authority, freeze_authority = None, token_program = token_program), )] - pub mint_liquidity: Account, + pub liquidity_provider_mint: Account, pub mint_a: Account, pub mint_b: Account, - /// Pool's token A account. + /// Pool's token A reserve. #[account( mut, init(idempotent), payer = payer, token(mint = mint_a, authority = pool_authority, token_program = token_program), )] - pub pool_account_a: Account, - /// Pool's token B account. + pub pool_a: Account, + /// Pool's token B reserve. #[account( mut, init(idempotent), payer = payer, token(mint = mint_b, authority = pool_authority, token_program = token_program), )] - pub pool_account_b: Account, + pub pool_b: Account, #[account(mut)] pub payer: Signer, pub token_program: Program, @@ -68,11 +66,16 @@ pub struct CreatePool { } #[inline(always)] -pub fn handle_create_pool(accounts: &mut CreatePool) -> Result<(), ProgramError> { - accounts.pool.set_inner(PoolInner { - amm: *accounts.amm.address(), +pub fn handle_create_pool(accounts: &mut CreatePoolAccounts) -> Result<(), ProgramError> { + accounts.pool_config.set_inner(PoolConfigInner { + config: *accounts.config.address(), mint_a: *accounts.mint_a.address(), mint_b: *accounts.mint_b.address(), + // No swaps have happened yet, so the admin has no fee claim. These + // accumulators are written by `swap_tokens` and zeroed by + // `claim_admin_fees`. + admin_fees_owed_a: 0, + admin_fees_owed_b: 0, }); Ok(()) } diff --git a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs index 3fd10c26..b476d2e3 100644 --- a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -1,24 +1,22 @@ use { crate::{ - state::{Amm, Pool}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, }; -/// Accounts for depositing liquidity into a pool. -/// -/// Seeds reference the amm, mint_a, and mint_b account addresses — these -/// must be provided as separate account inputs. +/// Seeds reference the `config`, `mint_a`, and `mint_b` account addresses, +/// which must be provided as separate account inputs. #[derive(Accounts)] -pub struct DepositLiquidity { - #[account(address = AmmPda::seeds())] - pub amm: Account, - #[account(address = PoolPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub pool: Account, +pub struct DepositLiquidityAccounts { + #[account(address = ConfigPda::seeds())] + pub config: Account, + #[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub pool_config: Account, /// Pool authority PDA. - #[account(address = PoolAuthorityPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] + #[account(address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()))] pub pool_authority: UncheckedAccount, /// Depositor (must be signer to authorise transfers). pub depositor: Signer, @@ -29,30 +27,30 @@ pub struct DepositLiquidity { /// with `Account` (it reads `T::BUMP_OFFSET`). SPL `Mint` doesn't /// implement `Discriminator`; `InterfaceAccount` takes the generic /// existing-account verifier path that doesn't need it. - #[account(mut, address = LiquidityMintPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub mint_liquidity: InterfaceAccount, + #[account(mut, address = LiquidityMintPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub liquidity_provider_mint: InterfaceAccount, pub mint_a: Account, pub mint_b: Account, - /// Pool's token A vault. + /// Pool's token A reserve. #[account(mut)] - pub pool_account_a: Account, - /// Pool's token B vault. + pub pool_a: Account, + /// Pool's token B reserve. #[account(mut)] - pub pool_account_b: Account, + pub pool_b: Account, /// Depositor's LP token account. #[account( mut, init(idempotent), payer = payer, - token(mint = mint_liquidity, authority = depositor, token_program = token_program), + token(mint = liquidity_provider_mint, authority = depositor, token_program = token_program), )] - pub depositor_account_liquidity: Account, + pub liquidity_provider_token: Account, /// Depositor's token A account. #[account(mut)] - pub depositor_account_a: Account, + pub token_a: Account, /// Depositor's token B account. #[account(mut)] - pub depositor_account_b: Account, + pub token_b: Account, #[account(mut)] pub payer: Signer, pub token_program: Program, @@ -75,19 +73,37 @@ fn isqrt(n: u128) -> u64 { #[inline(always)] pub fn handle_deposit_liquidity( - accounts: &mut DepositLiquidity, + accounts: &mut DepositLiquidityAccounts, amount_a: u64, amount_b: u64, - bumps: &DepositLiquidityBumps, + bumps: &DepositLiquidityAccountsBumps, ) -> Result<(), ProgramError> { - // Clamp to what the depositor actually has. - let depositor_a = accounts.depositor_account_a.amount(); - let depositor_b = accounts.depositor_account_b.amount(); - let mut amount_a = if amount_a > depositor_a { depositor_a } else { amount_a }; - let mut amount_b = if amount_b > depositor_b { depositor_b } else { amount_b }; + // Fail fast if the depositor lacks the requested balance. Never silently + // clamp to the available balance: callers expect their requested amount to + // be the amount actually deposited (slippage logic builds on top of it). + let depositor_a = accounts.token_a.amount(); + let depositor_b = accounts.token_b.amount(); + if amount_a > depositor_a || amount_b > depositor_b { + return Err(ProgramError::InsufficientFunds); + } + let mut amount_a = amount_a; + let mut amount_b = amount_b; - let pool_a_amount = accounts.pool_account_a.amount(); - let pool_b_amount = accounts.pool_account_b.amount(); + // LP curve runs on *effective* reserves (vault balance minus admin's + // accumulated fee claim). The admin's owed slice is a fixed obligation, + // not LP-claimable capital, so it must not affect the deposit ratio. + // checked_sub: a raw `-` would wrap silently on a BPF release build if the + // owed slice ever exceeded the vault balance. + let pool_a_amount = accounts + .pool_a + .amount() + .checked_sub(accounts.pool_config.admin_fees_owed_a()) + .ok_or(ProgramError::ArithmeticOverflow)?; + let pool_b_amount = accounts + .pool_b + .amount() + .checked_sub(accounts.pool_config.admin_fees_owed_b()) + .ok_or(ProgramError::ArithmeticOverflow)?; let pool_creation = pool_a_amount == 0 && pool_b_amount == 0; if !pool_creation { @@ -107,36 +123,61 @@ pub fn handle_deposit_liquidity( } } - // Compute liquidity = sqrt(amount_a * amount_b). - let product = (amount_a as u128) - .checked_mul(amount_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; - let mut liquidity = isqrt(product); - - // Lock minimum liquidity on first deposit. - if pool_creation { - if liquidity < crate::MINIMUM_LIQUIDITY { + // LP-mint math, two branches: + // - First deposit: liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY. The + // geometric mean bootstraps the pool; the locked floor is burned + // forever to prevent the first depositor draining the pool later. + // - Subsequent deposit: liquidity = min(a * supply / pool_a, + // b * supply / pool_b), proportional to the depositor's share of each + // reserve. Using sqrt(a * b) for *every* deposit (the previous + // behaviour) breaks proportionality on subsequent deposits. + let liquidity: u64 = if pool_creation { + let product = (amount_a as u128) + .checked_mul(amount_b as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let sqrt = isqrt(product); + if sqrt < crate::MINIMUM_LIQUIDITY { return Err(ProgramError::InsufficientFunds); } - liquidity -= crate::MINIMUM_LIQUIDITY; + sqrt.checked_sub(crate::MINIMUM_LIQUIDITY) + .ok_or(ProgramError::ArithmeticOverflow)? + } else { + let total_supply = accounts.liquidity_provider_mint.supply() as u128; + let from_a = (amount_a as u128) + .checked_mul(total_supply) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(pool_a_amount as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let from_b = (amount_b as u128) + .checked_mul(total_supply) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(pool_b_amount as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + u64::try_from(from_a.min(from_b)).map_err(|_| ProgramError::ArithmeticOverflow)? + }; + + // Reject deposits too small to mint any LP tokens (skill: never mint + // zero-priced shares). + if liquidity == 0 { + return Err(ProgramError::InsufficientFunds); } // Transfer token A to the pool. accounts.token_program - .transfer(&accounts.depositor_account_a, &accounts.pool_account_a, &accounts.depositor, amount_a) + .transfer(&accounts.token_a, &accounts.pool_a, &accounts.depositor, amount_a) .invoke()?; // Transfer token B to the pool. accounts.token_program - .transfer(&accounts.depositor_account_b, &accounts.pool_account_b, &accounts.depositor, amount_b) + .transfer(&accounts.token_b, &accounts.pool_b, &accounts.depositor, amount_b) .invoke()?; // Mint LP tokens to the depositor (signed by pool authority). - // Seed order matches PoolAuthorityPda: [b"authority", amm, mint_a, mint_b, bump]. + // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. let bump = [bumps.pool_authority]; let seeds: &[Seed] = &[ Seed::from(crate::AUTHORITY_SEED), - Seed::from(accounts.amm.address().as_ref()), + 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]), @@ -144,8 +185,8 @@ pub fn handle_deposit_liquidity( accounts.token_program .mint_to( - &accounts.mint_liquidity, - &accounts.depositor_account_liquidity, + &accounts.liquidity_provider_mint, + &accounts.liquidity_provider_token, &accounts.pool_authority, liquidity, ) diff --git a/tokens/token-swap/quasar/src/instructions/mod.rs b/tokens/token-swap/quasar/src/instructions/mod.rs index 3c822791..c0a9ab8c 100644 --- a/tokens/token-swap/quasar/src/instructions/mod.rs +++ b/tokens/token-swap/quasar/src/instructions/mod.rs @@ -1,11 +1,13 @@ -mod create_amm; +mod claim_admin_fees; +mod create_config; mod create_pool; mod deposit_liquidity; -mod swap_exact_tokens_for_tokens; +mod swap_tokens; mod withdraw_liquidity; -pub use create_amm::*; +pub use claim_admin_fees::*; +pub use create_config::*; pub use create_pool::*; pub use deposit_liquidity::*; -pub use swap_exact_tokens_for_tokens::*; +pub use swap_tokens::*; pub use withdraw_liquidity::*; diff --git a/tokens/token-swap/quasar/src/instructions/swap_exact_tokens_for_tokens.rs b/tokens/token-swap/quasar/src/instructions/swap_exact_tokens_for_tokens.rs deleted file mode 100644 index 220f50b1..00000000 --- a/tokens/token-swap/quasar/src/instructions/swap_exact_tokens_for_tokens.rs +++ /dev/null @@ -1,150 +0,0 @@ -use { - crate::{ - state::{Amm, Pool}, - AmmPda, PoolAuthorityPda, PoolPda, - }, - quasar_lang::prelude::*, - quasar_spl::prelude::*, -}; - -/// Accounts for swapping tokens using the constant-product formula. -#[derive(Accounts)] -pub struct SwapExactTokensForTokens { - #[account(address = AmmPda::seeds())] - pub amm: Account, - #[account(address = PoolPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub pool: Account, - /// Pool authority PDA. - #[account(address = PoolAuthorityPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub pool_authority: UncheckedAccount, - pub trader: Signer, - pub mint_a: Account, - pub mint_b: Account, - #[account(mut)] - pub pool_account_a: Account, - #[account(mut)] - pub pool_account_b: Account, - #[account( - mut, - init(idempotent), - payer = payer, - token(mint = mint_a, authority = trader, token_program = token_program), - )] - pub trader_account_a: Account, - #[account( - mut, - init(idempotent), - payer = payer, - token(mint = mint_b, authority = trader, token_program = token_program), - )] - pub trader_account_b: Account, - #[account(mut)] - pub payer: Signer, - pub token_program: Program, - pub system_program: Program, -} - -#[inline(always)] -pub fn handle_swap_exact_tokens_for_tokens( - accounts: &mut SwapExactTokensForTokens, - swap_a: bool, - input_amount: u64, - min_output_amount: u64, - bumps: &SwapExactTokensForTokensBumps, -) -> Result<(), ProgramError> { - // Clamp input to what the trader has. - let input = if swap_a { - let trader_a = accounts.trader_account_a.amount(); - if input_amount > trader_a { trader_a } else { input_amount } - } else { - let trader_b = accounts.trader_account_b.amount(); - if input_amount > trader_b { trader_b } else { input_amount } - }; - - // Apply fee. - let fee = accounts.amm.fee.get() as u64; - let taxed_input = input - input * fee / 10000; - - // Constant-product formula: output = taxed_input * pool_out / (pool_in + taxed_input) - let pool_a = accounts.pool_account_a.amount(); - let pool_b = accounts.pool_account_b.amount(); - - let output = if swap_a { - (taxed_input as u128) - .checked_mul(pool_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)? - .checked_div( - (pool_a as u128) - .checked_add(taxed_input as u128) - .ok_or(ProgramError::ArithmeticOverflow)?, - ) - .ok_or(ProgramError::ArithmeticOverflow)? as u64 - } else { - (taxed_input as u128) - .checked_mul(pool_a as u128) - .ok_or(ProgramError::ArithmeticOverflow)? - .checked_div( - (pool_b as u128) - .checked_add(taxed_input as u128) - .ok_or(ProgramError::ArithmeticOverflow)?, - ) - .ok_or(ProgramError::ArithmeticOverflow)? as u64 - }; - - if output < min_output_amount { - return Err(ProgramError::Custom(4)); // OutputTooSmall - } - - // Record invariant before the trade. - let invariant = (pool_a as u128) - .checked_mul(pool_b as u128) - .ok_or(ProgramError::ArithmeticOverflow)?; - - // Build authority signer seeds. - // Seed order matches PoolAuthorityPda: [b"authority", amm, mint_a, mint_b, bump]. - let bump = [bumps.pool_authority]; - let seeds: &[Seed] = &[ - Seed::from(crate::AUTHORITY_SEED), - Seed::from(accounts.amm.address().as_ref()), - Seed::from(accounts.mint_a.address().as_ref()), - Seed::from(accounts.mint_b.address().as_ref()), - Seed::from(&bump as &[u8]), - ]; - - if swap_a { - // Trader sends token A to pool. - accounts.token_program - .transfer(&accounts.trader_account_a, &accounts.pool_account_a, &accounts.trader, input) - .invoke()?; - // Pool sends token B to trader (signed). - accounts.token_program - .transfer(&accounts.pool_account_b, &accounts.trader_account_b, &accounts.pool_authority, output) - .invoke_signed(seeds)?; - } else { - // Pool sends token A to trader (signed). - accounts.token_program - .transfer(&accounts.pool_account_a, &accounts.trader_account_a, &accounts.pool_authority, output) - .invoke_signed(seeds)?; - // Trader sends token B to pool. - accounts.token_program - .transfer(&accounts.trader_account_b, &accounts.pool_account_b, &accounts.trader, input) - .invoke()?; - } - - // Verify invariant holds (new product >= old product). - let new_pool_a = pool_a as u128 - + if swap_a { input as u128 } else { 0 } - - if !swap_a { output as u128 } else { 0 }; - let new_pool_b = pool_b as u128 - + if !swap_a { input as u128 } else { 0 } - - if swap_a { output as u128 } else { 0 }; - let new_invariant = new_pool_a - .checked_mul(new_pool_b) - .ok_or(ProgramError::ArithmeticOverflow)?; - - if new_invariant < invariant { - return Err(ProgramError::Custom(5)); // InvariantViolated - } - - Ok(()) -} diff --git a/tokens/token-swap/quasar/src/instructions/swap_tokens.rs b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs new file mode 100644 index 00000000..897a9016 --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs @@ -0,0 +1,233 @@ +use { + crate::{ + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, PoolAuthorityPda, PoolPda, BASIS_POINTS_DIVISOR, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// `pool_config` is mutable because each swap accumulates the admin's slice +/// of the trading fee into `admin_fees_owed_a` / `admin_fees_owed_b`. +#[derive(Accounts)] +pub struct SwapTokensAccounts { + #[account(address = ConfigPda::seeds())] + pub config: Account, + #[account( + mut, + address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()), + )] + pub pool_config: Account, + /// Pool authority PDA. + #[account(address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub pool_authority: UncheckedAccount, + pub trader: Signer, + pub mint_a: Account, + pub mint_b: Account, + #[account(mut)] + pub pool_a: Account, + #[account(mut)] + pub pool_b: Account, + #[account( + mut, + init(idempotent), + payer = payer, + token(mint = mint_a, authority = trader, token_program = token_program), + )] + pub token_a: Account, + #[account( + mut, + init(idempotent), + payer = payer, + token(mint = mint_b, authority = trader, token_program = token_program), + )] + pub token_b: Account, + #[account(mut)] + pub payer: Signer, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_swap_tokens( + accounts: &mut SwapTokensAccounts, + input_is_token_a: bool, + input_amount: u64, + min_output_amount: u64, + bumps: &SwapTokensAccountsBumps, +) -> Result<(), ProgramError> { + // Never silently clamp the input to the trader's balance: the trader's + // min_output_amount is computed against the input they requested, so + // clamping would let the swap fill at terms they never agreed to. Fail + // fast instead and let the client re-quote. + let trader_balance = if input_is_token_a { + accounts.token_a.amount() + } else { + accounts.token_b.amount() + }; + if input_amount > trader_balance { + return Err(ProgramError::InsufficientFunds); + } + let input = input_amount; + + // Split the trading fee between LPs and the admin. + // fee_amount = total fee charged on the input side + // admin_portion = admin's slice (accumulates as a virtual claim) + // lp_portion = fee_amount - admin_portion (stays in the reserves, + // boosting LP yield) + // The admin's slice is *not* transferred immediately; it bumps + // `pool_config.admin_fees_owed_` and is swept later by + // `claim_admin_fees`. This saves a CPI per swap. u128 + checked: the + // intermediate `input * fee` can overflow u64; multiply before divide. + let fee = accounts.config.fee() as u128; + let admin_share_bps = accounts.config.admin_share_bps() as u128; + let fee_amount = (input as u128) + .checked_mul(fee) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(BASIS_POINTS_DIVISOR as u128) + .ok_or(ProgramError::ArithmeticOverflow)? as u64; + let admin_portion = (fee_amount as u128) + .checked_mul(admin_share_bps) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(BASIS_POINTS_DIVISOR as u128) + .ok_or(ProgramError::ArithmeticOverflow)? as u64; + let taxed_input = input + .checked_sub(fee_amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + + // Effective reserves = raw vault balance - admin's accumulated claim. + // The constant-product curve runs on the LP-claimable portion only, so + // the admin's outstanding fees do not contribute to LP yield and do not + // distort the price. checked_sub: a raw `-` would wrap silently on a BPF + // release build if the owed slice ever exceeded the vault balance. + let pool_a_raw = accounts.pool_a.amount(); + let pool_b_raw = accounts.pool_b.amount(); + let owed_a = accounts.pool_config.admin_fees_owed_a(); + let owed_b = accounts.pool_config.admin_fees_owed_b(); + let effective_pool_a = pool_a_raw + .checked_sub(owed_a) + .ok_or(ProgramError::ArithmeticOverflow)?; + let effective_pool_b = pool_b_raw + .checked_sub(owed_b) + .ok_or(ProgramError::ArithmeticOverflow)?; + + let output = if input_is_token_a { + (taxed_input as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div( + (effective_pool_a as u128) + .checked_add(taxed_input as u128) + .ok_or(ProgramError::ArithmeticOverflow)?, + ) + .ok_or(ProgramError::ArithmeticOverflow)? as u64 + } else { + (taxed_input as u128) + .checked_mul(effective_pool_a as u128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div( + (effective_pool_b as u128) + .checked_add(taxed_input as u128) + .ok_or(ProgramError::ArithmeticOverflow)?, + ) + .ok_or(ProgramError::ArithmeticOverflow)? as u64 + }; + + if output < min_output_amount { + return Err(ProgramError::Custom(4)); // OutputTooSmall + } + + // Record invariant on the *effective* reserves before the trade. Using + // raw balances would let the admin's accumulated fees count toward LP + // yield (wrong). + let invariant = (effective_pool_a as u128) + .checked_mul(effective_pool_b as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + + // Effects (Checks-Effects-Interactions): accumulate the admin's slice on + // the *input* side before any transfer CPI. The fee always comes off the + // input, so the admin's claim grows in the input token. Writing state + // before the interactions is the safe ordering - a failed CPI reverts the + // whole transaction, so the accumulator update can never outlive a failed + // transfer. + let (new_owed_a, new_owed_b) = if input_is_token_a { + ( + owed_a.checked_add(admin_portion).ok_or(ProgramError::ArithmeticOverflow)?, + owed_b, + ) + } else { + ( + owed_a, + owed_b.checked_add(admin_portion).ok_or(ProgramError::ArithmeticOverflow)?, + ) + }; + let config_addr = *accounts.pool_config.config(); + let mint_a_addr = *accounts.pool_config.mint_a(); + let mint_b_addr = *accounts.pool_config.mint_b(); + accounts.pool_config.set_inner(PoolConfigInner { + config: config_addr, + mint_a: mint_a_addr, + mint_b: mint_b_addr, + admin_fees_owed_a: new_owed_a, + admin_fees_owed_b: new_owed_b, + }); + + // Interactions: the token transfers, after state is written. + // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. + let bump = [bumps.pool_authority]; + let seeds: &[Seed] = &[ + Seed::from(crate::AUTHORITY_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]), + ]; + + if input_is_token_a { + // Trader sends token A to pool. + accounts.token_program + .transfer(&accounts.token_a, &accounts.pool_a, &accounts.trader, input) + .invoke()?; + // Pool sends token B to trader (signed). + accounts.token_program + .transfer(&accounts.pool_b, &accounts.token_b, &accounts.pool_authority, output) + .invoke_signed(seeds)?; + } else { + // Pool sends token A to trader (signed). + accounts.token_program + .transfer(&accounts.pool_a, &accounts.token_a, &accounts.pool_authority, output) + .invoke_signed(seeds)?; + // Trader sends token B to pool. + accounts.token_program + .transfer(&accounts.token_b, &accounts.pool_b, &accounts.trader, input) + .invoke()?; + } + + // Verify invariant holds on the LP-claimable (effective) reserves. + // u128 + checked throughout - a raw `+`/`-` could wrap on extreme values. + let new_pool_a_raw = (pool_a_raw as u128) + .checked_add(if input_is_token_a { input as u128 } else { 0 }) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_sub(if !input_is_token_a { output as u128 } else { 0 }) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_pool_b_raw = (pool_b_raw as u128) + .checked_add(if !input_is_token_a { input as u128 } else { 0 }) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_sub(if input_is_token_a { output as u128 } else { 0 }) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_effective_a = new_pool_a_raw + .checked_sub(new_owed_a as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_effective_b = new_pool_b_raw + .checked_sub(new_owed_b as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_invariant = new_effective_a + .checked_mul(new_effective_b) + .ok_or(ProgramError::ArithmeticOverflow)?; + + if new_invariant < invariant { + return Err(ProgramError::Custom(5)); // InvariantViolated + } + + Ok(()) +} diff --git a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs index 1b23369e..c5354245 100644 --- a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -1,21 +1,20 @@ use { crate::{ - state::{Amm, Pool}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, }; -/// Accounts for withdrawing liquidity from a pool. #[derive(Accounts)] -pub struct WithdrawLiquidity { - #[account(address = AmmPda::seeds())] - pub amm: Account, - #[account(address = PoolPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub pool: Account, +pub struct WithdrawLiquidityAccounts { + #[account(address = ConfigPda::seeds())] + pub config: Account, + #[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub pool_config: Account, /// Pool authority PDA. - #[account(address = PoolAuthorityPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] + #[account(address = PoolAuthorityPda::seeds(config.address(), mint_a.address(), mint_b.address()))] pub pool_authority: UncheckedAccount, pub depositor: Signer, /// LP mint at the LiquidityMintPda. @@ -25,32 +24,32 @@ pub struct WithdrawLiquidity { /// with `Account` (it reads `T::BUMP_OFFSET`). SPL `Mint` doesn't /// implement `Discriminator`; `InterfaceAccount` takes the generic /// existing-account verifier path that doesn't need it. - #[account(mut, address = LiquidityMintPda::seeds(amm.address(), mint_a.address(), mint_b.address()))] - pub mint_liquidity: InterfaceAccount, + #[account(mut, address = LiquidityMintPda::seeds(config.address(), mint_a.address(), mint_b.address()))] + pub liquidity_provider_mint: InterfaceAccount, #[account(mut)] pub mint_a: Account, #[account(mut)] pub mint_b: Account, #[account(mut)] - pub pool_account_a: Account, + pub pool_a: Account, #[account(mut)] - pub pool_account_b: Account, + pub pool_b: Account, #[account(mut)] - pub depositor_account_liquidity: Account, + pub liquidity_provider_token: Account, #[account( mut, init(idempotent), payer = payer, token(mint = mint_a, authority = depositor, token_program = token_program), )] - pub depositor_account_a: Account, + pub token_a: Account, #[account( mut, init(idempotent), payer = payer, token(mint = mint_b, authority = depositor, token_program = token_program), )] - pub depositor_account_b: Account, + pub token_b: Account, #[account(mut)] pub payer: Signer, pub token_program: Program, @@ -59,48 +58,68 @@ pub struct WithdrawLiquidity { #[inline(always)] pub fn handle_withdraw_liquidity( - accounts: &mut WithdrawLiquidity, + accounts: &mut WithdrawLiquidityAccounts, amount: u64, - bumps: &WithdrawLiquidityBumps, + bumps: &WithdrawLiquidityAccountsBumps, ) -> Result<(), ProgramError> { - // Seed order matches PoolAuthorityPda: [b"authority", amm, mint_a, mint_b, bump]. + // Seed order matches PoolAuthorityPda: [b"authority", config, mint_a, mint_b, bump]. let bump = [bumps.pool_authority]; let seeds: &[Seed] = &[ Seed::from(crate::AUTHORITY_SEED), - Seed::from(accounts.amm.address().as_ref()), + 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]), ]; - // Compute proportional amounts. - let total_liquidity = accounts.mint_liquidity.supply() + crate::MINIMUM_LIQUIDITY; + // Compute proportional amounts. LPs withdraw a share of the *effective* + // reserves (vault balance minus the admin's accumulated fee claim). + // The admin's owed slice physically stays in the vaults but is not + // distributed to exiting LPs - it's swept separately via + // `claim_admin_fees`. + // checked_sub: admin_fees_owed is an invariant subset of the vault balance; + // a raw `-` would wrap silently on a BPF release build if that ever broke. + let effective_pool_a = accounts + .pool_a + .amount() + .checked_sub(accounts.pool_config.admin_fees_owed_a()) + .ok_or(ProgramError::ArithmeticOverflow)?; + let effective_pool_b = accounts + .pool_b + .amount() + .checked_sub(accounts.pool_config.admin_fees_owed_b()) + .ok_or(ProgramError::ArithmeticOverflow)?; + let total_liquidity = accounts + .liquidity_provider_mint + .supply() + .checked_add(crate::MINIMUM_LIQUIDITY) + .ok_or(ProgramError::ArithmeticOverflow)?; let amount_a = (amount as u128) - .checked_mul(accounts.pool_account_a.amount() as u128) + .checked_mul(effective_pool_a as u128) .ok_or(ProgramError::ArithmeticOverflow)? .checked_div(total_liquidity as u128) .ok_or(ProgramError::ArithmeticOverflow)? as u64; let amount_b = (amount as u128) - .checked_mul(accounts.pool_account_b.amount() as u128) + .checked_mul(effective_pool_b as u128) .ok_or(ProgramError::ArithmeticOverflow)? .checked_div(total_liquidity as u128) .ok_or(ProgramError::ArithmeticOverflow)? as u64; // Transfer token A from pool to depositor. accounts.token_program - .transfer(&accounts.pool_account_a, &accounts.depositor_account_a, &accounts.pool_authority, amount_a) + .transfer(&accounts.pool_a, &accounts.token_a, &accounts.pool_authority, amount_a) .invoke_signed(seeds)?; // Transfer token B from pool to depositor. accounts.token_program - .transfer(&accounts.pool_account_b, &accounts.depositor_account_b, &accounts.pool_authority, amount_b) + .transfer(&accounts.pool_b, &accounts.token_b, &accounts.pool_authority, amount_b) .invoke_signed(seeds)?; // Burn LP tokens. accounts.token_program - .burn(&accounts.depositor_account_liquidity, &accounts.mint_liquidity, &accounts.depositor, amount) + .burn(&accounts.liquidity_provider_token, &accounts.liquidity_provider_mint, &accounts.depositor, amount) .invoke()?; Ok(()) diff --git a/tokens/token-swap/quasar/src/lib.rs b/tokens/token-swap/quasar/src/lib.rs index af911627..ba3271de 100644 --- a/tokens/token-swap/quasar/src/lib.rs +++ b/tokens/token-swap/quasar/src/lib.rs @@ -12,6 +12,12 @@ declare_id!("22222222222222222222222222222222222222222222"); /// Minimum liquidity locked on first deposit 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 +/// fraction. Keeps the bare 10_000 out of the math. +pub const BASIS_POINTS_DIVISOR: u64 = 10_000; +/// Seed for the global Config PDA (singleton). +pub const CONFIG_SEED: &[u8] = b"config"; /// Seed for the pool authority PDA. pub const AUTHORITY_SEED: &[u8] = b"authority"; /// Seed for the liquidity mint PDA. @@ -21,62 +27,64 @@ pub const LIQUIDITY_SEED: &[u8] = b"liquidity"; // Each marker captures the prefix and Address args; `address = T::seeds(...)` // drives derivation in the `#[account]` constraint. -/// AMM PDA at seeds = [b"amm"]. +/// Singleton `Config` PDA at seeds = [b"config"]. One per deployed program. #[derive(Seeds)] -#[seeds(b"amm")] -pub struct AmmPda; +#[seeds(b"config")] +pub struct ConfigPda; -/// Pool PDA at seeds = [amm, mint_a, mint_b] — no string prefix. +/// `PoolConfig` PDA at seeds = [config, mint_a, mint_b] — no string prefix. #[derive(Seeds)] -#[seeds(b"", amm: Address, mint_a: Address, mint_b: Address)] +#[seeds(b"", config: Address, mint_a: Address, mint_b: Address)] pub struct PoolPda; -/// Pool-authority PDA at seeds = [amm, mint_a, mint_b, b"authority"]. +/// Pool-authority PDA at seeds = [config, mint_a, mint_b, b"authority"]. /// Modelled with prefix b"authority" + the three Address args; the -/// rendered slice list ends up [amm, mint_a, mint_b, b"authority"] when +/// rendered slice list ends up [config, mint_a, mint_b, b"authority"] when /// you use `with_bump`. Note: the new \`#[seeds]\` puts the literal /// prefix first, so the on-chain derivation order is -/// [b"authority", amm, mint_a, mint_b] — different from the original +/// [b"authority", config, mint_a, mint_b] — different from the original /// Anchor scheme. Programs are independent so this is consistent and /// correct on its own; the addresses just won't match the Anchor copy. #[derive(Seeds)] -#[seeds(b"authority", amm: Address, mint_a: Address, mint_b: Address)] +#[seeds(b"authority", config: Address, mint_a: Address, mint_b: Address)] pub struct PoolAuthorityPda; -/// Liquidity-mint PDA at seeds = [b"liquidity", amm, mint_a, mint_b]. +/// Liquidity-mint PDA at seeds = [b"liquidity", config, mint_a, mint_b]. #[derive(Seeds)] -#[seeds(b"liquidity", amm: Address, mint_a: Address, mint_b: Address)] +#[seeds(b"liquidity", config: Address, mint_a: Address, mint_b: Address)] pub struct LiquidityMintPda; /// Simple constant-product AMM (token swap). /// -/// Five instructions: -/// 1. `create_amm` — register a new AMM with admin + fee +/// Six instructions: +/// 1. `create_config` — initialise the singleton AMM config (admin, fee, +/// admin share) /// 2. `create_pool` — create a liquidity pool for a token pair /// 3. `deposit_liquidity` — add liquidity and receive LP tokens /// 4. `withdraw_liquidity` — burn LP tokens and receive pool tokens -/// 5. `swap_exact_tokens_for_tokens` — swap one token for another +/// 5. `swap_tokens` — swap one token for another +/// 6. `claim_admin_fees` — admin sweeps accumulated fee slice from a pool #[program] mod quasar_token_swap { use super::*; #[instruction(discriminator = 0)] - pub fn create_amm( - ctx: Ctx, - id: Address, + pub fn create_config( + ctx: Ctx, fee: u16, + admin_share_bps: u16, ) -> Result<(), ProgramError> { - instructions::handle_create_amm(&mut ctx.accounts, id, fee) + instructions::handle_create_config(&mut ctx.accounts, fee, admin_share_bps) } #[instruction(discriminator = 1)] - pub fn create_pool(ctx: Ctx) -> Result<(), ProgramError> { + pub fn create_pool(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_create_pool(&mut ctx.accounts) } #[instruction(discriminator = 2)] pub fn deposit_liquidity( - ctx: Ctx, + ctx: Ctx, amount_a: u64, amount_b: u64, ) -> Result<(), ProgramError> { @@ -85,25 +93,32 @@ mod quasar_token_swap { #[instruction(discriminator = 3)] pub fn withdraw_liquidity( - ctx: Ctx, + ctx: Ctx, amount: u64, ) -> Result<(), ProgramError> { instructions::handle_withdraw_liquidity(&mut ctx.accounts, amount, &ctx.bumps) } #[instruction(discriminator = 4)] - pub fn swap_exact_tokens_for_tokens( - ctx: Ctx, - swap_a: bool, + pub fn swap_tokens( + ctx: Ctx, + input_is_token_a: bool, input_amount: u64, min_output_amount: u64, ) -> Result<(), ProgramError> { - instructions::handle_swap_exact_tokens_for_tokens( + instructions::handle_swap_tokens( &mut ctx.accounts, - swap_a, + input_is_token_a, input_amount, min_output_amount, &ctx.bumps, ) } + + #[instruction(discriminator = 5)] + pub fn claim_admin_fees( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::handle_claim_admin_fees(&mut ctx.accounts, &ctx.bumps) + } } diff --git a/tokens/token-swap/quasar/src/state.rs b/tokens/token-swap/quasar/src/state.rs index 1e880875..0c6f83e4 100644 --- a/tokens/token-swap/quasar/src/state.rs +++ b/tokens/token-swap/quasar/src/state.rs @@ -1,25 +1,54 @@ use quasar_lang::prelude::*; -/// Automated Market Maker configuration. +/// Shared configuration for the AMM (admin + trading fee). /// -/// Stores the AMM identifier, admin, and fee (in basis points). +/// `Config` is a singleton: one account per deployed program, seeded by the +/// fixed byte string `b"config"`. This mirrors how real DEXs are deployed in +/// practice (e.g. Phoenix and Raydium ship one program per market/AMM, so the +/// program-level config is global by construction). Every `PoolConfig` +/// references this `Config` for fee/admin parameters. #[account(discriminator = 100, set_inner)] -pub struct Amm { - /// Unique identifier for this AMM. - pub id: Address, +pub struct Config { /// Admin authority. pub admin: Address, - /// LP fee in basis points (e.g. 30 = 0.3%). + /// Total trading fee in basis points (e.g. 30 = 0.3%). Split between LPs + /// and the admin according to `admin_share_bps`. pub fee: u16, + /// Fraction of the trading fee that goes to the admin, in basis points + /// (out of 10_000). The remainder goes to LPs (it stays in the pool + /// reserves and grows the LP-claimable balance). Must be < 10_000. + pub admin_share_bps: u16, } -/// Liquidity pool linking an AMM to a pair of token mints. +/// Per-pool configuration / identity record linking an AMM `Config` to a pair +/// of token mints. +/// +/// Holds the metadata that identifies a single pool: which `Config` it belongs +/// to and which two mints it trades. The actual pool reserves live in separate +/// token accounts (`pool_a`, `pool_b`) owned by the pool authority PDA — they +/// are not stored here. This struct is the pool's *configuration*, not its +/// state. +/// +/// Also tracks the admin's accumulated trading-fee claim per side +/// (`admin_fees_owed_a` / `admin_fees_owed_b`). Those amounts physically sit +/// in the existing `pool_a` / `pool_b` reserves; the accumulators are a +/// *virtual* obligation against those balances. LP-facing math (deposit, +/// withdraw, swap curve) uses `pool_X.amount() - admin_fees_owed_X` so the +/// admin's owed slice is not counted toward LP yield. #[account(discriminator = 101, set_inner)] -pub struct Pool { - /// The AMM this pool belongs to. - pub amm: Address, +pub struct PoolConfig { + /// Address of the parent `Config` account this pool belongs to. + pub config: Address, /// Mint of token A. pub mint_a: Address, /// Mint of token B. pub mint_b: Address, + /// Admin's accumulated fee claim on token A, in base units. Sits + /// physically in `pool_a` but excluded from the LP curve and from + /// LP-withdrawable amounts. Swept by `claim_admin_fees`. + pub admin_fees_owed_a: u64, + /// Admin's accumulated fee claim on token B, in base units. Sits + /// physically in `pool_b` but excluded from the LP curve and from + /// LP-withdrawable amounts. Swept by `claim_admin_fees`. + pub admin_fees_owed_b: u64, } diff --git a/tokens/token-swap/quasar/src/tests.rs b/tokens/token-swap/quasar/src/tests.rs index 0598f77a..ddc0989a 100644 --- a/tokens/token-swap/quasar/src/tests.rs +++ b/tokens/token-swap/quasar/src/tests.rs @@ -1,10 +1,15 @@ extern crate std; use { alloc::vec, - quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + quasar_svm::{ + token::{create_keyed_associated_token_account, create_keyed_mint_account, Mint}, + Account, Instruction, Pubkey, QuasarSvm, SPL_TOKEN_PROGRAM_ID, + }, std::println, }; +// ── SVM setup ──────────────────────────────────────────────────────────────── + fn setup() -> QuasarSvm { let elf = std::fs::read("target/deploy/quasar_token_swap.so").unwrap(); QuasarSvm::new() @@ -12,6 +17,8 @@ fn setup() -> QuasarSvm { .with_token_program() } +// ── Account factories ───────────────────────────────────────────────────────── + fn signer(address: Pubkey) -> Account { quasar_svm::token::create_keyed_system_account(&address, 10_000_000_000) } @@ -26,84 +33,793 @@ fn empty(address: Pubkey) -> Account { } } -fn build_create_amm_data(id: &Pubkey, fee: u16) -> Vec { - let mut data = vec![0u8]; // discriminator - data.extend_from_slice(id.as_ref()); +/// Pre-initialised SPL mint with no authority and no supply. +fn test_mint(addr: Pubkey, decimals: u8) -> Account { + create_keyed_mint_account( + &addr, + &Mint { + is_initialized: true, + decimals, + ..Mint::default() + }, + ) +} + +/// Depositor's pre-funded ATA (address derived from wallet + mint). +fn funded_ata(wallet: Pubkey, mint: Pubkey, amount: u64) -> Account { + create_keyed_associated_token_account(&wallet, &mint, amount) +} + +/// ATA address derived from wallet + mint (same formula as SPL ATA program). +fn ata_addr(wallet: Pubkey, mint: Pubkey) -> Pubkey { + create_keyed_associated_token_account(&wallet, &mint, 0).address +} + +/// Read the `amount` field (bytes 64–72) from a packed token account. +fn token_amount(account: &Account) -> u64 { + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +// ── PDA helpers ─────────────────────────────────────────────────────────────── + +fn config_pda() -> Pubkey { + Pubkey::find_program_address(&[b"config"], &crate::ID.into()).0 +} + +fn pool_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[b"", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], + &crate::ID.into(), + ) + .0 +} + +fn pool_authority_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[b"authority", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], + &crate::ID.into(), + ) + .0 +} + +fn lp_mint_pda(config: Pubkey, mint_a: Pubkey, mint_b: Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[b"liquidity", config.as_ref(), mint_a.as_ref(), mint_b.as_ref()], + &crate::ID.into(), + ) + .0 +} + +// ── Instruction data builders ───────────────────────────────────────────────── + +fn build_create_config_data(fee: u16, admin_share_bps: u16) -> Vec { + let mut data = vec![0u8]; // discriminator = 0 data.extend_from_slice(&fee.to_le_bytes()); + data.extend_from_slice(&admin_share_bps.to_le_bytes()); data } -#[test] -fn test_create_amm() { - let mut svm = setup(); +fn build_deposit_data(amount_a: u64, amount_b: u64) -> Vec { + let mut data = vec![2u8]; // discriminator = 2 + data.extend_from_slice(&amount_a.to_le_bytes()); + data.extend_from_slice(&amount_b.to_le_bytes()); + data +} + +fn build_withdraw_data(amount: u64) -> Vec { + let mut data = vec![3u8]; // discriminator = 3 + data.extend_from_slice(&amount.to_le_bytes()); + data +} +fn build_swap_data(input_is_token_a: bool, input_amount: u64, min_output: u64) -> Vec { + let mut data = vec![4u8]; // discriminator = 4 + data.push(input_is_token_a as u8); + data.extend_from_slice(&input_amount.to_le_bytes()); + data.extend_from_slice(&min_output.to_le_bytes()); + data +} + +// ── Instruction builders ────────────────────────────────────────────────────── + +fn ix_create_config(config: Pubkey, admin: Pubkey, payer: Pubkey, fee: u16, admin_share: u16) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(config.into(), false), + solana_instruction::AccountMeta::new_readonly(admin.into(), false), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + ], + data: build_create_config_data(fee, admin_share), + } +} + +fn ix_create_pool( + config: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + lp_mint: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, + payer: Pubkey, +) -> Instruction { + let rent_id = quasar_svm::solana_sdk_ids::sysvar::rent::ID; + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(config.into(), false), + solana_instruction::AccountMeta::new(pool_config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), + solana_instruction::AccountMeta::new(lp_mint.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), + // pool_a and pool_b are non-PDA token accounts created via + // system::create_account CPI, which requires the `to` account to + // be a signer in the parent transaction (signers=[]). + solana_instruction::AccountMeta::new(pool_a.into(), true), + solana_instruction::AccountMeta::new(pool_b.into(), true), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + solana_instruction::AccountMeta::new_readonly(rent_id.into(), false), + ], + data: vec![1u8], // discriminator = 1 + } +} + +fn ix_deposit( + config: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + depositor: Pubkey, + lp_mint: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, + lp_token: Pubkey, + token_a: Pubkey, + token_b: Pubkey, + payer: Pubkey, + amount_a: u64, + amount_b: u64, +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), + solana_instruction::AccountMeta::new_readonly(depositor.into(), true), + solana_instruction::AccountMeta::new(lp_mint.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), + solana_instruction::AccountMeta::new(pool_a.into(), false), + solana_instruction::AccountMeta::new(pool_b.into(), false), + // lp_token is a non-PDA account created via system::create_account + // CPI; the `to` account must be a signer in the parent instruction. + solana_instruction::AccountMeta::new(lp_token.into(), true), + solana_instruction::AccountMeta::new(token_a.into(), false), + solana_instruction::AccountMeta::new(token_b.into(), false), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + ], + data: build_deposit_data(amount_a, amount_b), + } +} + +fn ix_withdraw( + config: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + depositor: Pubkey, + lp_mint: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, + lp_token: Pubkey, + token_a: Pubkey, + token_b: Pubkey, + payer: Pubkey, + amount: u64, +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), + solana_instruction::AccountMeta::new_readonly(depositor.into(), true), + solana_instruction::AccountMeta::new(lp_mint.into(), false), + solana_instruction::AccountMeta::new(mint_a.into(), false), + solana_instruction::AccountMeta::new(mint_b.into(), false), + solana_instruction::AccountMeta::new(pool_a.into(), false), + solana_instruction::AccountMeta::new(pool_b.into(), false), + solana_instruction::AccountMeta::new(lp_token.into(), false), + // token_a and token_b are non-PDA accounts created via + // system::create_account CPI; must be signers in parent. + solana_instruction::AccountMeta::new(token_a.into(), true), + solana_instruction::AccountMeta::new(token_b.into(), true), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + ], + data: build_withdraw_data(amount), + } +} + +fn ix_swap( + config: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + trader: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, + token_a: Pubkey, + token_b: Pubkey, + payer: Pubkey, + input_is_token_a: bool, + input_amount: u64, + min_output: u64, +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(config.into(), false), + solana_instruction::AccountMeta::new(pool_config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), + solana_instruction::AccountMeta::new_readonly(trader.into(), true), + solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), + solana_instruction::AccountMeta::new(pool_a.into(), false), + solana_instruction::AccountMeta::new(pool_b.into(), false), + // Both token accounts have init(idempotent); the output one is a + // non-PDA created via system CPI and needs to be a signer. + // Marking both is harmless since the SVM doesn't verify signatures. + solana_instruction::AccountMeta::new(token_a.into(), true), + solana_instruction::AccountMeta::new(token_b.into(), true), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + ], + data: build_swap_data(input_is_token_a, input_amount, min_output), + } +} + +fn ix_claim_fees( + config: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, + admin: Pubkey, + admin_token_a: Pubkey, + admin_token_b: Pubkey, +) -> Instruction { + Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new_readonly(config.into(), false), + solana_instruction::AccountMeta::new(pool_config.into(), false), + solana_instruction::AccountMeta::new_readonly(pool_authority.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_a.into(), false), + solana_instruction::AccountMeta::new_readonly(mint_b.into(), false), + solana_instruction::AccountMeta::new(pool_a.into(), false), + solana_instruction::AccountMeta::new(pool_b.into(), false), + solana_instruction::AccountMeta::new_readonly(admin.into(), true), + solana_instruction::AccountMeta::new(admin_token_a.into(), false), + solana_instruction::AccountMeta::new(admin_token_b.into(), false), + solana_instruction::AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![5u8], // discriminator = 5 + } +} + +// ── Shared pool environment ─────────────────────────────────────────────────── + +struct PoolEnv { + svm: QuasarSvm, + admin: Pubkey, + payer: Pubkey, + config: Pubkey, + mint_a: Pubkey, + mint_b: Pubkey, + pool_config: Pubkey, + pool_authority: Pubkey, + lp_mint: Pubkey, + pool_a: Pubkey, + pool_b: Pubkey, +} + +/// Creates config + two mints + pool and commits everything to the SVM. +fn setup_pool() -> PoolEnv { + let mut svm = setup(); let payer = Pubkey::new_unique(); let admin = Pubkey::new_unique(); - let amm_id = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - // Derive the AMM PDA - let (amm_pda, _) = Pubkey::find_program_address(&[b"amm"], &crate::ID.into()); + // create_config + let config = config_pda(); + let r = svm.process_instruction( + &ix_create_config(config, admin, payer, 30, 1_667), + &[empty(config), empty(admin), signer(payer)], + ); + assert!(r.is_ok(), "setup_pool/create_config: {:?}", r.raw_result); + + // Pre-populate mint accounts (no on-chain minting needed for tests). + let mint_a = Pubkey::new_unique(); + let mint_b = Pubkey::new_unique(); + svm.set_account(test_mint(mint_a, 6)); + svm.set_account(test_mint(mint_b, 6)); + + // Derive pool PDAs. + let pool_config = pool_pda(config, mint_a, mint_b); + let pool_authority = pool_authority_pda(config, mint_a, mint_b); + let lp_mint = lp_mint_pda(config, mint_a, mint_b); + // Pool token-A and token-B reserves live at arbitrary unique addresses. + let pool_a = Pubkey::new_unique(); + let pool_b = Pubkey::new_unique(); + + // create_pool — pass empty PDA slots (pool_config, lp_mint) and signer + // slots for non-PDA token accounts (pool_a, pool_b). The SVM commits + // all accounts from the merged list, so every new account must appear here. + let r = svm.process_instruction( + &ix_create_pool( + config, pool_config, pool_authority, lp_mint, + mint_a, mint_b, pool_a, pool_b, payer, + ), + &[ + empty(pool_config), + empty(pool_authority), + empty(lp_mint), + signer(pool_a), // non-PDA: needs signer status for create_account CPI + signer(pool_b), + signer(payer), + ], + ); + assert!(r.is_ok(), "setup_pool/create_pool: {:?}", r.raw_result); + + PoolEnv { svm, admin, payer, config, mint_a, mint_b, pool_config, pool_authority, lp_mint, pool_a, pool_b } +} + +/// Deposits `amount_a` / `amount_b` for a fresh depositor. Returns the +/// depositor's LP-token account address. +fn do_deposit(env: &mut PoolEnv, amount_a: u64, amount_b: u64) -> (Pubkey, Pubkey) { + let depositor = Pubkey::new_unique(); + + // Pre-fund the depositor's token accounts and commit them to the SVM so + // they're in the "merged" set and get committed after the instruction. + let ta = funded_ata(depositor, env.mint_a, amount_a); + let tb = funded_ata(depositor, env.mint_b, amount_b); + let token_a = ta.address; + let token_b = tb.address; + env.svm.set_account(ta); + env.svm.set_account(tb); - let data = build_create_amm_data(&amm_id, 30); + // LP token account will be created by init(idempotent) — pass as signer + // because system::create_account CPI requires the new account to sign. + let lp_token = Pubkey::new_unique(); + let r = env.svm.process_instruction( + &ix_deposit( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, token_a, token_b, env.payer, + amount_a, amount_b, + ), + &[signer(lp_token), signer(depositor)], + ); + assert!(r.is_ok(), "do_deposit: {:?}", r.raw_result); + + (depositor, lp_token) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — create_config (existing) +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_create_config() { + let mut svm = setup(); + let payer = Pubkey::new_unique(); + let admin = Pubkey::new_unique(); + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); + let data = build_create_config_data(30, 1667); let instruction = Instruction { program_id: crate::ID, accounts: vec![ - solana_instruction::AccountMeta::new(amm_pda.into(), false), + solana_instruction::AccountMeta::new(config_pda.into(), false), solana_instruction::AccountMeta::new_readonly(admin.into(), false), solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), ], data, }; - let result = svm.process_instruction( &instruction, - &[empty(amm_pda), signer(admin), signer(payer)], + &[empty(config_pda), signer(admin), signer(payer)], ); + assert!(result.is_ok(), "create_config failed: {:?}", result.raw_result); + println!(" CREATE CONFIG CU: {}", result.compute_units_consumed); +} - assert!( - result.is_ok(), - "create_amm failed: {:?}", - result.raw_result +#[test] +fn test_create_config_invalid_fee() { + let mut svm = setup(); + let payer = Pubkey::new_unique(); + let admin = Pubkey::new_unique(); + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); + let data = build_create_config_data(10000, 1667); // fee >= 10_000 → invalid + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + solana_instruction::AccountMeta::new(config_pda.into(), false), + solana_instruction::AccountMeta::new_readonly(admin.into(), false), + solana_instruction::AccountMeta::new(payer.into(), true), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), + ], + data, + }; + let result = svm.process_instruction( + &instruction, + &[empty(config_pda), signer(admin), signer(payer)], ); - println!(" CREATE AMM CU: {}", result.compute_units_consumed); + assert!(!result.is_ok(), "create_config should have failed with invalid fee"); + println!(" CREATE CONFIG (invalid fee) correctly rejected"); } #[test] -fn test_create_amm_invalid_fee() { +fn test_create_config_invalid_admin_share() { let mut svm = setup(); - let payer = Pubkey::new_unique(); let admin = Pubkey::new_unique(); - let amm_id = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - - let (amm_pda, _) = Pubkey::find_program_address(&[b"amm"], &crate::ID.into()); - - // Fee >= 10000 should fail. - let data = build_create_amm_data(&amm_id, 10000); - + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); + let data = build_create_config_data(30, 10000); // admin_share_bps >= 10_000 → invalid let instruction = Instruction { program_id: crate::ID, accounts: vec![ - solana_instruction::AccountMeta::new(amm_pda.into(), false), + solana_instruction::AccountMeta::new(config_pda.into(), false), solana_instruction::AccountMeta::new_readonly(admin.into(), false), solana_instruction::AccountMeta::new(payer.into(), true), - solana_instruction::AccountMeta::new_readonly(system_program.into(), false), + solana_instruction::AccountMeta::new_readonly(quasar_svm::system_program::ID.into(), false), ], data, }; - let result = svm.process_instruction( &instruction, - &[empty(amm_pda), signer(admin), signer(payer)], + &[empty(config_pda), signer(admin), signer(payer)], + ); + assert!(!result.is_ok(), "create_config should have failed with admin_share_bps >= 10000"); + println!(" CREATE CONFIG (invalid admin share) correctly rejected"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — create_pool +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_create_pool() { + let env = setup_pool(); + // The pool_config PDA must now exist and be owned by our program. + let pc = env.svm.get_account(&env.pool_config).expect("pool_config missing after create_pool"); + assert_eq!(pc.owner, env.svm.get_account(&env.pool_config).unwrap().owner); + // LP mint PDA must be a valid SPL mint (82 bytes, owned by token program). + let lp = env.svm.get_account(&env.lp_mint).expect("lp_mint missing"); + assert_eq!(lp.data.len(), 82, "LP mint should be 82 bytes"); + println!(" CREATE POOL: pool_config={}, lp_mint={}", env.pool_config, env.lp_mint); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — deposit_liquidity +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_deposit_liquidity_initial() { + let mut env = setup_pool(); + + let amount_a = 1_000_000u64; + let amount_b = 4_000_000u64; + + let (_depositor, lp_token) = do_deposit(&mut env, amount_a, amount_b); + + // LP token account must exist with a non-zero balance. + let lp_acct = env.svm.get_account(&lp_token).expect("lp_token missing after deposit"); + let lp_balance = token_amount(&lp_acct); + assert!(lp_balance > 0, "expected LP tokens, got 0"); + + // Pool reserves must have received the tokens. + let pa = env.svm.get_account(&env.pool_a).expect("pool_a missing"); + let pb = env.svm.get_account(&env.pool_b).expect("pool_b missing"); + assert_eq!(token_amount(&pa), amount_a); + assert_eq!(token_amount(&pb), amount_b); + + println!(" DEPOSIT: LP minted={}, pool_a={}, pool_b={}", lp_balance, amount_a, amount_b); +} + +#[test] +fn test_deposit_liquidity_subsequent_proportional() { + let mut env = setup_pool(); + + // Initial deposit: 1:4 ratio. + let (_, lp1) = do_deposit(&mut env, 1_000_000, 4_000_000); + let lp1_bal = token_amount(&env.svm.get_account(&lp1).unwrap()); + + // Second depositor with the same 1:4 ratio gets proportional LP tokens. + let (_, lp2) = do_deposit(&mut env, 500_000, 2_000_000); + let lp2_bal = token_amount(&env.svm.get_account(&lp2).unwrap()); + + // Half the first deposit → should get roughly half the LP tokens. + // Allow ±1 for integer rounding. + assert!( + lp2_bal > 0 && lp2_bal <= lp1_bal, + "second depositor LP={} should be > 0 and <= first LP={}", + lp2_bal, lp1_bal + ); + println!(" SECOND DEPOSIT: lp1={}, lp2={}", lp1_bal, lp2_bal); +} + +#[test] +fn test_deposit_insufficient_funds_rejected() { + let mut env = setup_pool(); + + let depositor = Pubkey::new_unique(); + // Fund with only 100 of each but request 1_000_000. + let ta = funded_ata(depositor, env.mint_a, 100); + let tb = funded_ata(depositor, env.mint_b, 100); + let (token_a, token_b) = (ta.address, tb.address); + env.svm.set_account(ta); + env.svm.set_account(tb); + let lp_token = Pubkey::new_unique(); + + let r = env.svm.process_instruction( + &ix_deposit( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, token_a, token_b, env.payer, + 1_000_000, 1_000_000, + ), + &[empty(lp_token), signer(depositor)], + ); + assert!(!r.is_ok(), "deposit with insufficient funds should fail"); + println!(" DEPOSIT insufficient funds correctly rejected"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — withdraw_liquidity +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_withdraw_liquidity() { + let mut env = setup_pool(); + let amount_a = 2_000_000u64; + let amount_b = 2_000_000u64; + + let (depositor, lp_token) = do_deposit(&mut env, amount_a, amount_b); + let lp_balance = token_amount(&env.svm.get_account(&lp_token).unwrap()); + assert!(lp_balance > 0); + + // Withdraw half the LP tokens. + let withdraw_amount = lp_balance / 2; + + // Output token accounts are created by init(idempotent) → pass as empty. + let recv_a = Pubkey::new_unique(); + let recv_b = Pubkey::new_unique(); + + let r = env.svm.process_instruction( + &ix_withdraw( + env.config, env.pool_config, env.pool_authority, depositor, + env.lp_mint, env.mint_a, env.mint_b, env.pool_a, env.pool_b, + lp_token, recv_a, recv_b, env.payer, + withdraw_amount, + ), + // recv_a / recv_b are non-PDA accounts init(idempotent) → signer required. + &[signer(recv_a), signer(recv_b), signer(depositor)], ); + assert!(r.is_ok(), "withdraw failed: {:?}", r.raw_result); + + // Verify the depositor received tokens. + let ra = env.svm.get_account(&recv_a).expect("recv_a missing after withdraw"); + let rb = env.svm.get_account(&recv_b).expect("recv_b missing after withdraw"); + assert!(token_amount(&ra) > 0, "recv_a should have tokens after withdraw"); + assert!(token_amount(&rb) > 0, "recv_b should have tokens after withdraw"); + + println!( + " WITHDRAW: lp_burned={}, recv_a={}, recv_b={}", + withdraw_amount, token_amount(&ra), token_amount(&rb) + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — swap_tokens +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_swap_a_to_b() { + let mut env = setup_pool(); + // Seed the pool with liquidity first. + do_deposit(&mut env, 10_000_000, 10_000_000); + + // Trader swaps 100_000 token A for token B. + let trader = Pubkey::new_unique(); + let ta = funded_ata(trader, env.mint_a, 1_000_000); + let token_a = ta.address; + let token_b_out = Pubkey::new_unique(); // created by init(idempotent) + env.svm.set_account(ta); + + let input = 100_000u64; + let r = env.svm.process_instruction( + &ix_swap( + env.config, env.pool_config, env.pool_authority, trader, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + token_a, token_b_out, env.payer, + true, input, 1, // input_is_token_a=true, min_output=1 + ), + // token_b_out is a new non-PDA account → signer required for init. + &[signer(token_b_out), signer(trader)], + ); + assert!(r.is_ok(), "swap A→B failed: {:?}", r.raw_result); + + let out_acct = env.svm.get_account(&token_b_out).expect("token_b_out missing after swap"); + let received = token_amount(&out_acct); + assert!(received > 0, "expected non-zero token B output"); + println!(" SWAP A→B: input={}, output={}", input, received); +} + +#[test] +fn test_swap_b_to_a() { + let mut env = setup_pool(); + do_deposit(&mut env, 10_000_000, 10_000_000); + + let trader = Pubkey::new_unique(); + let tb = funded_ata(trader, env.mint_b, 1_000_000); + let token_b = tb.address; + let token_a_out = Pubkey::new_unique(); + env.svm.set_account(tb); + + let input = 100_000u64; + let r = env.svm.process_instruction( + &ix_swap( + env.config, env.pool_config, env.pool_authority, trader, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + token_a_out, token_b, env.payer, + false, input, 1, // input_is_token_a=false + ), + &[signer(token_a_out), signer(trader)], + ); + assert!(r.is_ok(), "swap B→A failed: {:?}", r.raw_result); + + let out_acct = env.svm.get_account(&token_a_out).expect("token_a_out missing"); + let received = token_amount(&out_acct); + assert!(received > 0, "expected non-zero token A output"); + println!(" SWAP B→A: input={}, output={}", input, received); +} + +#[test] +fn test_swap_slippage_rejected() { + let mut env = setup_pool(); + do_deposit(&mut env, 10_000_000, 10_000_000); + + let trader = Pubkey::new_unique(); + let ta = funded_ata(trader, env.mint_a, 1_000_000); + let token_a = ta.address; + let token_b_out = Pubkey::new_unique(); + env.svm.set_account(ta); + + // min_output set absurdly high (more than pool can deliver). + let r = env.svm.process_instruction( + &ix_swap( + env.config, env.pool_config, env.pool_authority, trader, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + token_a, token_b_out, env.payer, + true, 100_000, 999_999_999, + ), + &[empty(token_b_out), signer(trader)], + ); + assert!(!r.is_ok(), "swap with impossible slippage should fail"); + println!(" SWAP slippage guard correctly rejected"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests — claim_admin_fees +// ═══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_claim_admin_fees() { + let mut env = setup_pool(); + + // Seed pool and do a swap so fees accumulate. + do_deposit(&mut env, 10_000_000, 10_000_000); + + let trader = Pubkey::new_unique(); + let ta = funded_ata(trader, env.mint_a, 1_000_000); + let token_a_in = ta.address; + let token_b_out = Pubkey::new_unique(); + env.svm.set_account(ta); + + let r = env.svm.process_instruction( + &ix_swap( + env.config, env.pool_config, env.pool_authority, trader, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + token_a_in, token_b_out, env.payer, + true, 500_000, 1, + ), + &[signer(token_b_out), signer(trader)], + ); + assert!(r.is_ok(), "swap before claim: {:?}", r.raw_result); + + // Admin claims accumulated fees. + let admin_ta = funded_ata(env.admin, env.mint_a, 0); + let admin_tb = funded_ata(env.admin, env.mint_b, 0); + let (ata_a, ata_b) = (admin_ta.address, admin_tb.address); + env.svm.set_account(admin_ta); + env.svm.set_account(admin_tb); + + let r = env.svm.process_instruction( + &ix_claim_fees( + env.config, env.pool_config, env.pool_authority, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + env.admin, ata_a, ata_b, + ), + &[signer(env.admin)], + ); + assert!(r.is_ok(), "claim_admin_fees failed: {:?}", r.raw_result); + + // After claim, admin_token_a should have received some fees (A was the input side). + let admin_a = env.svm.get_account(&ata_a).expect("admin_ta missing after claim"); assert!( - !result.is_ok(), - "create_amm should have failed with invalid fee" + token_amount(&admin_a) > 0, + "admin should have received token-A fees" + ); + println!(" CLAIM FEES: admin_a_fees={}", token_amount(&admin_a)); +} + +#[test] +fn test_claim_admin_fees_unauthorized() { + let mut env = setup_pool(); + do_deposit(&mut env, 10_000_000, 10_000_000); + + // Swap to accumulate some fees. + let trader = Pubkey::new_unique(); + let ta = funded_ata(trader, env.mint_a, 1_000_000); + let token_a_in = ta.address; + let token_b_out = Pubkey::new_unique(); + env.svm.set_account(ta); + env.svm.process_instruction( + &ix_swap( + env.config, env.pool_config, env.pool_authority, trader, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + token_a_in, token_b_out, env.payer, + true, 100_000, 1, + ), + &[signer(token_b_out), signer(trader)], + ) + .expect("swap before unauthorized claim test"); + + // Impersonator tries to claim with a wrong signer. + let bad_actor = Pubkey::new_unique(); + let fake_ta = funded_ata(bad_actor, env.mint_a, 0); + let fake_tb = funded_ata(bad_actor, env.mint_b, 0); + let (fta, ftb) = (fake_ta.address, fake_tb.address); + env.svm.set_account(fake_ta); + env.svm.set_account(fake_tb); + + let r = env.svm.process_instruction( + &ix_claim_fees( + env.config, env.pool_config, env.pool_authority, + env.mint_a, env.mint_b, env.pool_a, env.pool_b, + bad_actor, fta, ftb, + ), + &[signer(bad_actor)], ); - println!(" CREATE AMM (invalid fee) correctly rejected"); + assert!(!r.is_ok(), "unauthorized claim_admin_fees should fail"); + println!(" CLAIM FEES unauthorized correctly rejected"); }