Skip to content

feat(token-swap): production-shape AMM example - protocol fees, slippage, correctness fixes, u128 math#35

Closed
mikemaccana-edwardbot wants to merge 10 commits into
quicknode:mainfrom
mikemaccana:chore/amm-naming-alignment
Closed

feat(token-swap): production-shape AMM example - protocol fees, slippage, correctness fixes, u128 math#35
mikemaccana-edwardbot wants to merge 10 commits into
quicknode:mainfrom
mikemaccana:chore/amm-naming-alignment

Conversation

@mikemaccana-edwardbot

@mikemaccana-edwardbot mikemaccana-edwardbot commented May 20, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Brings the constant-product AMM example in tokens/token-swap/ to production shape: purpose-led naming throughout, a configurable admin protocol-fee mechanism, slippage protection on every state-changing handler, correctness fixes on deposit_liquidity and the LP-mint formula, a constant-product invariant check on swap, and a full migration from fixed-point (I64F64) to u128 + checked_* integer arithmetic. The fixed crate dependency is removed entirely.

Correctness fixes

  • deposit_liquidity ratio bug. The non-empty-pool branch was using K = pool_a * pool_b as a "ratio" and then multiplying or dividing the caller's amount by it — nonsense math, unexercised because every prior test 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 that lies on the current price line. Uses effective reserves (vault balance minus the admin's owed fees) so accumulated admin fees don't distort the LP price. Adds AmmError::DepositAmountTooSmall for sub-base-unit deposits that clamp to zero.

  • LP-mint formula bug. deposit_liquidity was using sqrt(amount_a * amount_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) against effective reserves.

    The initial-deposit sqrt is computed with a u128 Newton's-method integer_sqrt helper (no more I64F64::sqrt).

  • claim_admin_fees no-signal bug. Used to silently succeed when both fee accumulators were zero. Now reverts with AmmError::NothingToClaim. (Also unblocks tests: two byte-identical claim transactions share a signature; tests expire_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 slice. PoolConfig.admin_fees_owed_a / _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 from both sides, authorised via has_one = admin against Config. deposit_liquidity and withdraw_liquidity use effective reserves so the admin's owed slice doesn't drag LP shares.
  • Slippage protection on every state-changing handler. Pass 0 to opt out. Withdraw bounds are checked before any transfers.
    • swap_tokens: existing min_output_amount kept; error renamed OutputTooSmallSlippageExceeded.
    • deposit_liquidity: new minimum_lp_tokens_out. Pairs with the ratio-clamp upper bound for full slippage protection (clamp protects against over-spending either token; floor protects against under-receiving LP tokens). Reverts with AmmError::DepositBelowMinimum.
    • withdraw_liquidity: new minimum_token_a_out and minimum_token_b_out. Reverts with AmmError::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, ~1.8e19 base units) and compared against the pre-trade invariant; the trade reverts if k decreases. Defence-in-depth against curve-math regressions.

Renames & structural changes

Rule applied throughout: purpose over implementation, clarity over convention. A first-time reader should be able to infer the role of each account, struct, field, and parameter from its name.

