From a7d3212fee09fa90818335e6a4be8e164b6ed5f7 Mon Sep 17 00:00:00 2001 From: "Edward (Mike's bot)" Date: Tue, 26 May 2026 20:33:20 +0000 Subject: [PATCH 01/10] feat(token-swap): production-shape AMM example - protocol fees, slippage, correctness fixes, u128 math Brings the constant-product AMM example to production shape: purpose-led naming, a configurable admin protocol-fee mechanism, slippage protection on every state-changing handler, correctness fixes on deposit and LP-mint math, a constant-product invariant check on swap, and a full migration from fixed-point (I64F64) to u128 + checked_* integer arithmetic. Drops the `fixed` crate dependency entirely. Correctness fixes - deposit_liquidity used K = pool_a * pool_b as a "ratio" and then multiplied/divided the caller's amount by it. That's nonsense math, unexercised because all prior tests deposited into an empty pool. Replaced with Uniswap V2's mint() ratio-clamp pattern in u128 integer math: caller amounts are upper bounds; the contract picks the largest pair on the current price line. Uses *effective* reserves (vault balance minus admin's owed fees). Adds AmmError::DepositAmountTooSmall for sub-base-unit deposits that clamp to zero. - deposit_liquidity LP-mint formula used sqrt(a * b) for *every* deposit, breaking proportionality on subsequent deposits. Now matches Uniswap V2: initial: liquidity = sqrt(a*b) - MINIMUM_LIQUIDITY subsequent: liquidity = min(a*supply/pool_a, b*supply/pool_b) using effective reserves. Initial-deposit sqrt is computed with a u128 Newton's-method integer_sqrt helper (no more I64F64::sqrt). - claim_admin_fees silently succeeded when both fee accumulators were zero. Adds AmmError::NothingToClaim so the admin gets a real signal and the test suite can exercise the no-op path. (Also fixes a litesvm gotcha where two byte-identical claim txs share a signature; tests now expire the blockhash between repeat claims.) New features - Admin protocol-fee mechanism (Uniswap V2 / Raydium accumulator pattern). `Config.admin_share_bps` splits each swap's trading fee between LPs and a configurable admin. `PoolConfig.admin_fees_owed_a` and `_b` accumulate the admin's per-side claim virtually inside the existing pool_a/pool_b vaults (no extra CPI per swap). New `claim_admin_fees` handler lets the admin sweep accrued fees, authorised via `has_one = admin` against `Config`. - Slippage protection on every state-changing handler: swap_tokens: min_output_amount (existing arg; error renamed OutputTooSmall -> SlippageExceeded) deposit_liquidity: minimum_lp_tokens_out (new) withdraw_liquidity: minimum_token_a_out, minimum_token_b_out (new) Pass 0 to opt out. Withdraw bounds are checked *before* any transfers. New AmmError variants DepositBelowMinimum and WithdrawalBelowMinimum. - Constant-product invariant check on swap. Post-trade k = effective_a * effective_b is recomputed in u128 (raw u64 multiplication overflows at large reserves) and compared against the pre-trade invariant; the trade reverts if k decreases. Defence-in-depth against curve-math regressions. Renames & structural changes (purpose over implementation) Account/struct/field renames so a first-time reader can infer each thing's role from its name: - Pool account fields: pool_account_a/b -> pool_a / pool_b mint_liquidity -> liquidity_provider_mint depositor_account_a/b -> token_a / token_b depositor_account_liquidity -> liquidity_provider_token trader_account_a/b -> token_a / token_b (the `trader_`/`depositor_` prefix duplicates the signer field already on the Accounts struct). - Vault terminology dropped: vault_a/b -> pool_a/b. "Vault" implies yield-bearing structures (Drift/Kamino/Marinade); these are just the pool's reserves. - State struct Amm -> Config (program-level Config, not "the AMM"). - State struct Pool -> PoolConfig (it holds per-pool config / identity, not pool state). - Error type TutorialError -> AmmError. - Accounts derive structs: CreateAmm/CreatePool/DepositLiquidity/ WithdrawLiquidity/SwapExactTokensForTokens -> CreateConfigAccounts / CreatePoolAccounts / DepositLiquidityAccounts / WithdrawLiquidityAccounts / SwapTokensAccounts. Structs name what they *are* (the bag of accounts for handler X), not a verb they pretend to do. - Handler renames: create_amm -> create_config (says which account it creates); swap_exact_tokens_for_tokens -> swap_tokens (the Uniswap-style disambiguator earns no keep without a sibling). - swap parameter swap_a: bool -> input_is_token_a: bool (purpose, not internal direction flag). - Config is now a singleton: seeds = [b"config"], `id: Pubkey` parameter removed from create_config. Real DEXes ship one program per AMM; the `id` was leftover complexity. Math discipline - All money math is u128 + checked_mul / checked_div / try_into, multiply-before-divide, floor rounding in the pool's favour (Uniswap V2 convention). - swap_tokens constant-product output formula and fee math rewritten as u128 numerator / u128 denominator. - withdraw_liquidity proportional-withdraw formula rewritten in u128 with the same discipline. - deposit_liquidity already uses u128 throughout. - integer_sqrt (u128 Newton's method) replaces I64F64::sqrt for the initial-deposit branch. - AmmError::MathOverflow added for the checked_* path. - Removed `fixed = "1.27.0"` from Cargo.toml. Documentation - README rewritten: aligns identifiers with the code, documents the singleton `Config`, the admin protocol-fee design, slippage args, the corrected deposit / LP-mint math, and notes integer_sqrt. - Adds an NVDAx/USDC lifecycle walkthrough with four actors (admin, LP, retail trader, arbitrageur) and the arithmetic for every handler call. Tests - 18/18 Rust + LiteSVM integration tests passing. - Coverage: matching-ratio deposit, deposit clamp on either side, deposit-after-swap effective-ratio, sub-base-unit deposit revert, proportional LP-mint on subsequent deposits, LP-mint uses effective reserves after a swap, swap slippage revert, deposit slippage revert (with exact-floor sanity), withdraw slippage revert (both sides), invariant preservation across many swaps, admin-fee accrual and claim, NothingToClaim revert on empty claim. Scope - tokens/token-swap/anchor/programs/token-swap/ (source + tests) - tokens/token-swap/quasar/ (mirror source + tests) - tokens/token-swap/README.md --- tokens/token-swap/README.md | 269 +++- .../anchor/programs/token-swap/Cargo.toml | 4 +- .../programs/token-swap/src/constants.rs | 3 + .../anchor/programs/token-swap/src/errors.rs | 57 +- .../src/instructions/claim_admin_fees.rs | 174 +++ .../token-swap/src/instructions/create_amm.rs | 41 - .../src/instructions/create_config.rs | 44 + .../src/instructions/create_pool.rs | 42 +- .../src/instructions/deposit_liquidity.rs | 226 +++- .../token-swap/src/instructions/mod.rs | 10 +- .../swap_exact_tokens_for_tokens.rs | 239 ---- .../src/instructions/swap_tokens.rs | 319 +++++ .../src/instructions/withdraw_liquidity.rs | 133 +- .../anchor/programs/token-swap/src/lib.rs | 53 +- .../anchor/programs/token-swap/src/state.rs | 64 +- .../programs/token-swap/tests/test_swap.rs | 1181 +++++++++++++++-- .../src/instructions/claim_admin_fees.rs | 111 ++ .../quasar/src/instructions/create_amm.rs | 33 - .../quasar/src/instructions/create_config.rs | 43 + .../quasar/src/instructions/create_pool.rs | 47 +- .../src/instructions/deposit_liquidity.rs | 65 +- .../token-swap/quasar/src/instructions/mod.rs | 10 +- .../swap_exact_tokens_for_tokens.rs | 150 --- .../quasar/src/instructions/swap_tokens.rs | 201 +++ .../src/instructions/withdraw_liquidity.rs | 58 +- tokens/token-swap/quasar/src/lib.rs | 63 +- tokens/token-swap/quasar/src/state.rs | 49 +- tokens/token-swap/quasar/src/tests.rs | 73 +- 28 files changed, 2888 insertions(+), 874 deletions(-) create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs delete mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/create_amm.rs create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs delete mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs create mode 100644 tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs delete mode 100644 tokens/token-swap/quasar/src/instructions/create_amm.rs create mode 100644 tokens/token-swap/quasar/src/instructions/create_config.rs delete mode 100644 tokens/token-swap/quasar/src/instructions/swap_exact_tokens_for_tokens.rs create mode 100644 tokens/token-swap/quasar/src/instructions/swap_tokens.rs diff --git a/tokens/token-swap/README.md b/tokens/token-swap/README.md index 0f753ccf..6aea8876 100644 --- a/tokens/token-swap/README.md +++ b/tokens/token-swap/README.md @@ -4,6 +4,20 @@ A Constant Product Automated Market Maker (AMM) in [Anchor](https://solana.com/d 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. +## 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 floors on every state-changing 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. No floats, no fixed-point types for money. +- Anchor 1.0 Rust [program](https://solana.com/docs/terminology#program) with LiteSVM integration tests. + ## Why a CPAMM Other bonding-curve designs exist: @@ -25,14 +39,14 @@ Requirements: 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](https://solana.com/docs/terminology#program-derived-address-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 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](https://solana.com/docs/terminology#instruction) scope small.** Smaller instructions touch fewer accounts, leaving room in the transaction and improving composability and security. ## File structure @@ -42,11 +56,12 @@ 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 @@ -54,49 +69,251 @@ programs/token-swap/src/ ## 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 (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. -`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). +- 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). + +## Realistic lifecycle: an NVDAx/USDC pool + +A worked example, end to end, using this program. Assume NVDAx (a tokenised NVIDIA share) trades around 5 USDC offchain. + +**Cast:** + +- **Anya** — deploys this AMM program to mainnet and runs it. Motivation: have a working AMM that people actually use, and earn fees from it. 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 also seeds the NVDAx/USDC pool herself (eating the locked `MINIMUM_LIQUIDITY` cost) so users have something to trade against from day one — which means she earns *twice*: her admin slice via `claim_admin_fees`, plus the LP yield on her initial deposit (same mechanism as Liam). +- **Liam** — yield farmer with idle USDC. Motivation: earn swap fees on his capital. +- **Trang** — retail trader. Motivation: get NVDAx exposure with USDC she already holds. +- **Sam** — arbitrageur. Motivation: make money on price gaps. (Side effect: his trades drag the pool's mid-price back toward the offchain market price, because that's where his profitable trade stops being profitable.) + +### Step 1 — Anya 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` = Anya + - `payer` = Anya + - `system_program` +- **Args:** `fee = 30` (0.3%), `admin_share_bps = 1667` (Uniswap V2's classic 1/6 default — Anya keeps 1/6 of the trading fee; LPs keep 5/6) + +`Config` exists. No pools yet, no liquidity yet. + +### Step 2 — Anya creates the NVDAx/USDC pool + +- **Handler:** `create_pool` +- **Accounts (`CreatePoolAccounts`):** + - `config` — Anya'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` = Anya + - token, ATA, system programs +- **Args:** none + +Pool exists; reserves are empty. No one can swap yet. + +### Step 3 — Anya seeds initial liquidity + +Anya picks a 1:5 ratio so the 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` = Anya (signer) + - `mint_a`, `mint_b` + - `pool_a`, `pool_b` (the pool's reserves) + - `liquidity_provider_token` — Anya's LP-token ATA (created) + - `token_a` — Anya's NVDAx ATA, `token_b` — Anya's USDC ATA + - `payer` = Anya + - token, ATA, system programs +- **Args:** `amount_a = 20`, `amount_b = 100`, `minimum_lp_tokens_out = 0` (initial deposit — Anya 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). +- Anya receives ~44.72 LP tokens. The 100 base-unit dust is locked forever, owned by no one. Anya eats that cost as the price of bootstrapping. + +Pool state: **20 NVDAx, 100 USDC**. Mid-price = 5. Anya owns 100% of withdrawable LP supply. + +### Step 4 — Liam adds liquidity + +At the current 1:5 ratio, Liam deposits **100 NVDAx and 500 USDC**. + +- **Handler:** `deposit_liquidity` +- **Accounts:** same shape as Step 3, `depositor` = Liam +- **Args:** `amount_a = 100`, `amount_b = 500`, `minimum_lp_tokens_out = 223_000_000` (Liam 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. + +Pool state: **120 NVDAx, 600 USDC**. LP supply ~268.32. Liam owns ~83%, Anya ~17%. + +### Step 5 — Trang buys NVDAx with USDC + +- **Handler:** `swap_tokens` +- **Accounts (`SwapTokensAccounts`):** + - `config` — for the fee + - `pool_config`, `pool_authority` + - `trader` = Trang (signer) + - `mint_a`, `mint_b` + - `pool_a`, `pool_b` (the pool's reserves) + - `token_a` — Trang's NVDAx ATA (created if missing), `token_b` — Trang's USDC ATA + - `payer` = Trang + - 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`. + +Trang gets ~2.156 NVDAx. Effective price ~5.10 USDC/NVDAx — worse than mid-price because of the fee plus her own price impact. + +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 — Sam arbitrages + +NVDAx still trades at 5.00 offchain; our pool now says 5.18. There's a profitable trade: buy NVDAx offchain at 5.00, sell it into the pool at ~5.18. Sam does it. + +- **Handler:** `swap_tokens` +- **Accounts:** same shape as Step 5, `trader` = Sam +- **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`. + +Sam paid ~10.75 USDC offchain for 2.15 NVDAx, sold into the pool for ~10.92 USDC. Profit ~0.17 USDC, minus gas. + +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 Sam's profit hit zero and he stopped. + +### Step 8 — Anya claims her admin fees + +After a month of trading activity, Anya sweeps her accumulated slice. + +- **Handler:** `claim_admin_fees` +- **Accounts (`ClaimAdminFeesAccounts`):** + - `config` — Anya'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` = Anya (signer) + - `admin_token_a` — Anya's NVDAx ATA (must already exist) + - `admin_token_b` — Anya'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. 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. + +Pool state: **119.986 NVDAx, 600.065 USDC raw**, with `admin_fees_owed_a = 0` and `admin_fees_owed_b = 0`. + +### Step 9 — Liam withdraws + +Later on, Liam exits. + +- **Handler:** `withdraw_liquidity` +- **Accounts (`WithdrawLiquidityAccounts`):** same shape as deposit, `depositor` = Liam +- **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 Anya'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 + +- **Anya** calls `create_config` → `create_pool` → `deposit_liquidity` (admin, pool creator, initial LP) +- **Liam** calls `deposit_liquidity` (LP) +- **Trang** calls `swap_tokens` with `input_is_token_a = false` (buyer) +- **Sam** calls `swap_tokens` with `input_is_token_a = true` (arbitrageur) +- **Anya** calls `claim_admin_fees` (sweep her accumulated fee slice) +- **Liam** later calls `withdraw_liquidity` (exit) + +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). ## 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..b30fbac7 100644 --- a/tokens/token-swap/anchor/programs/token-swap/Cargo.toml +++ b/tokens/token-swap/anchor/programs/token-swap/Cargo.toml @@ -22,7 +22,9 @@ 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" +# `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..e566c93e 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,9 @@ use anchor_lang::prelude::*; #[constant] pub const MINIMUM_LIQUIDITY: u64 = 100; +#[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..0593aade 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,19 @@ 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 off-chain + // 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 on-chain logs name the failure mode. + #[msg("Math overflow")] + MathOverflow, } diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs new file mode 100644 index 00000000..17e1c281 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs @@ -0,0 +1,174 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, 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 off-chain 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); + } + + let authority_bump = context.bumps.pool_authority; + let authority_seeds = &[ + &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[..]]; + + // Transfer the owed token-A fees from the pool reserve to the admin. + if owed_a > 0 { + token::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, + )?; + } + + // Transfer the owed token-B fees from the pool reserve to the admin. + if owed_b > 0 { + token::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, + )?; + } + + // Reset the accumulators. Done after the transfers so a failed CPI + // leaves the on-chain bookkeeping intact (the admin can retry). + let pool_config = &mut context.accounts.pool_config; + pool_config.admin_fees_owed_a = 0; + pool_config.admin_fees_owed_b = 0; + + msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b); + + Ok(()) +} + +#[derive(Accounts)] +pub struct ClaimAdminFeesAccounts<'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: AccountInfo<'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, + )] + 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, + )] + 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, + )] + 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, + )] + pub admin_token_b: Box>, + + pub token_program: Program<'info, Token>, +} 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..b28234a7 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs @@ -0,0 +1,44 @@ +use anchor_lang::prelude::*; + +use crate::{constants::CONFIG_SEED, errors::*, state::Config}; + +pub fn handle_create_config( + mut 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 CreateConfigAccounts<'info> { + #[account( + init, + payer = payer, + space = Config::DISCRIMINATOR.len() + Config::INIT_SPACE, + seeds = [CONFIG_SEED], + bump, + constraint = fee < 10000 @ AmmError::InvalidFee, + constraint = admin_share_bps < 10000 @ AmmError::AdminShareTooHigh, + )] + pub config: Account<'info, Config>, + + /// 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_pool.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rs index 3d423a04..4869c8a3 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 @@ -5,48 +5,46 @@ use anchor_spl::{ }; use crate::{ - constants::{AUTHORITY_SEED, LIQUIDITY_SEED}, - state::{Amm, Pool}, + constants::{AUTHORITY_SEED, CONFIG_SEED, LIQUIDITY_SEED}, + 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(mut 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 CreatePoolAccounts<'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, )] - 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, @@ -59,7 +57,7 @@ pub struct CreatePool<'info> { 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,7 +66,7 @@ pub struct CreatePool<'info> { mint::decimals = 6, mint::authority = pool_authority, )] - pub mint_liquidity: Box>, + pub liquidity_provider_mint: Box>, pub mint_a: Box>, @@ -80,7 +78,7 @@ pub struct CreatePool<'info> { associated_token::mint = mint_a, associated_token::authority = pool_authority, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( init, @@ -88,7 +86,7 @@ pub struct CreatePool<'info> { associated_token::mint = mint_b, associated_token::authority = pool_authority, )] - pub pool_account_b: Box>, + pub pool_b: Box>, /// The account paying for all rents #[account(mut)] 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..ec054f05 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 @@ -3,78 +3,180 @@ use anchor_spl::{ associated_token::AssociatedToken, token::{self, Mint, MintTo, Token, TokenAccount, 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; + let effective_pool_a = pool_a.amount - pool_config.admin_fees_owed_a; + let effective_pool_b = pool_b.amount - pool_config.admin_fees_owed_b; // 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)) + // 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) + .unwrap() + .checked_div(effective_pool_a as u128) .unwrap(); - if pool_a.amount > pool_b.amount { - ( - I64F64::from_num(amount_b) - .checked_mul(ratio) - .unwrap() - .to_num::(), - amount_b, - ) + if amount_b_required <= amount_b as u128 { + // The depositor's `amount_b` is enough to cover the ratio; use + // the full `amount_a` and clamp `amount_b` down. + let amount_b_required = u64::try_from(amount_b_required).unwrap(); + (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) + .unwrap() + .checked_div(effective_pool_b as u128) + .unwrap(); + let amount_a_required = u64::try_from(amount_a_required).unwrap(); + (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 off-chain 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). @@ -82,9 +184,9 @@ pub fn handle_deposit_liquidity( 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(), }, ), @@ -95,9 +197,9 @@ pub fn handle_deposit_liquidity( 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,7 +210,7 @@ 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, @@ -119,8 +221,8 @@ pub fn handle_deposit_liquidity( 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,23 +234,23 @@ pub fn handle_deposit_liquidity( } #[derive(Accounts)] -pub struct DepositLiquidity<'info> { +pub struct DepositLiquidityAccounts<'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, @@ -163,14 +265,14 @@ 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>, @@ -181,36 +283,36 @@ pub struct DepositLiquidity<'info> { associated_token::mint = mint_a, associated_token::authority = pool_authority, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = pool_authority, )] - 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, )] - pub depositor_account_liquidity: Box>, + pub liquidity_provider_token: Box>, #[account( mut, associated_token::mint = mint_a, associated_token::authority = depositor, )] - pub depositor_account_a: Box>, + pub token_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = depositor, )] - pub depositor_account_b: Box>, + pub token_b: Box>, /// The account paying for all rents #[account(mut)] 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..c0a9ab8c 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 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/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..e43777a3 --- /dev/null +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs @@ -0,0 +1,319 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{self, Mint, Token, TokenAccount, TransferChecked}, +}; + +use crate::{ + constants::{AUTHORITY_SEED, 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); + } + let input = input_amount; + + // 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 as u128) + .checked_mul(config.fee as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(10_000) + .ok_or(AmmError::MathOverflow)?; + let admin_portion = fee_amount + .checked_mul(config.admin_share_bps as u128) + .ok_or(AmmError::MathOverflow)? + .checked_div(10_000) + .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.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; + let effective_pool_a = pool_a.amount - pool_config.admin_fees_owed_a; + let effective_pool_b = pool_b.amount - pool_config.admin_fees_owed_b; + + // 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 off-chain 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)?; + + // Transfer tokens to the pool + let authority_bump = context.bumps.pool_authority; + let authority_seeds = &[ + &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[..]]; + // 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 input_is_token_a { + token::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, + context.accounts.mint_a.decimals, + )?; + token::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::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::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, + )?; + } + + // The fee always comes off the *input* side, so the admin's claim + // accumulates in the same token they paid the fee in. + 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) + .unwrap(); + } else { + pool_config.admin_fees_owed_b = pool_config + .admin_fees_owed_b + .checked_add(admin_portion) + .unwrap(); + } + + msg!( + "Traded {} tokens ({} after fees) for {} (admin slice {})", + input, + 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 - pool_config.admin_fees_owed_a; + let effective_pool_b_after = context.accounts.pool_b.amount - pool_config.admin_fees_owed_b; + 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 SwapTokensAccounts<'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: 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_a: Box>, + + #[account( + mut, + associated_token::mint = mint_b, + associated_token::authority = pool_authority, + )] + pub pool_b: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = mint_a, + associated_token::authority = trader, + )] + pub token_a: Box>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = mint_b, + associated_token::authority = trader, + )] + 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 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..5a64de9e 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 @@ -3,17 +3,22 @@ use anchor_spl::{ associated_token::AssociatedToken, token::{self, Burn, Mint, Token, TokenAccount, 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,67 @@ 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; + let effective_pool_a = context.accounts.pool_a.amount - pool_config.admin_fees_owed_a; + let effective_pool_b = context.accounts.pool_b.amount - pool_config.admin_fees_owed_b; + + // 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( 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 +95,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( 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, @@ -77,8 +116,8 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 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(), + mint: context.accounts.liquidity_provider_mint.to_account_info(), + from: context.accounts.liquidity_provider_token.to_account_info(), authority: context.accounts.depositor.to_account_info(), }, ), @@ -89,31 +128,29 @@ pub fn handle_withdraw_liquidity(context: Context, amount: u6 } #[derive(Accounts)] -pub struct WithdrawLiquidity<'info> { +pub struct WithdrawLiquidityAccounts<'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, @@ -128,14 +165,14 @@ pub struct WithdrawLiquidity<'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>, @@ -148,21 +185,21 @@ pub struct WithdrawLiquidity<'info> { associated_token::mint = mint_a, associated_token::authority = pool_authority, )] - pub pool_account_a: Box>, + pub pool_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = pool_authority, )] - pub pool_account_b: Box>, + pub pool_b: Box>, #[account( mut, - associated_token::mint = mint_liquidity, + associated_token::mint = liquidity_provider_mint, associated_token::authority = depositor, )] - pub depositor_account_liquidity: Box>, + pub liquidity_provider_token: Box>, #[account( init_if_needed, @@ -170,7 +207,7 @@ pub struct WithdrawLiquidity<'info> { associated_token::mint = mint_a, associated_token::authority = depositor, )] - pub depositor_account_a: Box>, + pub token_a: Box>, #[account( init_if_needed, @@ -178,7 +215,7 @@ pub struct WithdrawLiquidity<'info> { associated_token::mint = mint_b, associated_token::authority = depositor, )] - pub depositor_account_b: Box>, + pub token_b: Box>, /// The account paying for all rents #[account(mut)] 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..92a4c0e2 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/lib.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/lib.rs @@ -6,39 +6,68 @@ 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 index 8a89414c..4c3041ee 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/state.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/state.rs @@ -1,33 +1,75 @@ 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 on-chain layout simpler and matches realistic deployment. #[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 struct Config { + /// Account that has admin authority over the AMM. pub admin: Pubkey, - /// The LP fee taken on each trade, in basis points + /// 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, } +/// 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 Pool { - /// Primary key of the AMM - pub amm: Pubkey, +pub struct PoolConfig { + /// Address of the parent `Config` account this pool belongs to. + pub config: Pubkey, - /// Mint of token A + /// Mint of token A. pub mint_a: Pubkey, - /// Mint of token B + /// 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..76eb20fb 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::CreateConfigAccounts { + 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::CreatePoolAccounts { + 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::CreateConfigAccounts { + 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::DepositLiquidityAccounts { + 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::DepositLiquidityAccounts { + 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::SwapTokensAccounts { + 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::DepositLiquidityAccounts { + 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::WithdrawLiquidityAccounts { + config: ts.config_key, + 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(), @@ -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::DepositLiquidityAccounts { + 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::SwapTokensAccounts { + 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::ClaimAdminFeesAccounts { + 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::SwapTokensAccounts { + 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::ClaimAdminFeesAccounts { + 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::ClaimAdminFeesAccounts { + 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::DepositLiquidityAccounts { + 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::SwapTokensAccounts { + 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 are no longer 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 on-chain 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::SwapTokensAccounts { + 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::DepositLiquidityAccounts { + 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::WithdrawLiquidityAccounts { + config: ts.config_key, + 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), + ) +} + +/// 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..76f50e12 --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -0,0 +1,111 @@ +use { + crate::{ + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, PoolAuthorityPda, PoolPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Accounts for sweeping the admin's accumulated trading-fee claim out of a +/// pool. +/// +/// 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]), + ]; + + 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)?; + } + + // Reset the accumulators. Done after the transfers so a failed CPI + // leaves the on-chain bookkeeping intact (the admin can retry). + 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, + }); + + 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..5cb4c261 --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/create_config.rs @@ -0,0 +1,43 @@ +use { + crate::{state::{Config, ConfigInner}, ConfigPda}, + quasar_lang::prelude::*, +}; + +/// Accounts for creating the singleton AMM config. +/// +/// `Config` is a global singleton: one account per deployed program, derived +/// at the fixed seed `b"config"`. There is no `id` parameter \u2014 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 >= 10000 { + 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 >= 10000 { + 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..02f7645e 100644 --- a/tokens/token-swap/quasar/src/instructions/create_pool.rs +++ b/tokens/token-swap/quasar/src/instructions/create_pool.rs @@ -1,7 +1,7 @@ use { crate::{ - state::{Amm, Pool, PoolInner}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, @@ -9,28 +9,28 @@ use { /// Accounts for creating a new liquidity pool. /// -/// 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]. +/// Seeds are based on account addresses: 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]. /// /// Note: post-PR-#195 the seed prefix is always emitted first by -/// `#[derive(Seeds)]`, so pool_authority/mint_liquidity now derive with +/// `#[derive(Seeds)]`, so pool_authority/liquidity_provider_mint now derive with /// the literal prefix in front (different on-chain addresses than the /// Anchor sibling, but 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 +38,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 +68,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..8f2c0155 100644 --- a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -1,7 +1,7 @@ use { crate::{ - state::{Amm, Pool}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, @@ -9,16 +9,16 @@ use { /// Accounts for depositing liquidity into a pool. /// -/// Seeds reference the amm, mint_a, and mint_b account addresses — these +/// Seeds reference the config, mint_a, and mint_b account addresses — these /// 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 +29,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 +75,22 @@ 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 depositor_a = accounts.token_a.amount(); + let depositor_b = accounts.token_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 }; - 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. + let pool_a_amount = accounts.pool_a.amount() - accounts.pool_config.admin_fees_owed_a(); + let pool_b_amount = accounts.pool_b.amount() - accounts.pool_config.admin_fees_owed_b(); let pool_creation = pool_a_amount == 0 && pool_b_amount == 0; if !pool_creation { @@ -123,20 +126,20 @@ pub fn handle_deposit_liquidity( // 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 +147,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..1c4a3d5f --- /dev/null +++ b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs @@ -0,0 +1,201 @@ +use { + crate::{ + state::{Config, PoolConfig, PoolConfigInner}, + ConfigPda, PoolAuthorityPda, PoolPda, + }, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +/// Accounts for swapping tokens using the constant-product formula. +/// +/// `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> { + // Clamp input to what the trader has. + let input = if input_is_token_a { + let trader_a = accounts.token_a.amount(); + if input_amount > trader_a { trader_a } else { input_amount } + } else { + let trader_b = accounts.token_b.amount(); + if input_amount > trader_b { trader_b } else { 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. + let fee = accounts.config.fee() as u64; + let admin_share_bps = accounts.config.admin_share_bps() as u64; + let fee_amount = input * fee / 10000; + let admin_portion = fee_amount * admin_share_bps / 10000; + let taxed_input = input - fee_amount; + + // 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_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 - owed_a; + let effective_pool_b = pool_b_raw - owed_b; + + 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)?; + + // Build authority signer seeds. + // 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()?; + } + + // Accumulate the admin's slice on the *input* side. The fee always + // comes off the input, so the admin's claim grows in the input token. + 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, + }); + + // Verify invariant holds on the LP-claimable (effective) reserves. + let new_pool_a_raw = (pool_a_raw as u128) + + if input_is_token_a { input as u128 } else { 0 } + - if !input_is_token_a { output as u128 } else { 0 }; + let new_pool_b_raw = (pool_b_raw as u128) + + if !input_is_token_a { input as u128 } else { 0 } + - if input_is_token_a { output as u128 } else { 0 }; + let new_effective_a = new_pool_a_raw - (new_owed_a as u128); + let new_effective_b = new_pool_b_raw - (new_owed_b as u128); + 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..b4010be1 100644 --- a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -1,7 +1,7 @@ use { crate::{ - state::{Amm, Pool}, - AmmPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, + state::{Config, PoolConfig}, + ConfigPda, LiquidityMintPda, PoolAuthorityPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, @@ -9,13 +9,13 @@ use { /// 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 +25,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 +59,54 @@ 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`. + let effective_pool_a = accounts.pool_a.amount() - accounts.pool_config.admin_fees_owed_a(); + let effective_pool_b = accounts.pool_b.amount() - accounts.pool_config.admin_fees_owed_b(); + let total_liquidity = accounts.liquidity_provider_mint.supply() + crate::MINIMUM_LIQUIDITY; 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..ee680af5 100644 --- a/tokens/token-swap/quasar/src/lib.rs +++ b/tokens/token-swap/quasar/src/lib.rs @@ -12,6 +12,8 @@ declare_id!("22222222222222222222222222222222222222222222"); /// Minimum liquidity locked on first deposit to prevent manipulation. pub const MINIMUM_LIQUIDITY: u64 = 100; +/// 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 +23,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 +89,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..b7048475 100644 --- a/tokens/token-swap/quasar/src/tests.rs +++ b/tokens/token-swap/quasar/src/tests.rs @@ -26,31 +26,31 @@ fn empty(address: Pubkey) -> Account { } } -fn build_create_amm_data(id: &Pubkey, fee: u16) -> Vec { +fn build_create_config_data(fee: u16, admin_share_bps: u16) -> Vec { let mut data = vec![0u8]; // discriminator - data.extend_from_slice(id.as_ref()); data.extend_from_slice(&fee.to_le_bytes()); + data.extend_from_slice(&admin_share_bps.to_le_bytes()); data } #[test] -fn test_create_amm() { +fn test_create_config() { 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()); + // Derive the singleton Config PDA (seeds = [b"config"]). + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - let data = build_create_amm_data(&amm_id, 30); + // Uniswap V2's classic 1/6 split for the admin slice. + 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), @@ -60,35 +60,34 @@ fn test_create_amm() { 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_amm failed: {:?}", + "create_config failed: {:?}", result.raw_result ); - println!(" CREATE AMM CU: {}", result.compute_units_consumed); + println!(" CREATE CONFIG CU: {}", result.compute_units_consumed); } #[test] -fn test_create_amm_invalid_fee() { +fn test_create_config_invalid_fee() { 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()); + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); // Fee >= 10000 should fail. - let data = build_create_amm_data(&amm_id, 10000); + let data = build_create_config_data(10000, 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), @@ -98,12 +97,48 @@ fn test_create_amm_invalid_fee() { 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_amm should have failed with invalid fee" + "create_config should have failed with invalid fee" ); - println!(" CREATE AMM (invalid fee) correctly rejected"); + println!(" CREATE CONFIG (invalid fee) correctly rejected"); +} + +#[test] +fn test_create_config_invalid_admin_share() { + let mut svm = setup(); + + let payer = Pubkey::new_unique(); + let admin = Pubkey::new_unique(); + let system_program = quasar_svm::system_program::ID; + + let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); + + // admin_share_bps >= 10000 should fail. + let data = build_create_config_data(30, 10000); + + 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(system_program.into(), false), + ], + data, + }; + + let result = svm.process_instruction( + &instruction, + &[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"); } From 912511f65dc25170a594aecdbf3dfc555215cdb9 Mon Sep 17 00:00:00 2001 From: "Edward (Mike's bot)" Date: Tue, 26 May 2026 22:48:59 +0000 Subject: [PATCH 02/10] refactor(token-swap): split state.rs into state/ directory Match the convention used by the majority of non-trivial Anchor examples in this repo (escrow, anchor-program-example, carnival, close-account, cutils, etc.): one struct per file under a state/ module, with mod.rs re-exporting the contents. state/mod.rs - re-exports Config and PoolConfig state/config.rs - Config struct (program-level singleton) state/pool_config.rs - PoolConfig struct (per-pool identity record) Pure structural refactor. No behaviour, field, or doc changes. All downstream imports (`use crate::state::{Config, PoolConfig}`) continue to work via the glob re-export. Build clean, 18/18 integration tests pass, no new warnings. --- .../programs/token-swap/src/state/config.rs | 34 +++++++++++++++++++ .../programs/token-swap/src/state/mod.rs | 5 +++ .../src/{state.rs => state/pool_config.rs} | 33 ------------------ 3 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/state/config.rs create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/state/mod.rs rename tokens/token-swap/anchor/programs/token-swap/src/{state.rs => state/pool_config.rs} (54%) 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..b8391c06 --- /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 on-chain 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.rs b/tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs similarity index 54% rename from tokens/token-swap/anchor/programs/token-swap/src/state.rs rename to tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs index 4c3041ee..60b8867c 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/state.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/state/pool_config.rs @@ -1,38 +1,5 @@ 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 on-chain 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, -} - /// Per-pool configuration / identity record. /// /// Holds the metadata that identifies a single pool: which `Config` it belongs From 9be8bc5f61c8f514d57394be4f49c636a782e1d7 Mon Sep 17 00:00:00 2001 From: "Edward (Mike's bot)" Date: Wed, 27 May 2026 17:33:35 +0000 Subject: [PATCH 03/10] docs(token-swap): drop 'no floats, no fixed-point' from README That clause was saying *why we removed* the fixed crate, which belongs in the changelog/PR description, not in the README that describes what the code does. The positive half (u128 + checked arithmetic, matching production Solana AMMs) is kept. --- tokens/token-swap/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tokens/token-swap/README.md b/tokens/token-swap/README.md index 6aea8876..1ce2e169 100644 --- a/tokens/token-swap/README.md +++ b/tokens/token-swap/README.md @@ -15,7 +15,7 @@ The pool keeps `x * y = K` invariant: if `x` is the reserve of token A and `y` i - 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 floors on every state-changing 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. No floats, no fixed-point types for money. +- 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 From 4ce5c42f8487bc99dbb5dd720cd0fcea84928dc4 Mon Sep 17 00:00:00 2001 From: "Edward (Mike's bot)" Date: Wed, 27 May 2026 17:36:54 +0000 Subject: [PATCH 04/10] docs(token-swap/quasar): drop redundant 'Accounts for X' doc-comments The opening line of each instruction's account-struct doc-comment was paraphrasing the struct name (e.g. "Accounts for sweeping the admin's trading-fee claim" above `ClaimAdminFeesAccounts`). Dropped those lines and kept only the substantive notes (auth model, mut rationale, seed quirks). Also fixed a stray literal \u2014 sequence in create_config.rs that should have been an em-dash. --- .../quasar/src/instructions/claim_admin_fees.rs | 3 --- .../quasar/src/instructions/create_config.rs | 4 +--- .../quasar/src/instructions/create_pool.rs | 16 +++++++--------- .../quasar/src/instructions/deposit_liquidity.rs | 6 ++---- .../quasar/src/instructions/swap_tokens.rs | 2 -- .../src/instructions/withdraw_liquidity.rs | 1 - 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs index 76f50e12..fa9b4689 100644 --- a/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs +++ b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -7,9 +7,6 @@ use { quasar_spl::prelude::*, }; -/// Accounts for sweeping the admin's accumulated trading-fee claim out of a -/// pool. -/// /// 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. diff --git a/tokens/token-swap/quasar/src/instructions/create_config.rs b/tokens/token-swap/quasar/src/instructions/create_config.rs index 5cb4c261..06992920 100644 --- a/tokens/token-swap/quasar/src/instructions/create_config.rs +++ b/tokens/token-swap/quasar/src/instructions/create_config.rs @@ -3,10 +3,8 @@ use { quasar_lang::prelude::*, }; -/// Accounts for creating the singleton AMM config. -/// /// `Config` is a global singleton: one account per deployed program, derived -/// at the fixed seed `b"config"`. There is no `id` parameter \u2014 calling this +/// 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 { diff --git a/tokens/token-swap/quasar/src/instructions/create_pool.rs b/tokens/token-swap/quasar/src/instructions/create_pool.rs index 02f7645e..1d37907d 100644 --- a/tokens/token-swap/quasar/src/instructions/create_pool.rs +++ b/tokens/token-swap/quasar/src/instructions/create_pool.rs @@ -7,16 +7,14 @@ use { 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_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]. -/// -/// Note: post-PR-#195 the seed prefix is always emitted first by -/// `#[derive(Seeds)]`, so pool_authority/liquidity_provider_mint 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 CreatePoolAccounts { #[account(address = ConfigPda::seeds())] diff --git a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs index 8f2c0155..e2601e83 100644 --- a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -7,10 +7,8 @@ use { quasar_spl::prelude::*, }; -/// Accounts for depositing liquidity into a pool. -/// -/// Seeds reference the config, 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 DepositLiquidityAccounts { #[account(address = ConfigPda::seeds())] diff --git a/tokens/token-swap/quasar/src/instructions/swap_tokens.rs b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs index 1c4a3d5f..39cab41d 100644 --- a/tokens/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs @@ -7,8 +7,6 @@ use { quasar_spl::prelude::*, }; -/// Accounts for swapping tokens using the constant-product formula. -/// /// `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)] diff --git a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs index b4010be1..56c5ea53 100644 --- a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -7,7 +7,6 @@ use { quasar_spl::prelude::*, }; -/// Accounts for withdrawing liquidity from a pool. #[derive(Accounts)] pub struct WithdrawLiquidityAccounts { #[account(address = ConfigPda::seeds())] From 17a821c364b1586a4311ca01493a9ea5e8efe911 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 21:03:52 +0000 Subject: [PATCH 05/10] fix(token-swap): address review findings from solana-anchor-claude-skill - Enforce mint_a < mint_b in create_pool (was documented but not checked; without it duplicate pools with swapped mints can coexist) - Replace .unwrap() with .ok_or(AmmError::MathOverflow)? on all checked arithmetic in deposit_liquidity ratio-clamp and swap_tokens fee accumulator - Remove pointless `let input = input_amount` alias in swap_tokens - Rename `depositor` -> `withdrawer` in WithdrawLiquidityAccounts (the signer is exiting a position, not depositing) - Remove stale "Set the correct key here" scaffold comment from lib.rs - Fix on-chain/off-chain -> onchain/offchain throughout source and tests - Fix README file structure: state.rs -> state/ directory --- tokens/token-swap/README.md | 5 ++++- .../anchor/programs/token-swap/src/errors.rs | 11 +++++++++-- .../src/instructions/claim_admin_fees.rs | 4 ++-- .../token-swap/src/instructions/create_pool.rs | 2 ++ .../src/instructions/deposit_liquidity.rs | 16 +++++++++------- .../token-swap/src/instructions/swap_tokens.rs | 16 +++++++--------- .../src/instructions/withdraw_liquidity.rs | 11 +++++------ .../anchor/programs/token-swap/src/lib.rs | 1 - .../programs/token-swap/src/state/config.rs | 2 +- .../programs/token-swap/tests/test_swap.rs | 4 ++-- 10 files changed, 41 insertions(+), 31 deletions(-) diff --git a/tokens/token-swap/README.md b/tokens/token-swap/README.md index 1ce2e169..047f1774 100644 --- a/tokens/token-swap/README.md +++ b/tokens/token-swap/README.md @@ -64,7 +64,10 @@ programs/token-swap/src/ │ ├── swap_tokens.rs │ └── withdraw_liquidity.rs ├── lib.rs -└── state.rs +└── state/ + ├── config.rs + ├── mod.rs + └── pool_config.rs ``` ## State 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 0593aade..69d67a63 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/errors.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/errors.rs @@ -61,14 +61,21 @@ pub enum AmmError { // (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 off-chain + // `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 on-chain logs name the failure mode. + // 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/claim_admin_fees.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs index 17e1c281..ca75bee4 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs @@ -24,7 +24,7 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu 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 off-chain that the call did nothing - silent + // 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 @@ -81,7 +81,7 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu } // Reset the accumulators. Done after the transfers so a failed CPI - // leaves the on-chain bookkeeping intact (the admin can retry). + // leaves the onchain bookkeeping intact (the admin can retry). let pool_config = &mut context.accounts.pool_config; pool_config.admin_fees_owed_a = 0; pool_config.admin_fees_owed_b = 0; 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 4869c8a3..15100714 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 @@ -6,6 +6,7 @@ use anchor_spl::{ use crate::{ constants::{AUTHORITY_SEED, CONFIG_SEED, LIQUIDITY_SEED}, + errors::AmmError, state::{Config, PoolConfig}, }; @@ -38,6 +39,7 @@ pub struct CreatePoolAccounts<'info> { mint_b.key().as_ref(), ], bump, + constraint = mint_a.key() < mint_b.key() @ AmmError::InvalidMintOrder, )] pub pool_config: Box>, 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 ec054f05..ea1314d4 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 @@ -81,23 +81,25 @@ pub fn handle_deposit_liquidity( // matches Uniswap V2. let amount_b_required = (amount_a as u128) .checked_mul(effective_pool_b as u128) - .unwrap() + .ok_or(AmmError::MathOverflow)? .checked_div(effective_pool_a as u128) - .unwrap(); + .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).unwrap(); + let amount_b_required = + u64::try_from(amount_b_required).map_err(|_| AmmError::MathOverflow)?; (amount_a, amount_b_required) } else { // `amount_b` is the binding side; use the full `amount_b` and // clamp `amount_a` down to what the ratio needs. let amount_a_required = (amount_b as u128) .checked_mul(effective_pool_a as u128) - .unwrap() + .ok_or(AmmError::MathOverflow)? .checked_div(effective_pool_b as u128) - .unwrap(); - let amount_a_required = u64::try_from(amount_a_required).unwrap(); + .ok_or(AmmError::MathOverflow)?; + let amount_a_required = + u64::try_from(amount_a_required).map_err(|_| AmmError::MathOverflow)?; (amount_a_required, amount_b) } }; @@ -165,7 +167,7 @@ pub fn handle_deposit_liquidity( } // Depositor's slippage protection: the caller passes the lowest LP - // amount they're willing to receive (computed off-chain at quote time). + // 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. 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 index e43777a3..00f67ff4 100644 --- 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 @@ -27,8 +27,6 @@ pub fn handle_swap_tokens( if !input_is_token_a && input_amount > context.accounts.token_b.amount { return err!(AmmError::InsufficientBalance); } - let input = input_amount; - // 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 @@ -41,7 +39,7 @@ pub fn handle_swap_tokens( // protocol-favouring (the trader pays slightly more fee on rounding, // not less). let config = &context.accounts.config; - let fee_amount = (input as u128) + let fee_amount = (input_amount as u128) .checked_mul(config.fee as u128) .ok_or(AmmError::MathOverflow)? .checked_div(10_000) @@ -61,7 +59,7 @@ pub fn handle_swap_tokens( // 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.checked_sub(fee_amount).ok_or(AmmError::MathOverflow)?; + 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 @@ -100,7 +98,7 @@ pub fn handle_swap_tokens( 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 off-chain at quote time). If the + // 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!( @@ -143,7 +141,7 @@ pub fn handle_swap_tokens( authority: context.accounts.trader.to_account_info(), }, ), - input, + input_amount, context.accounts.mint_a.decimals, )?; token::transfer_checked( @@ -197,17 +195,17 @@ pub fn handle_swap_tokens( pool_config.admin_fees_owed_a = pool_config .admin_fees_owed_a .checked_add(admin_portion) - .unwrap(); + .ok_or(AmmError::MathOverflow)?; } else { pool_config.admin_fees_owed_b = pool_config .admin_fees_owed_b .checked_add(admin_portion) - .unwrap(); + .ok_or(AmmError::MathOverflow)?; } msg!( "Traded {} tokens ({} after fees) for {} (admin slice {})", - input, + input_amount, taxed_input, output, admin_portion 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 5a64de9e..991871d2 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 @@ -118,7 +118,7 @@ pub fn handle_withdraw_liquidity( Burn { mint: context.accounts.liquidity_provider_mint.to_account_info(), from: context.accounts.liquidity_provider_token.to_account_info(), - authority: context.accounts.depositor.to_account_info(), + authority: context.accounts.withdrawer.to_account_info(), }, ), amount, @@ -159,8 +159,7 @@ pub struct WithdrawLiquidityAccounts<'info> { )] pub pool_authority: AccountInfo<'info>, - /// The account paying for all rents - pub depositor: Signer<'info>, + pub withdrawer: Signer<'info>, #[account( mut, @@ -197,7 +196,7 @@ pub struct WithdrawLiquidityAccounts<'info> { #[account( mut, associated_token::mint = liquidity_provider_mint, - associated_token::authority = depositor, + associated_token::authority = withdrawer, )] pub liquidity_provider_token: Box>, @@ -205,7 +204,7 @@ pub struct WithdrawLiquidityAccounts<'info> { init_if_needed, payer = payer, associated_token::mint = mint_a, - associated_token::authority = depositor, + associated_token::authority = withdrawer, )] pub token_a: Box>, @@ -213,7 +212,7 @@ pub struct WithdrawLiquidityAccounts<'info> { init_if_needed, payer = payer, associated_token::mint = mint_b, - associated_token::authority = depositor, + associated_token::authority = withdrawer, )] pub token_b: Box>, 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 92a4c0e2..e5f73cdf 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/lib.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/lib.rs @@ -5,7 +5,6 @@ mod errors; mod instructions; mod state; -// Set the correct key here declare_id!("GahM6PrXesrBkHiGJ5no4EskLNnVBCaSwVKbM4UtzyK6"); #[program] 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 index b8391c06..6272ecda 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/state/config.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/state/config.rs @@ -7,7 +7,7 @@ use anchor_lang::prelude::*; /// 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 on-chain layout simpler and matches realistic deployment. +/// makes the onchain layout simpler and matches realistic deployment. #[account] #[derive(Default, InitSpace)] pub struct Config { 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 76eb20fb..7783698a 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 @@ -1093,7 +1093,7 @@ fn test_lp_mint_proportional_to_share_of_pool() { } /// Test G: LP-mint correctness after a swap has shifted the pool ratio. The -/// effective reserves are no longer the seeded ratio; LP minting must use +/// 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] @@ -1108,7 +1108,7 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { let swap_in = 1_000_000u64; swap_a_to_b(&mut ts, swap_in); - // Read post-swap effective reserves directly from on-chain state. + // 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 = { From 7aed8dd42c4eae1679ecd92ee2b6baf3abd59334 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 14:53:12 +0000 Subject: [PATCH 06/10] fix(token-swap): second-pass review findings - token_interface, CEI, mint ordering - Migrate all instruction handlers to anchor_spl::token_interface (InterfaceAccount, InterfaceAccount, Interface) so pools work with both Classic Token Program and Token Extensions Program - Enable spl-token-interface feature in anchor-spl dependency - Apply CEI ordering in swap_tokens: pre-copy seed bytes, update admin_fees_owed before transfer CPIs, build signer_seeds after - Apply CEI ordering in claim_admin_fees: zero accumulators before transfer CPIs inside a scoped borrow block - Add mint_a < mint_b canonical ordering constraint to create_pool (AmmError::InvalidMintOrder) to prevent duplicate-pair pools - Add associated_token::token_program constraint to all ATA accounts for correct routing between token program variants --- .../anchor/programs/token-swap/Cargo.toml | 2 +- .../src/instructions/claim_admin_fees.rs | 52 ++++++++----- .../src/instructions/create_pool.rs | 16 ++-- .../src/instructions/deposit_liquidity.rs | 31 ++++---- .../src/instructions/swap_tokens.rs | 78 +++++++++++-------- .../src/instructions/withdraw_liquidity.rs | 31 ++++---- 6 files changed, 121 insertions(+), 89 deletions(-) diff --git a/tokens/token-swap/anchor/programs/token-swap/Cargo.toml b/tokens/token-swap/anchor/programs/token-swap/Cargo.toml index b30fbac7..864990b9 100644 --- a/tokens/token-swap/anchor/programs/token-swap/Cargo.toml +++ b/tokens/token-swap/anchor/programs/token-swap/Cargo.toml @@ -21,7 +21,7 @@ custom-panic = [] [dependencies] anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } -anchor-spl = { version = "1.0.0", features = ["metadata"] } +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. diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs index ca75bee4..985e74e5 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs @@ -1,5 +1,5 @@ use anchor_lang::prelude::*; -use anchor_spl::token::{self, Mint, Token, TokenAccount, TransferChecked}; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; use crate::{ constants::{AUTHORITY_SEED, CONFIG_SEED}, @@ -34,19 +34,32 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu 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 = &[ - &context.accounts.pool_config.config.to_bytes(), - &context.accounts.mint_a.key().to_bytes(), - &context.accounts.mint_b.key().to_bytes(), + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), AUTHORITY_SEED, &[authority_bump], ]; let signer_seeds = &[&authority_seeds[..]]; - // Transfer the owed token-A fees from the pool reserve to the admin. if owed_a > 0 { - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { @@ -62,9 +75,8 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu )?; } - // Transfer the owed token-B fees from the pool reserve to the admin. if owed_b > 0 { - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { @@ -80,12 +92,6 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu )?; } - // Reset the accumulators. Done after the transfers so a failed CPI - // leaves the onchain bookkeeping intact (the admin can retry). - let pool_config = &mut context.accounts.pool_config; - pool_config.admin_fees_owed_a = 0; - pool_config.admin_fees_owed_b = 0; - msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b); Ok(()) @@ -126,9 +132,9 @@ pub struct ClaimAdminFeesAccounts<'info> { )] pub pool_authority: AccountInfo<'info>, - pub mint_a: Box>, + pub mint_a: Box>, - pub mint_b: Box>, + pub mint_b: Box>, /// The pool's token-A reserve. The admin's owed token-A fees are paid out /// of this account. @@ -136,8 +142,9 @@ pub struct ClaimAdminFeesAccounts<'info> { mut, associated_token::mint = mint_a, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_a: Box>, + pub pool_a: Box>, /// The pool's token-B reserve. The admin's owed token-B fees are paid out /// of this account. @@ -145,8 +152,9 @@ pub struct ClaimAdminFeesAccounts<'info> { mut, associated_token::mint = mint_b, associated_token::authority = pool_authority, + associated_token::token_program = token_program, )] - pub pool_b: Box>, + pub pool_b: Box>, /// Must match the address stored in `Config.admin` (enforced by /// `has_one = admin` above). @@ -159,16 +167,18 @@ pub struct ClaimAdminFeesAccounts<'info> { mut, token::mint = mint_a, token::authority = admin, + token::token_program = token_program, )] - pub admin_token_a: Box>, + 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 admin_token_b: Box>, - pub token_program: Program<'info, Token>, + pub token_program: Interface<'info, TokenInterface>, } 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 15100714..dc55aac6 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,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token::{Mint, Token, TokenAccount}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ @@ -68,34 +68,36 @@ pub struct CreatePoolAccounts<'info> { mint::decimals = 6, mint::authority = pool_authority, )] - pub liquidity_provider_mint: 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_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_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 ea1314d4..4cc56711 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,7 +1,7 @@ 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 crate::{ @@ -182,7 +182,7 @@ pub fn handle_deposit_liquidity( // 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 { @@ -195,7 +195,7 @@ pub fn handle_deposit_liquidity( amount_a, context.accounts.mint_a.decimals, )?; - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new( context.accounts.token_program.key(), TransferChecked { @@ -219,7 +219,7 @@ pub fn handle_deposit_liquidity( &[authority_bump], ]; let signer_seeds = &[&authority_seeds[..]]; - token::mint_to( + token_interface::mint_to( CpiContext::new_with_signer( context.accounts.token_program.key(), MintTo { @@ -274,54 +274,59 @@ pub struct DepositLiquidityAccounts<'info> { ], bump, )] - pub liquidity_provider_mint: 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_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_b: Box>, + pub pool_b: Box>, #[account( init_if_needed, payer = payer, associated_token::mint = liquidity_provider_mint, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub liquidity_provider_token: Box>, + pub liquidity_provider_token: Box>, #[account( mut, associated_token::mint = mint_a, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub token_a: Box>, + pub token_a: Box>, #[account( mut, associated_token::mint = mint_b, associated_token::authority = depositor, + associated_token::token_program = token_program, )] - pub token_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/swap_tokens.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs index 00f67ff4..e8f88cbc 100644 --- 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 @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token::{self, Mint, Token, TokenAccount, TransferChecked}, + token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}, }; use crate::{ @@ -117,21 +117,42 @@ pub fn handle_swap_tokens( .checked_mul(effective_pool_b as u128) .ok_or(AmmError::MathOverflow)?; - // Transfer tokens to the pool + // 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 = &[ - &context.accounts.pool_config.config.to_bytes(), - &context.accounts.mint_a.key().to_bytes(), - &context.accounts.mint_b.key().to_bytes(), + config_bytes.as_ref(), + mint_a_bytes.as_ref(), + mint_b_bytes.as_ref(), 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 input_is_token_a { - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new( context.accounts.token_program.key(), TransferChecked { @@ -144,7 +165,7 @@ pub fn handle_swap_tokens( input_amount, context.accounts.mint_a.decimals, )?; - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { @@ -159,7 +180,7 @@ pub fn handle_swap_tokens( context.accounts.mint_b.decimals, )?; } else { - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { @@ -173,7 +194,7 @@ pub fn handle_swap_tokens( output, context.accounts.mint_a.decimals, )?; - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new( context.accounts.token_program.key(), TransferChecked { @@ -188,21 +209,6 @@ pub fn handle_swap_tokens( )?; } - // The fee always comes off the *input* side, so the admin's claim - // accumulates in the same token they paid the fee in. - 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)?; - } - msg!( "Traded {} tokens ({} after fees) for {} (admin slice {})", input_amount, @@ -272,46 +278,50 @@ pub struct SwapTokensAccounts<'info> { /// The account doing the swap pub trader: Signer<'info>, - 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_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_b: Box>, + 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>, + 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>, + 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/withdraw_liquidity.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/withdraw_liquidity.rs index 991871d2..b08121a4 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,7 +1,7 @@ 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 crate::{ @@ -80,7 +80,7 @@ pub fn handle_withdraw_liquidity( ); // 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 { @@ -95,7 +95,7 @@ pub fn handle_withdraw_liquidity( context.accounts.mint_a.decimals, )?; - token::transfer_checked( + token_interface::transfer_checked( CpiContext::new_with_signer( context.accounts.token_program.key(), TransferChecked { @@ -112,7 +112,7 @@ pub fn handle_withdraw_liquidity( // 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 { @@ -171,57 +171,62 @@ pub struct WithdrawLiquidityAccounts<'info> { ], bump, )] - pub liquidity_provider_mint: 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_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_b: Box>, + pub pool_b: Box>, #[account( mut, associated_token::mint = liquidity_provider_mint, associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub liquidity_provider_token: Box>, + pub liquidity_provider_token: Box>, #[account( init_if_needed, payer = payer, associated_token::mint = mint_a, associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub token_a: Box>, + pub token_a: Box>, #[account( init_if_needed, payer = payer, associated_token::mint = mint_b, associated_token::authority = withdrawer, + associated_token::token_program = token_program, )] - pub token_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>, } From f828c76b22fd52d45cf0012913a77b491570fd4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 14:56:06 +0000 Subject: [PATCH 07/10] chore: add solana-anchor-claude-skill and fix .gitignore to track skills Allow .claude/skills/ through .gitignore so skill files are committed alongside the code they guide. Skills remain ignored otherwise. --- .../skills/solana-anchor-claude-skill/RUST.md | 163 ++++++++++++++++++ .../solana-anchor-claude-skill/SKILL.md | 56 ++++++ .../solana-anchor-claude-skill/TYPESCRIPT.md | 91 ++++++++++ .gitignore | 3 +- 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/solana-anchor-claude-skill/RUST.md create mode 100644 .claude/skills/solana-anchor-claude-skill/SKILL.md create mode 100644 .claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md diff --git a/.claude/skills/solana-anchor-claude-skill/RUST.md b/.claude/skills/solana-anchor-claude-skill/RUST.md new file mode 100644 index 00000000..a6463f4c --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/RUST.md @@ -0,0 +1,163 @@ +# Rust Guidelines (Anchor Programs) + +These guidelines apply to Anchor programs and any Rust crates that use Solana dependencies. Read this alongside the general rules in [SKILL.md](SKILL.md). + +## Anchor Version + +- Write all code like the latest stable Anchor (currently 1.0.2 but there may be a newer version by the time you read this) +- Use LiteSVM and Rust tests for new Anchor programs. `anchor init` uses LiteSVM by default. +- Do not use unnecessary macros that are not needed in the latest stable Anchor +- Don't implement instruction handlers as methods on account structs. There's no reason to tie state to functions, the function is not modifying the state (if we did like OOP, which we don't), and the functions and structs work without doing this, so there's no reason to add implement instruction handlers as methods on account structs. + +## Anchor has silly defaults + +Every project will need an IDL. + +```toml +[features] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +``` + +and if it uses Tokens (like almost every Anchor project) it will need this dependency (insert whatever version is applicable): + +```toml +[dependencies] +anchor-spl = "1.0.2" +``` + +## Project Structure + +- **Never modify the program ID** in `lib.rs` or `Anchor.toml` when making changes +- Create files inside the `state` folder for whatever state is needed +- Create files inside the `instructions` or `handlers` folders (whichever exists) for whatever instruction handlers are needed +- Put Account Constraints in instruction files, but ensure the names end with `AccountConstraints` rather than just naming them the same thing as the function +- Handlers that are only for the admin should be in a new folder called `admin` inside whichever parent folder exists (`instructions/admin/` or `handlers/admin/`) + +## Account Constraints + +- Use a newline after each key in the account constraints struct, so the macro and the matching key/value have some space from other macros and their matching key/value + +## Bumps + +- Use `context.bumps.foo` not `context.bumps.get("foo").unwrap()` - the latter is outdated + +## Data Structures + +- When making structs ensure strings and Vectors have a `max_len` attribute +- Vectors have two numbers for `max_len`: the first is the max length of the vector, the second is the max length of the items in the vector + +## Space Calculation (CRITICAL - NO MAGIC NUMBERS) + +- **Do not use magic numbers anywhere**. I don't want to see `8 + 32` or whatever. +- **Do not make constants for the sizes of various data structures** +- For `space`, use syntax like: `space = SomeStruct::DISCRIMINATOR.len() + SomeStruct::INIT_SPACE,` +- All structs should have `#[derive(InitSpace)]` added to them, to get the `INIT_SPACE` trait +- **DO NOT use magic numbers** + +**Example:** + +```rust +#[derive(InitSpace)] +#[account] +pub struct UserProfile { + pub authority: Pubkey, + + #[max_len(50)] + pub username: String, + + pub bump: u8, +} + +#[derive(Accounts)] +pub struct InitializeProfile<'info> { + #[account( + init, + payer = authority, + space = UserProfile::DISCRIMINATOR.len() + UserProfile::INIT_SPACE, + seeds = [b"profile", authority.key().as_ref()], + bump + )] + pub profile: Account<'info, UserProfile>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, +} +``` + +## Error Handling + +- Return useful error messages +- Write code to handle common errors like insufficient funds, bad values for parameters, and other obvious situations +- All arithmetic in onchain code is `checked_*` — never raw `+ - * /`. Solana's BPF doesn't trap on overflow in release builds; silent wraps are how hacks happen. `checked_*` returns `Option`; force the error with `.ok_or(MyError::MathOverflow)?`. Reserve `saturating_*` for cosmetic/UX display values, never for balances. + +## Onchain Financial Math + +Applies to any code touching money, balances, prices, shares, fees, or token amounts. These rules are non-negotiable. + +- **Integers only — no floats, no fixed-point libraries.** Floats are non-deterministic across platforms (different validators could disagree on state). `fixed::types::I64F64`, `rust_decimal`, `bnum`-fixed-point and similar are out — they add audit surface, burn compute, and hide the rounding/precision decisions you should be making explicitly. Token amounts are integers (base units), prices are ratios of integers. The system is discrete. Production Solana AMMs (Orca, Raydium, Meteora, Saber, Phoenix) all use raw `u128`. If you find yourself reaching for a decimal type, stop — the right tool is `u128` with discipline. +- **Multiply before you divide.** `a * b / c`, not `(a / c) * b`. Division truncates; dividing first throws away precision permanently. +- **Use `u128` (or wider) for intermediate products.** `u64 * u64` overflows at ~1.8e19. Cast both operands to `u128` _before_ multiplying, then narrow the final result with `try_into().map_err(|_| MyError::MathOverflow)?`. +- **Round in the protocol's favour, never the user's.** Value-to-share and share-to-value conversions: user gets floor, protocol gets ceil. Otherwise you leak 1 base unit per transaction forever, and attackers will industrialise it. +- **Validate ranges before doing the math.** Reject zero inputs, `amount > balance`, ratios that would mint zero shares. Cheap, prevents the inflation/donation attack on empty pools and other whole bug classes. +- **Check invariants after the math, not just before.** "K must not decrease" on a swap, "total LP shares == sum of holdings", "reserves >= owed fees". Compute, then `require!()` the invariant. +- **Decimals are tracked, not assumed.** USDC=6, SOL=9, SPL tokens vary. Use `transfer_checked` (carries decimals in the CPI). Reserves hold raw base units; the UI does cosmetic conversion. Never hard-code `* 10^9`. +- **Oracle/price freshness is part of the math.** Check `last_updated_slot` and reject if older than N slots. A stale price means the calculation is wrong. +- **Checks-effects-interactions.** Update state before the token transfer CPI, not after. +- **Treat client-supplied values as adversarial.** If a handler takes `(amount_a, amount_b)`, verify each against onchain state, not against each other. +- **Test the branch the bug lives in.** Standard AMM/lending bugs sit in the _non-empty pool_, _post-swap_, _post-fee_, _rounding-edge_ branches. The happy path almost always works. Write the test that exercises the branch where the bug actually lives. +- **LP shares use different formulas for first deposit vs subsequent.** First deposit: shares = `sqrt(amount_a * amount_b)` (geometric mean bootstraps the pool). Subsequent deposits: shares = `min(amount_a * supply / reserve_a, amount_b * supply / reserve_b)` (proportional to share-of-pool). Using the geometric mean for every deposit is a real, repeated bug — test both branches separately. +- **For integer sqrt, hand-code Newton's method on `u128`** (~15 lines, as Uniswap V2 in Solidity / Saber in Rust do). Don't reach for a fixed-point crate for one sqrt. +- **Slippage protection: accept a `min_output_*` from the user and verify before the CPI.** Swaps, deposits, and withdraws all need it. Without it, sandwich attackers steal value across the price gap they create. +- **Never silently clamp user input to balance.** If a user asks to swap 100 and you clamp to 80 because that's the balance, the user's slippage check passes against the wrong amount. Either fail the instruction or return the actual amount so the client can validate. +- **Use `transfer_checked`, never raw `transfer`.** `transfer_checked` carries the mint and decimals through the CPI, so a wrong-mint or wrong-decimals account causes a CPI failure instead of a silent miscalculation. +- **For token program compatibility, use `anchor_spl::token_interface`** (`InterfaceAccount`, `InterfaceAccount`, `Interface`). The same code then works against both the Classic Token Program and the Token Extensions Program. +- **Oracle freshness uses slots, not unix time.** Slot count is what the runtime guarantees; `Clock::get()?.unix_timestamp` is validator-influenced. Check `last_updated_slot` against `Clock::get()?.slot` and reject if older than N slots. If you must use a unix timestamp (because the oracle only exposes one), state why in a comment. +- **Canonical pubkey ordering for two-asset pools.** Order mints so `mint_a.key() < mint_b.key()` (lexicographic on the 32-byte key). Same pool whether the user passes `(USDC, SOL)` or `(SOL, USDC)`. Enforce in the constraint, don't rely on the client. + +### Escrows, Vaults, and Escape Hatches + +- **Every escrow needs a cancel/withdraw instruction.** An escrow with no cancel locks abandoned offers forever — funds become unrecoverable when the counterparty disappears. The cancel must be callable by the maker (and only the maker) at any time before the trade settles. +- **Don't use `init_if_needed` for an account the wrong party would pay rent for.** Common bug: the taker's instruction lazily creates the maker's destination ATA via `init_if_needed`, so the taker pays the maker's rent. Either require the maker to pre-create their ATA or pass the rent payer explicitly. +- **Update state before the CPI.** Already in the list above, but worth repeating in the vault context: write the new balance/share count first, then transfer. A CPI that re-enters (rare on Solana but possible via callbacks) sees current state, not stale state. + +**Pattern to copy when ratio-clamping (Uniswap V2 style):** + +```rust +let pool_a = pool_a_amount as u128; +let pool_b = pool_b_amount as u128; +let amount_a_u128 = amount_a as u128; +let amount_b_u128 = amount_b as u128; + +// Multiply before divide; u128 prevents overflow. +let amount_b_required = amount_a_u128 + .checked_mul(pool_b).ok_or(ErrorCode::MathOverflow)? + .checked_div(pool_a).ok_or(ErrorCode::MathOverflow)?; + +let (final_a, final_b) = if amount_b_required <= amount_b_u128 { + (amount_a_u128, amount_b_required) +} else { + let amount_a_required = amount_b_u128 + .checked_mul(pool_a).ok_or(ErrorCode::MathOverflow)? + .checked_div(pool_b).ok_or(ErrorCode::MathOverflow)?; + (amount_a_required, amount_b_u128) +}; + +let final_a: u64 = final_a.try_into().map_err(|_| ErrorCode::MathOverflow)?; +let final_b: u64 = final_b.try_into().map_err(|_| ErrorCode::MathOverflow)?; +``` + +## Cargo hygiene + +- Run `cargo clean` after finishing with a Rust project. Anchor `target/` directories accumulate fast (multi-GiB per project). +- If disk usage hits 85%, clean before doing more work. + +## PDA Management + +- Add `pub bump: u8` to every struct stored in PDA +- Save the bumps inside each when the struct inside the PDA is created + +## System Functions + +- When you get the time via Clock, use `Clock::get()?;` rather than `anchor_lang::solana_program::clock` diff --git a/.claude/skills/solana-anchor-claude-skill/SKILL.md b/.claude/skills/solana-anchor-claude-skill/SKILL.md new file mode 100644 index 00000000..21d5db25 --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/SKILL.md @@ -0,0 +1,56 @@ +--- +--- +name: solana-anchor-claude-skill +description: "Use when working on Solana software, including one or more of: Solana client code using TypeScript, Rust libraries that use Solana crates, Anchor programs, Quasar programs, LiteSVM tests, including Rust program files, TypeScript tests, and Anchor.toml configuration. Designed to create minimal, reusable code without unnecessary duplication." +--- + +# Coding Guidelines + +I acknowledge these guidelines have been applied and found that they apply to this project. + +## Key Principles + +**Fight for Truth:** Documentation must match code. Variable names must reflect purpose. Ambiguity is deceptive. "Grep before naming" — verify identifiers exist in source before referencing them in prose. + +**Do the Whole Thing:** Complete implementation with tests and documentation. "The standard isn't 'good enough' - it's 'holy shit, that's done.'" Run `anchor test` before declaring success. + +**Real Tests Required:** Tests must initialize accounts, send transactions, verify state changes, and check balances. Placeholder tests don't count. Do not stop until tests actually call program instruction handlers. + +**Complete Documentation:** Update README.md and CHANGELOG.md when adding features. READMEs should cover purpose, major concepts, testing, setup, and usage — focused and practical. + +## Solana-Specific Standards + +- Use **Solana terminology:** "programs" not "smart contracts," "transaction fees" not "gas," "onchain/offchain" (unhyphenated) +- Use **Token Extensions Program** for newer token program; **Classic Token Program** for older versions +- Use **instruction handlers** for functions; **instructions** for inputs +- Reference official docs: Anchor, LiteSVM, Solana Kite, Solana Kit, Agave (Anza) +- Avoid outdated sources: Solana Labs, Coral XYZ, Project Serum +- Don't use: Yarn (use npm), Switchboard Functions, Clockwork + +## Code Quality + +- **Deletionist approach:** Remove comments that repeat code, unused imports/constants, and redundant doc-comments +- **Configuration comments:** Explain WHY values were set, not just WHAT they are +- **No magic numbers:** Use named variables or reference IDLs instead of hardcoded values +- **Variable naming:** Use full words, plurals for arrays, verb-based function names +- **No placeholders:** Production code shouldn't contain incomplete implementations or "work in progress" markers + +## Language-Specific Guidelines + +The rules above apply to every file in the project. In addition, read the file that matches the language you are editing: + +- **TypeScript** (Solana Kit clients, Solana Kit tests, browser code, anything `.ts`): see [TYPESCRIPT.md](TYPESCRIPT.md) +- **Rust** (Anchor programs, LiteSVM tests, Solana crates, anything `.rs`): see [RUST.md](RUST.md) + +If a task touches both sides, read both. + +## Git commits + +Do not add "Co-Authored-By: Claude" or similar attribution when creating git commits. + +## Success Criteria + +✅ Tests pass (`anchor test`) +✅ Real integration tests implemented +✅ README and CHANGELOG updated +✅ All code truthful and verifiable diff --git a/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md b/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md new file mode 100644 index 00000000..ed11e40e --- /dev/null +++ b/.claude/skills/solana-anchor-claude-skill/TYPESCRIPT.md @@ -0,0 +1,91 @@ +# TypeScript Guidelines + +These guidelines apply to TypeScript unit tests, browser code, Solana Kit clients, and any other places where TypeScript is used in the project. Read this alongside the general rules in [SKILL.md](SKILL.md). + +## General TypeScript + +Use `type: module` in `package.json` files. + +Avoid using a `tsconfig.json` unless it's needed, as we use `tsx` to run most typescript and it doesn't usually need one. If you do need a `tsconfig.json`, state why at the top of the file, and you can use the most modern version of ECMAScript/JavaScript you want - up to say 2023. + +## Async/await + +Favor `async`/`await` and `try/catch` over `.then()` or `.catch()` or using callbacks for flow control. `tsx` has top level `await` so you don't need to wrap top level `await` in IIFEs. + +## Type System + +- **Always use `Array`**, never use `item[]` for consistency with other generic syntax like `Promise`, `Map`, and `Set` +- **Don't use `any`** + +## Comments + +- Most comments should use `//` and be above (not beside) the code +- The only exception is JSDoc/TSDoc comments which MUST use `/* */` syntax + +## Solana-Specific TypeScript + +- Don't make new `@solana/web3.js` version 1 code. Do not make new code using `@coral-xyz/anchor` package. Don't replace Solana Kit with web3.js version 1 code. web3.js version 1 is legacy and should be eventually removed. Solana Kit used to be called web3.js version 2. Use Solana Kit, preferably via Solana Kite. +- Use Kite's `connection.getPDAAndBump()` to turn seeds into PDAs and bumps +- There is no need to use offsets that you set to decode Solana account data - either download an npm package for the program like `@solana-program/token` for the token program or make one using Codama. +- In Solana Kit, you make instructions by making TS clients from IDLs using Codama. You can easily make Codama clients for installed IDLs using: + +`npx create-codama-clients` + +- Do not use the `bs58` npm package. + +Don't do this: + +```typescript +import bs58 from "bs58"; +const signature = bs58.encode(signatureBytes); +``` + +Do this instead: + +```typescript +import { getBase58Decoder } from "@solana/codecs"; +const signature = getBase58Decoder().decode(signatureBytes); +``` + +Yes, `bs58` and `@solana/codecs` packages have different concepts of 'encode' and 'decode'. + +## Unit Tests + +- Create unit tests in TS in the `tests` directory +- Use the Node.js inbuilt test and assertion libraries (then start the tests using `tsx` instead of `ts-mocha`) + +**Unit testing imports:** + +```typescript +import { before, describe, test } from "node:test"; +import assert from "node:assert"; +``` + +- Use `test` rather than `it` + +## Thrown object handling + +- JavaScript allows arbitrary items - strings, array, numbers etc to be 'thrown'. However you can assume that any non-Error item that is thrown is an programmer error. Handle it like this (including the comment since most TypeScript developers don't know this): + +```ts +// In JS it's possible to throw *anything*. A sensible programmer +// will only throw Errors but we must still check to satisfy +// TypeScript (and flag any craziness) +const ensureError = function (thrownObject: unknown): Error { + if (thrownObject instanceof Error) { + return thrownObject; + } + return new Error(`Non-Error thrown: ${String(thrownObject)}`); +}; +``` + +and + +```ts +try { + // some code that might throw +} catch (thrownObject) { + const error = ensureError(thrownObject); + throw error; +} +``` diff --git a/.gitignore b/.gitignore index d137957d..4f211c70 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ node_modules/ /target deploy -.claude +.claude/* +!.claude/skills/ From 28bb47c3e339f8ca22e9cbfb296db7c372704d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 01:41:32 +0000 Subject: [PATCH 08/10] fix(token-swap): audit pass - checked_sub, basis-points constant, admin folder, test fix - Fix test compile break: withdraw accounts field is `withdrawer`, not `depositor` (test_swap.rs was passing the old field name) - Replace raw `pool.amount - admin_fees_owed` with checked_sub across swap_tokens, deposit_liquidity, withdraw_liquidity - a BPF release underflow would wrap to a giant effective reserve - Extract the basis-points divisor (10_000) into a named BASIS_POINTS_DIVISOR constant; use it in the swap fee math and create_config constraints - Move the admin-only claim_admin_fees handler into instructions/admin/ --- .../programs/token-swap/src/constants.rs | 7 ++++ .../{ => admin}/claim_admin_fees.rs | 0 .../token-swap/src/instructions/admin/mod.rs | 3 ++ .../src/instructions/create_config.rs | 10 ++++-- .../src/instructions/deposit_liquidity.rs | 12 +++++-- .../token-swap/src/instructions/mod.rs | 4 +-- .../src/instructions/swap_tokens.rs | 33 +++++++++++++++---- .../src/instructions/withdraw_liquidity.rs | 16 +++++++-- .../programs/token-swap/tests/test_swap.rs | 4 +-- 9 files changed, 71 insertions(+), 18 deletions(-) rename tokens/token-swap/anchor/programs/token-swap/src/instructions/{ => admin}/claim_admin_fees.rs (100%) create mode 100644 tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/mod.rs 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 e566c93e..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,13 @@ 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"; diff --git a/tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/claim_admin_fees.rs similarity index 100% rename from tokens/token-swap/anchor/programs/token-swap/src/instructions/claim_admin_fees.rs rename to tokens/token-swap/anchor/programs/token-swap/src/instructions/admin/claim_admin_fees.rs 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_config.rs b/tokens/token-swap/anchor/programs/token-swap/src/instructions/create_config.rs index b28234a7..e377fe15 100644 --- 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 @@ -1,6 +1,10 @@ use anchor_lang::prelude::*; -use crate::{constants::CONFIG_SEED, errors::*, state::Config}; +use crate::{ + constants::{BASIS_POINTS_DIVISOR, CONFIG_SEED}, + errors::*, + state::Config, +}; pub fn handle_create_config( mut context: Context, @@ -26,8 +30,8 @@ pub struct CreateConfigAccounts<'info> { space = Config::DISCRIMINATOR.len() + Config::INIT_SPACE, seeds = [CONFIG_SEED], bump, - constraint = fee < 10000 @ AmmError::InvalidFee, - constraint = admin_share_bps < 10000 @ AmmError::AdminShareTooHigh, + 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>, 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 4cc56711..ce5431c4 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 @@ -66,8 +66,16 @@ pub fn handle_deposit_liquidity( let pool_a = &context.accounts.pool_a; let pool_b = &context.accounts.pool_b; let pool_config = &context.accounts.pool_config; - let effective_pool_a = pool_a.amount - pool_config.admin_fees_owed_a; - let effective_pool_b = pool_b.amount - pool_config.admin_fees_owed_b; + // 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 = effective_pool_a == 0 && effective_pool_b == 0; (amount_a, amount_b) = if pool_creation { 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 c0a9ab8c..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,11 @@ -mod claim_admin_fees; +mod admin; mod create_config; mod create_pool; mod deposit_liquidity; mod swap_tokens; mod withdraw_liquidity; -pub use claim_admin_fees::*; +pub use admin::*; pub use create_config::*; pub use create_pool::*; pub use deposit_liquidity::*; 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 index e8f88cbc..8ae3c8c0 100644 --- 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 @@ -5,7 +5,7 @@ use anchor_spl::{ }; use crate::{ - constants::{AUTHORITY_SEED, CONFIG_SEED}, + constants::{AUTHORITY_SEED, BASIS_POINTS_DIVISOR, CONFIG_SEED}, errors::*, state::{Config, PoolConfig}, }; @@ -42,12 +42,12 @@ pub fn handle_swap_tokens( let fee_amount = (input_amount as u128) .checked_mul(config.fee as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(10_000) + .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(10_000) + .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` @@ -68,8 +68,17 @@ pub fn handle_swap_tokens( let pool_a = &context.accounts.pool_a; let pool_b = &context.accounts.pool_b; let pool_config = &context.accounts.pool_config; - let effective_pool_a = pool_a.amount - pool_config.admin_fees_owed_a; - let effective_pool_b = pool_b.amount - pool_config.admin_fees_owed_b; + // 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) @@ -231,8 +240,18 @@ pub fn handle_swap_tokens( 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 - pool_config.admin_fees_owed_a; - let effective_pool_b_after = context.accounts.pool_b.amount - pool_config.admin_fees_owed_b; + 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)?; 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 b08121a4..28d25c0c 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 @@ -31,8 +31,20 @@ pub fn handle_withdraw_liquidity( // 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; - let effective_pool_a = context.accounts.pool_a.amount - pool_config.admin_fees_owed_a; - let effective_pool_b = context.accounts.pool_b.amount - pool_config.admin_fees_owed_b; + // 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) 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 7783698a..5bbd9ab0 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 @@ -421,7 +421,7 @@ fn test_withdraw_liquidity() { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, - depositor: ts.admin.pubkey(), + withdrawer: ts.admin.pubkey(), liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, @@ -1239,7 +1239,7 @@ fn withdraw_ix_with_min( config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, - depositor: ts.admin.pubkey(), + withdrawer: ts.admin.pubkey(), liquidity_provider_mint: ts.liquidity_provider_mint, mint_a: ts.mint_a, mint_b: ts.mint_b, From 17a62098fb9f17762f03281d78b8ef7674e754dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 01:55:27 +0000 Subject: [PATCH 09/10] refactor(token-swap): AccountConstraints naming, UncheckedAccount, quasar parity Anchor (F5/F6): - Rename account-constraint structs from `...Accounts` to `...AccountConstraints` (handlers, lib.rs, tests) - Migrate unchecked `AccountInfo` to `UncheckedAccount` (clears the deprecation warnings; modern stable-Anchor idiom) - Drop now-unnecessary `mut` on create_config / create_pool contexts Quasar (F7) - port the correctness work the mirror was missing: - checked arithmetic everywhere (swap fee/effective-reserve math, withdraw effective reserves + total liquidity, deposit reserves) - extract the basis-points divisor into BASIS_POINTS_DIVISOR - Checks-Effects-Interactions: write state before transfer CPIs in swap_tokens and claim_admin_fees - fail fast instead of silently clamping deposit/swap input to balance - fix LP-mint formula: sqrt(a*b) only bootstraps the first deposit; subsequent deposits mint min(a*supply/pool_a, b*supply/pool_b) - "on-chain" -> "onchain" Verified: anchor 18/18 LiteSVM tests pass, quasar 4/4 tests pass, both programs build with cargo build-sbf. --- .../instructions/admin/claim_admin_fees.rs | 6 +- .../src/instructions/create_config.rs | 6 +- .../src/instructions/create_pool.rs | 6 +- .../src/instructions/deposit_liquidity.rs | 6 +- .../src/instructions/swap_tokens.rs | 6 +- .../src/instructions/withdraw_liquidity.rs | 6 +- .../anchor/programs/token-swap/src/lib.rs | 12 +- .../programs/token-swap/tests/test_swap.rs | 38 +++--- .../src/instructions/claim_admin_fees.rs | 28 ++-- .../quasar/src/instructions/create_config.rs | 6 +- .../src/instructions/deposit_liquidity.rs | 70 ++++++++-- .../quasar/src/instructions/swap_tokens.rs | 128 +++++++++++------- .../src/instructions/withdraw_liquidity.rs | 20 ++- tokens/token-swap/quasar/src/lib.rs | 4 + 14 files changed, 218 insertions(+), 124 deletions(-) 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 index 985e74e5..78ddb179 100644 --- 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 @@ -19,7 +19,7 @@ use crate::{ /// `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<()> { +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; @@ -98,7 +98,7 @@ pub fn handle_claim_admin_fees(context: Context) -> Resu } #[derive(Accounts)] -pub struct ClaimAdminFeesAccounts<'info> { +pub struct ClaimAdminFeesAccountConstraints<'info> { #[account( seeds = [CONFIG_SEED], bump, @@ -130,7 +130,7 @@ pub struct ClaimAdminFeesAccounts<'info> { ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, pub mint_a: Box>, 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 index e377fe15..8673b2a9 100644 --- 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 @@ -7,7 +7,7 @@ use crate::{ }; pub fn handle_create_config( - mut context: Context, + context: Context, fee: u16, admin_share_bps: u16, ) -> Result<()> { @@ -23,7 +23,7 @@ pub fn handle_create_config( #[derive(Accounts)] #[instruction(fee: u16, admin_share_bps: u16)] -pub struct CreateConfigAccounts<'info> { +pub struct CreateConfigAccountConstraints<'info> { #[account( init, payer = payer, @@ -37,7 +37,7 @@ pub struct CreateConfigAccounts<'info> { /// The admin of the AMM /// CHECK: Read only, delegatable creation - pub admin: AccountInfo<'info>, + pub admin: UncheckedAccount<'info>, /// The account paying for all rents #[account(mut)] 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 dc55aac6..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 @@ -10,7 +10,7 @@ use crate::{ state::{Config, PoolConfig}, }; -pub fn handle_create_pool(mut context: Context) -> Result<()> { +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(); @@ -22,7 +22,7 @@ pub fn handle_create_pool(mut context: Context) -> Result<() } #[derive(Accounts)] -pub struct CreatePoolAccounts<'info> { +pub struct CreatePoolAccountConstraints<'info> { #[account( seeds = [CONFIG_SEED], bump, @@ -53,7 +53,7 @@ pub struct CreatePoolAccounts<'info> { ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, #[account( init, 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 ce5431c4..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 @@ -28,7 +28,7 @@ fn integer_sqrt(n: u128) -> u128 { } pub fn handle_deposit_liquidity( - context: Context, + context: Context, amount_a: u64, amount_b: u64, minimum_lp_tokens_out: u64, @@ -244,7 +244,7 @@ pub fn handle_deposit_liquidity( } #[derive(Accounts)] -pub struct DepositLiquidityAccounts<'info> { +pub struct DepositLiquidityAccountConstraints<'info> { #[account( seeds = [ pool_config.config.as_ref(), @@ -267,7 +267,7 @@ pub struct DepositLiquidityAccounts<'info> { ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, /// The account paying for all rents pub depositor: Signer<'info>, 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 index 8ae3c8c0..11c35961 100644 --- 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 @@ -11,7 +11,7 @@ use crate::{ }; pub fn handle_swap_tokens( - context: Context, + context: Context, input_is_token_a: bool, input_amount: u64, min_output_amount: u64, @@ -261,7 +261,7 @@ pub fn handle_swap_tokens( } #[derive(Accounts)] -pub struct SwapTokensAccounts<'info> { +pub struct SwapTokensAccountConstraints<'info> { #[account( seeds = [CONFIG_SEED], bump, @@ -292,7 +292,7 @@ pub struct SwapTokensAccounts<'info> { ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, /// The account doing the swap pub trader: Signer<'info>, 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 28d25c0c..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 @@ -11,7 +11,7 @@ use crate::{ }; pub fn handle_withdraw_liquidity( - context: Context, + context: Context, amount: u64, minimum_token_a_out: u64, minimum_token_b_out: u64, @@ -140,7 +140,7 @@ pub fn handle_withdraw_liquidity( } #[derive(Accounts)] -pub struct WithdrawLiquidityAccounts<'info> { +pub struct WithdrawLiquidityAccountConstraints<'info> { #[account( seeds = [CONFIG_SEED], bump, @@ -169,7 +169,7 @@ pub struct WithdrawLiquidityAccounts<'info> { ], bump, )] - pub pool_authority: AccountInfo<'info>, + pub pool_authority: UncheckedAccount<'info>, pub withdrawer: Signer<'info>, 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 e5f73cdf..2f5d10ce 100644 --- a/tokens/token-swap/anchor/programs/token-swap/src/lib.rs +++ b/tokens/token-swap/anchor/programs/token-swap/src/lib.rs @@ -13,19 +13,19 @@ pub mod swap_example { use super::*; pub fn create_config( - context: Context, + 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, @@ -39,7 +39,7 @@ pub mod swap_example { } pub fn withdraw_liquidity( - context: Context, + context: Context, amount: u64, minimum_token_a_out: u64, minimum_token_b_out: u64, @@ -53,7 +53,7 @@ pub mod swap_example { } pub fn swap_tokens( - context: Context, + context: Context, input_is_token_a: bool, input_amount: u64, min_output_amount: u64, @@ -66,7 +66,7 @@ pub mod swap_example { ) } - pub fn claim_admin_fees(context: Context) -> Result<()> { + pub fn claim_admin_fees(context: Context) -> Result<()> { instructions::handle_claim_admin_fees(context) } } 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 5bbd9ab0..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 @@ -128,7 +128,7 @@ fn full_setup() -> TestSetup { let create_config_ix = Instruction::new_with_bytes( program_id, &swap_example::instruction::CreateConfig { fee, admin_share_bps }.data(), - swap_example::accounts::CreateConfigAccounts { + swap_example::accounts::CreateConfigAccountConstraints { config: config_key, admin: admin.pubkey(), payer: payer.pubkey(), @@ -148,7 +148,7 @@ fn full_setup() -> TestSetup { let create_pool_ix = Instruction::new_with_bytes( program_id, &swap_example::instruction::CreatePool {}.data(), - swap_example::accounts::CreatePoolAccounts { + swap_example::accounts::CreatePoolAccountConstraints { config: config_key, pool_config: pool_config_key, pool_authority, @@ -203,7 +203,7 @@ fn test_create_config() { let create_config_ix = Instruction::new_with_bytes( program_id, &swap_example::instruction::CreateConfig { fee, admin_share_bps }.data(), - swap_example::accounts::CreateConfigAccounts { + swap_example::accounts::CreateConfigAccountConstraints { config: config_key, admin: admin.pubkey(), payer: payer.pubkey(), @@ -242,7 +242,7 @@ fn test_deposit_liquidity() { minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -289,7 +289,7 @@ fn test_swap_a_to_b() { minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -328,7 +328,7 @@ fn test_swap_a_to_b() { min_output_amount: 100, } .data(), - swap_example::accounts::SwapTokensAccounts { + swap_example::accounts::SwapTokensAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -376,7 +376,7 @@ fn test_withdraw_liquidity() { minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -417,7 +417,7 @@ fn test_withdraw_liquidity() { minimum_token_b_out: 0, } .data(), - swap_example::accounts::WithdrawLiquidityAccounts { + swap_example::accounts::WithdrawLiquidityAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -462,7 +462,7 @@ fn deposit_and_swap_a_to_b(ts: &mut TestSetup, deposit_a: u64, deposit_b: u64, s minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -497,7 +497,7 @@ fn deposit_and_swap_a_to_b(ts: &mut TestSetup, deposit_a: u64, deposit_b: u64, s min_output_amount: 1, } .data(), - swap_example::accounts::SwapTokensAccounts { + swap_example::accounts::SwapTokensAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -531,7 +531,7 @@ fn claim_admin_fees_ix(ts: &TestSetup) -> Instruction { Instruction::new_with_bytes( ts.program_id, &swap_example::instruction::ClaimAdminFees {}.data(), - swap_example::accounts::ClaimAdminFeesAccounts { + swap_example::accounts::ClaimAdminFeesAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -558,7 +558,7 @@ fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { min_output_amount: 1, } .data(), - swap_example::accounts::SwapTokensAccounts { + swap_example::accounts::SwapTokensAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -672,7 +672,7 @@ fn test_claim_admin_fees() { let claim_ix_again = Instruction::new_with_bytes( ts.program_id, &swap_example::instruction::ClaimAdminFees {}.data(), - swap_example::accounts::ClaimAdminFeesAccounts { + swap_example::accounts::ClaimAdminFeesAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -733,7 +733,7 @@ fn test_claim_admin_fees_rejects_non_admin() { let claim_ix = Instruction::new_with_bytes( ts.program_id, &swap_example::instruction::ClaimAdminFees {}.data(), - swap_example::accounts::ClaimAdminFeesAccounts { + swap_example::accounts::ClaimAdminFeesAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -776,7 +776,7 @@ fn deposit_ix(ts: &TestSetup, amount_a: u64, amount_b: u64) -> Instruction { minimum_lp_tokens_out: 0, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -950,7 +950,7 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { min_output_amount: 1, } .data(), - swap_example::accounts::SwapTokensAccounts { + swap_example::accounts::SwapTokensAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -1164,7 +1164,7 @@ fn swap_a_to_b_ix(ts: &TestSetup, input_amount: u64, min_output_amount: u64) -> min_output_amount, } .data(), - swap_example::accounts::SwapTokensAccounts { + swap_example::accounts::SwapTokensAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, @@ -1199,7 +1199,7 @@ fn deposit_ix_with_min_lp( minimum_lp_tokens_out, } .data(), - swap_example::accounts::DepositLiquidityAccounts { + swap_example::accounts::DepositLiquidityAccountConstraints { pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, depositor: ts.admin.pubkey(), @@ -1235,7 +1235,7 @@ fn withdraw_ix_with_min( minimum_token_b_out, } .data(), - swap_example::accounts::WithdrawLiquidityAccounts { + swap_example::accounts::WithdrawLiquidityAccountConstraints { config: ts.config_key, pool_config: ts.pool_config_key, pool_authority: ts.pool_authority, diff --git a/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs index fa9b4689..7c83f4c8 100644 --- a/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs +++ b/tokens/token-swap/quasar/src/instructions/claim_admin_fees.rs @@ -67,6 +67,21 @@ pub fn handle_claim_admin_fees( 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 @@ -91,18 +106,5 @@ pub fn handle_claim_admin_fees( .invoke_signed(seeds)?; } - // Reset the accumulators. Done after the transfers so a failed CPI - // leaves the on-chain bookkeeping intact (the admin can retry). - 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, - }); - Ok(()) } diff --git a/tokens/token-swap/quasar/src/instructions/create_config.rs b/tokens/token-swap/quasar/src/instructions/create_config.rs index 06992920..a5e7101d 100644 --- a/tokens/token-swap/quasar/src/instructions/create_config.rs +++ b/tokens/token-swap/quasar/src/instructions/create_config.rs @@ -1,5 +1,5 @@ use { - crate::{state::{Config, ConfigInner}, ConfigPda}, + crate::{state::{Config, ConfigInner}, ConfigPda, BASIS_POINTS_DIVISOR}, quasar_lang::prelude::*, }; @@ -23,13 +23,13 @@ pub fn handle_create_config( fee: u16, admin_share_bps: u16, ) -> Result<(), ProgramError> { - if fee >= 10000 { + 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 >= 10000 { + if admin_share_bps as u64 >= BASIS_POINTS_DIVISOR { return Err(ProgramError::InvalidArgument); } accounts.config.set_inner(ConfigInner { diff --git a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs index e2601e83..b476d2e3 100644 --- a/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -78,17 +78,32 @@ pub fn handle_deposit_liquidity( amount_b: u64, bumps: &DepositLiquidityAccountsBumps, ) -> Result<(), ProgramError> { - // Clamp to what the depositor actually has. + // 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(); - 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 }; + 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; // 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. - let pool_a_amount = accounts.pool_a.amount() - accounts.pool_config.admin_fees_owed_a(); - let pool_b_amount = accounts.pool_b.amount() - accounts.pool_config.admin_fees_owed_b(); + // 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 { @@ -108,18 +123,43 @@ 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. diff --git a/tokens/token-swap/quasar/src/instructions/swap_tokens.rs b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs index 39cab41d..897a9016 100644 --- a/tokens/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/tokens/token-swap/quasar/src/instructions/swap_tokens.rs @@ -1,7 +1,7 @@ use { crate::{ state::{Config, PoolConfig, PoolConfigInner}, - ConfigPda, PoolAuthorityPda, PoolPda, + ConfigPda, PoolAuthorityPda, PoolPda, BASIS_POINTS_DIVISOR, }, quasar_lang::prelude::*, quasar_spl::prelude::*, @@ -56,14 +56,19 @@ pub fn handle_swap_tokens( min_output_amount: u64, bumps: &SwapTokensAccountsBumps, ) -> Result<(), ProgramError> { - // Clamp input to what the trader has. - let input = if input_is_token_a { - let trader_a = accounts.token_a.amount(); - if input_amount > trader_a { trader_a } else { input_amount } + // 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 { - let trader_b = accounts.token_b.amount(); - if input_amount > trader_b { trader_b } else { input_amount } + 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 @@ -72,23 +77,39 @@ pub fn handle_swap_tokens( // 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. - let fee = accounts.config.fee() as u64; - let admin_share_bps = accounts.config.admin_share_bps() as u64; - let fee_amount = input * fee / 10000; - let admin_portion = fee_amount * admin_share_bps / 10000; - let taxed_input = input - fee_amount; + // `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. + // 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 - owed_a; - let effective_pool_b = pool_b_raw - 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) @@ -123,7 +144,35 @@ pub fn handle_swap_tokens( .checked_mul(effective_pool_b as u128) .ok_or(ProgramError::ArithmeticOverflow)?; - // Build authority signer seeds. + // 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] = &[ @@ -154,39 +203,24 @@ pub fn handle_swap_tokens( .invoke()?; } - // Accumulate the admin's slice on the *input* side. The fee always - // comes off the input, so the admin's claim grows in the input token. - 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, - }); - // 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) - + if input_is_token_a { input as u128 } else { 0 } - - if !input_is_token_a { output as u128 } else { 0 }; + .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) - + if !input_is_token_a { input as u128 } else { 0 } - - if input_is_token_a { output as u128 } else { 0 }; - let new_effective_a = new_pool_a_raw - (new_owed_a as u128); - let new_effective_b = new_pool_b_raw - (new_owed_b 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)?; diff --git a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs index 56c5ea53..c5354245 100644 --- a/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs +++ b/tokens/token-swap/quasar/src/instructions/withdraw_liquidity.rs @@ -77,9 +77,23 @@ pub fn handle_withdraw_liquidity( // 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`. - let effective_pool_a = accounts.pool_a.amount() - accounts.pool_config.admin_fees_owed_a(); - let effective_pool_b = accounts.pool_b.amount() - accounts.pool_config.admin_fees_owed_b(); - let total_liquidity = accounts.liquidity_provider_mint.supply() + crate::MINIMUM_LIQUIDITY; + // 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(effective_pool_a as u128) diff --git a/tokens/token-swap/quasar/src/lib.rs b/tokens/token-swap/quasar/src/lib.rs index ee680af5..ba3271de 100644 --- a/tokens/token-swap/quasar/src/lib.rs +++ b/tokens/token-swap/quasar/src/lib.rs @@ -12,6 +12,10 @@ 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. From f1073650e8d7a28de147caa9dbea6a3883b39297 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 02:37:34 +0000 Subject: [PATCH 10/10] test(token-swap): add comprehensive quasar integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 11 new integration tests using QuasarSvm covering all 6 instruction handlers: create_pool, deposit_liquidity (initial, subsequent, insufficient), withdraw_liquidity, swap_tokens (A→B, B→A, slippage guard), and claim_admin_fees (happy path + unauthorised rejection). Key correctness points: - Non-PDA init(idempotent) accounts (pool_a/b, lp_token, recv_a/b, token_out) need is_signer=true in AccountMeta so the inner create_account/allocate CPIs can satisfy the writable_signer requirement without a PDA signer list. - depositor/trader must be separate from payer in each instruction; using the same pubkey in a readonly and mutable slot triggers AccountBorrowFailed. All instructions use env.payer (funded in setup_pool) as the fee payer. https://claude.ai/code/session_01DFHVK3tVoPfz6MJMwEAGBf --- tokens/token-swap/quasar/src/tests.rs | 767 ++++++++++++++++++++++++-- 1 file changed, 724 insertions(+), 43 deletions(-) diff --git a/tokens/token-swap/quasar/src/tests.rs b/tokens/token-swap/quasar/src/tests.rs index b7048475..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,119 +33,793 @@ fn empty(address: Pubkey) -> Account { } } +/// 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 + 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 } +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(); + + // 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); + + // 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 system_program = quasar_svm::system_program::ID; - - // Derive the singleton Config PDA (seeds = [b"config"]). let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - - // Uniswap V2's classic 1/6 split for the admin slice. let data = build_create_config_data(30, 1667); - 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(system_program.into(), false), + 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)], ); - - assert!( - result.is_ok(), - "create_config failed: {:?}", - result.raw_result - ); + assert!(result.is_ok(), "create_config failed: {:?}", result.raw_result); println!(" CREATE CONFIG CU: {}", result.compute_units_consumed); } #[test] fn test_create_config_invalid_fee() { let mut svm = setup(); - let payer = Pubkey::new_unique(); let admin = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - - // Fee >= 10000 should fail. - let data = build_create_config_data(10000, 1667); - + 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(system_program.into(), false), + 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)], ); - - assert!( - !result.is_ok(), - "create_config should have failed with invalid fee" - ); + assert!(!result.is_ok(), "create_config should have failed with invalid fee"); println!(" CREATE CONFIG (invalid fee) correctly rejected"); } #[test] fn test_create_config_invalid_admin_share() { let mut svm = setup(); - let payer = Pubkey::new_unique(); let admin = Pubkey::new_unique(); - let system_program = quasar_svm::system_program::ID; - let (config_pda, _) = Pubkey::find_program_address(&[b"config"], &crate::ID.into()); - - // admin_share_bps >= 10000 should fail. - let data = build_create_config_data(30, 10000); - + 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(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(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!( - !result.is_ok(), - "create_config should have failed with admin_share_bps >= 10000" + lp2_bal > 0 && lp2_bal <= lp1_bal, + "second depositor LP={} should be > 0 and <= first LP={}", + lp2_bal, lp1_bal ); - println!(" CREATE CONFIG (invalid admin share) correctly rejected"); + 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!( + 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)], + ); + assert!(!r.is_ok(), "unauthorized claim_admin_fees should fail"); + println!(" CLAIM FEES unauthorized correctly rejected"); }