feat(token-swap): production-shape AMM example - protocol fees, slippage, correctness fixes, u128 math#35
Closed
mikemaccana-edwardbot wants to merge 10 commits into
Closed
Conversation
…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
fd81d43 to
a7d3212
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ondeposit_liquidityand the LP-mint formula, a constant-product invariant check on swap, and a full migration from fixed-point (I64F64) tou128 + checked_*integer arithmetic. Thefixedcrate dependency is removed entirely.Correctness fixes
deposit_liquidityratio bug. The non-empty-pool branch was usingK = pool_a * pool_bas 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'smint()ratio-clamp pattern inu128integer 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. AddsAmmError::DepositAmountTooSmallfor sub-base-unit deposits that clamp to zero.LP-mint formula bug.
deposit_liquiditywas usingsqrt(amount_a * amount_b)for every deposit, breaking proportionality on subsequent deposits. Now matches Uniswap V2:liquidity = sqrt(a*b) - MINIMUM_LIQUIDITYliquidity = min(a * supply / pool_a, b * supply / pool_b)against effective reserves.The initial-deposit
sqrtis computed with au128Newton's-methodinteger_sqrthelper (no moreI64F64::sqrt).claim_admin_feesno-signal bug. Used to silently succeed when both fee accumulators were zero. Now reverts withAmmError::NothingToClaim. (Also unblocks tests: two byte-identical claim transactions share a signature; testsexpire_blockhashbetween repeat claims.)New features
Config.admin_share_bpssplits each swap's trading fee between LPs and a configurable admin slice.PoolConfig.admin_fees_owed_a/_baccumulate the admin's per-side claim virtually inside the existingpool_a/pool_bvaults — no extra CPI per swap. Newclaim_admin_feeshandler lets the admin sweep accrued fees from both sides, authorised viahas_one = adminagainstConfig.deposit_liquidityandwithdraw_liquidityuse effective reserves so the admin's owed slice doesn't drag LP shares.0to opt out. Withdraw bounds are checked before any transfers.swap_tokens: existingmin_output_amountkept; error renamedOutputTooSmall→SlippageExceeded.deposit_liquidity: newminimum_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 withAmmError::DepositBelowMinimum.withdraw_liquidity: newminimum_token_a_outandminimum_token_b_out. Reverts withAmmError::WithdrawalBelowMinimum.k = effective_a * effective_bis recomputed inu128(rawu64multiplication overflows at large reserves, ~1.8e19 base units) and compared against the pre-trade invariant; the trade reverts ifkdecreases. 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_b→pool_a/pool_b(the reserves are "the pool" in natural English;vault_*imported yield-bearing-vault DeFi vocabulary that doesn't apply).mint_liquidity→liquidity_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:
Amm→Config(it holds program-level config —admin,fee,bump— not "the AMM").Pool→PoolConfig(it holds per-pool config / identity —config,mint_a,mint_b,bump— not pool state).TutorialError→AmmError.<HandlerName>Accounts(e.g.CreateAmm→CreateConfigAccounts). Structs name what they are (the bag of accounts for handler X), not a verb they pretend to do.Handlers:
create_amm→create_config(names the account it creates, matchingCreateConfigAccounts).swap_exact_tokens_for_tokens→swap_tokens(the Uniswap-style disambiguator earns no keep without aswap_tokens_for_exact_tokenssibling).swapparameterswap_a: bool→input_is_token_a: bool(purpose, not internal direction flag).Config singleton:
Configis now a singleton with seeds[b"config"]. Theid: Pubkeyparameter is removed fromcreate_config. Real DEXes ship one program per AMM (Phoenix, Raydium) — theidwas leftover complexity. Callingcreate_configtwice fails because the account already exists.CONFIG_SEED: &[u8] = b"config"added to anchorconstants.rs; quasar'sAmmPdarenamed toConfigPdawith#[seeds(b"config")];amm: Addressgenerics inPoolPda/PoolAuthorityPda/LiquidityMintPdarenamed toconfig: Address.[config, mint_a, mint_b]are unchanged.Math discipline
u128 + checked_mul / checked_div / try_into, multiply-before-divide, floor rounding in the pool's favour (Uniswap V2 convention).swap_tokensconstant-product output and fee formula rewritten inu128.withdraw_liquidityproportional-withdraw formula rewritten inu128.deposit_liquidityalready usedu128.integer_sqrt(u128Newton's method) replacesI64F64::sqrtfor the initial-deposit branch.AmmError::MathOverflowadded for thechecked_*path.fixed = "1.27.0"removed fromCargo.toml.Documentation
README.mdrewritten to match the code: identifiers, singletonConfig, admin protocol-fee design, slippage args, corrected deposit / LP-mint math.src/.Tests
18/18 Rust + LiteSVM integration tests passing. Coverage:
DepositAmountTooSmall;NothingToClaimrevert 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 + teststokens/token-swap/quasar/— mirror source + teststokens/token-swap/README.md