Account fields:

  • pool_account_a / pool_account_bpool_a / pool_b (the reserves are "the pool" in natural English; vault_* imported yield-bearing-vault DeFi vocabulary that doesn't apply).
  • mint_liquidityliquidity_provider_mint (the mint that issues LP tokens; "LP" expanded for first-time readers).
  • depositor_account_* / trader_account_*token_a / token_b / liquidity_provider_token (the redundant owner prefix duplicates the signer field already on the Accounts struct).

Types:

  • State struct AmmConfig (it holds program-level config — admin, fee, bump — not "the AMM").
  • State struct PoolPoolConfig (it holds per-pool config / identity — config, mint_a, mint_b, bump — not pool state).
  • Error type TutorialErrorAmmError.
  • Accounts derive structs renamed to <HandlerName>Accounts (e.g. CreateAmmCreateConfigAccounts). Structs name what they are (the bag of accounts for handler X), not a verb they pretend to do.

Handlers:

  • create_ammcreate_config (names the account it creates, matching CreateConfigAccounts).
  • swap_exact_tokens_for_tokensswap_tokens (the Uniswap-style disambiguator earns no keep without a swap_tokens_for_exact_tokens sibling).
  • swap parameter swap_a: boolinput_is_token_a: bool (purpose, not internal direction flag).

Config singleton:

  • Config is now a singleton with seeds [b"config"]. The id: Pubkey parameter is removed from create_config. Real DEXes ship one program per AMM (Phoenix, Raydium) — the id was leftover complexity. Calling create_config twice fails because the account already exists.
  • CONFIG_SEED: &[u8] = b"config" added to anchor constants.rs; quasar's AmmPda renamed to ConfigPda with #[seeds(b"config")]; amm: Address generics in PoolPda / PoolAuthorityPda / LiquidityMintPda renamed to config: Address.
  • Pool seeds [config, mint_a, mint_b] are unchanged.

Math discipline

  • All money math is now 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 and fee formula rewritten in u128.
  • withdraw_liquidity proportional-withdraw formula rewritten in u128.
  • deposit_liquidity already used u128.
  • integer_sqrt (u128 Newton's method) replaces I64F64::sqrt for the initial-deposit branch.
  • AmmError::MathOverflow added for the checked_* path.
  • fixed = "1.27.0" removed from Cargo.toml.

Documentation

  • README.md rewritten to match the code: identifiers, singleton Config, admin protocol-fee design, slippage args, corrected deposit / LP-mint math.
  • New NVDAx/USDC lifecycle walkthrough with four actors (admin, LP, retail trader, arbitrageur), spelling out every handler call (Accounts struct, fields, args) and the arithmetic for each swap / deposit / withdraw / claim. All identifiers grep-checked against src/.

Tests

18/18 Rust + LiteSVM integration tests passing. Coverage:

  • matching-ratio deposit uses both amounts in full;
  • deposit clamps excess on either side (A or B);
  • deposit after a swap honours the shifted effective ratio (not the raw vault ratio);
  • sub-base-unit deposit reverts with DepositAmountTooSmall;
  • subsequent-deposit LP-mint is proportional to share of pool;
  • post-swap deposit LP-mint uses effective reserves (admin fees excluded);
  • swap slippage revert (and zero-min still succeeds);
  • deposit slippage revert (with exact-floor sanity check);
  • withdraw slippage revert on both sides;
  • constant-product invariant preservation across swaps;
  • admin-fee accrual and claim across multiple swaps;
  • NothingToClaim revert on a no-op claim.

Run with: cargo test --manifest-path tokens/token-swap/anchor/programs/token-swap/Cargo.toml --tests.

Scope

  • tokens/token-swap/anchor/programs/token-swap/ — source + tests
  • tokens/token-swap/quasar/ — mirror source + tests
  • tokens/token-swap/README.md

…age, 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
@mikemaccana-edwardbot mikemaccana-edwardbot force-pushed the chore/amm-naming-alignment branch from fd81d43 to a7d3212 Compare May 26, 2026 20:33
@mikemaccana-edwardbot mikemaccana-edwardbot changed the title chore(token-swap): align AMM identifier names to domain meaning feat(token-swap): production-shape AMM example - protocol fees, slippage, correctness fixes, u128 math May 26, 2026
Edward (Mike's bot) and others added 9 commits May 26, 2026 22:48
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.
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.
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.
- 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
…mint ordering

- Migrate all instruction handlers to anchor_spl::token_interface
  (InterfaceAccount<Mint>, InterfaceAccount<TokenAccount>,
  Interface<TokenInterface>) 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
Allow .claude/skills/ through .gitignore so skill files are committed
alongside the code they guide. Skills remain ignored otherwise.
…in 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/
…asar 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.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants