Skip to content

Commit a7d3212

Browse files
author
Edward (Mike's bot)
committed
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
1 parent 5540080 commit a7d3212

28 files changed

Lines changed: 2888 additions & 874 deletions

tokens/token-swap/README.md

Lines changed: 243 additions & 26 deletions
Large diffs are not rendered by default.

tokens/token-swap/anchor/programs/token-swap/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ custom-panic = []
2222
[dependencies]
2323
anchor-lang = { version = "1.0.0", features = ["init-if-needed"] }
2424
anchor-spl = { version = "1.0.0", features = ["metadata"] }
25-
fixed = "1.27.0"
25+
# `fixed` removed: all financial math is now u128 + checked_*, matching how
26+
# production Solana AMMs (Orca, Raydium, Meteora, Saber) do it. Floats /
27+
# fixed-point types are not used for money in this program.
2628

2729
[dev-dependencies]
2830
litesvm = "0.11.0"

tokens/token-swap/anchor/programs/token-swap/src/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ use anchor_lang::prelude::*;
33
#[constant]
44
pub const MINIMUM_LIQUIDITY: u64 = 100;
55

6+
#[constant]
7+
pub const CONFIG_SEED: &[u8] = b"config";
8+
69
#[constant]
710
pub const AUTHORITY_SEED: &[u8] = b"authority";
811

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,51 @@
11
use anchor_lang::prelude::*;
22

33
#[error_code]
4-
pub enum TutorialError {
4+
pub enum AmmError {
55
#[msg("Invalid fee value")]
66
InvalidFee,
77

8+
// Returned when `create_config` is called with `admin_share_bps >= 10_000`.
9+
// The admin share is a basis-points fraction of the trading fee, so values
10+
// at or above 10_000 are nonsensical (the admin can't take more than the
11+
// whole fee).
12+
#[msg("Admin share must be less than 10000 basis points")]
13+
AdminShareTooHigh,
14+
815
#[msg("Depositing too little liquidity")]
916
DepositTooSmall,
1017

11-
#[msg("Output is below the minimum expected")]
12-
OutputTooSmall,
18+
// Returned by `deposit_liquidity` when clamping the caller's amounts to the
19+
// current pool ratio rounds one side down to zero. That happens when the
20+
// deposit is so small (or so lopsided) that the pool can't issue meaningful
21+
// LP shares without rounding one of the contributions away. We fail rather
22+
// than mint zero-priced LP tokens.
23+
#[msg("Deposit amount too small for current pool ratio")]
24+
DepositAmountTooSmall,
25+
26+
// Returned by `swap_tokens` when the computed `output_amount` is strictly
27+
// below the trader's `min_output_amount`. This is the trader's slippage
28+
// guard: between quoting and landing, the pool can shift (other traders,
29+
// sandwich attempts), so the trader passes the lowest output they're
30+
// willing to accept and the program reverts if reality is worse.
31+
#[msg("Swap output below minimum (slippage exceeded)")]
32+
SlippageExceeded,
33+
34+
// Returned by `withdraw_liquidity` when either side of the proportional
35+
// withdrawal falls below the LP's specified minimum. This is the LP's
36+
// slippage guard: if a big swap drains one side of the pool between the
37+
// LP quoting their exit and the tx landing, the LP gets a different
38+
// mix than expected and can bail.
39+
#[msg("Withdrawal amount below minimum (slippage exceeded)")]
40+
WithdrawalBelowMinimum,
41+
42+
// Returned by `deposit_liquidity` when the computed LP-token amount
43+
// falls below the depositor's specified minimum. This is the
44+
// *lower-bound* slippage guard on what the depositor receives. The
45+
// ratio clamp is the *upper-bound* guard (don't over-spend either
46+
// token); both are needed for full deposit slippage protection.
47+
#[msg("LP tokens minted below minimum (slippage exceeded)")]
48+
DepositBelowMinimum,
1349

1450
#[msg("Invariant does not hold")]
1551
InvariantViolated,
@@ -20,4 +56,19 @@ pub enum TutorialError {
2056
// amount used). We now fail fast so callers can react.
2157
#[msg("Requested amount exceeds available balance")]
2258
InsufficientBalance,
59+
60+
// Returned by `claim_admin_fees` when both accumulators are zero. Reverting
61+
// (rather than silently no-op'ing) gives the admin a clear signal that the
62+
// call was wasted, and avoids the litesvm gotcha where two byte-identical
63+
// claim txs share a signature and the runtime rejects the second as
64+
// `AlreadyProcessed`. Callers should check the accumulators off-chain
65+
// before submitting a claim.
66+
#[msg("No admin fees to claim")]
67+
NothingToClaim,
68+
69+
// Returned by arithmetic helpers when a checked_* operation overflows or
70+
// underflows. We treat these as hard failures rather than masking them
71+
// with `.unwrap()` so the on-chain logs name the failure mode.
72+
#[msg("Math overflow")]
73+
MathOverflow,
2374
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::token::{self, Mint, Token, TokenAccount, TransferChecked};
3+
4+
use crate::{
5+
constants::{AUTHORITY_SEED, CONFIG_SEED},
6+
errors::AmmError,
7+
state::{Config, PoolConfig},
8+
};
9+
10+
/// Sweep the admin's accumulated trading-fee claims for both sides of a pool
11+
/// into the admin's token accounts.
12+
///
13+
/// During each swap, the admin's slice of the fee accumulates as a virtual
14+
/// claim on the input-side reserve (`pool_config.admin_fees_owed_a` /
15+
/// `admin_fees_owed_b`). This handler transfers those amounts out of the
16+
/// pool reserves into the admin's ATAs and resets the accumulators to zero.
17+
///
18+
/// Authorisation: the `has_one = admin` constraint on `config` plus the
19+
/// `Signer` constraint on `admin` together mean only the address stored in
20+
/// `Config.admin` can call this. Any other signer will be rejected by
21+
/// Anchor's built-in `has_one` check.
22+
pub fn handle_claim_admin_fees(context: Context<ClaimAdminFeesAccounts>) -> Result<()> {
23+
let owed_a = context.accounts.pool_config.admin_fees_owed_a;
24+
let owed_b = context.accounts.pool_config.admin_fees_owed_b;
25+
26+
// Revert if there's nothing to claim. Two reasons:
27+
// 1. It tells the admin off-chain that the call did nothing - silent
28+
// no-ops mask wasted txs.
29+
// 2. Under litesvm, two byte-identical claim txs (same payer, same
30+
// accounts, same recent_blockhash) produce the same signature and
31+
// the runtime rejects the second as `AlreadyProcessed`. Failing
32+
// explicitly here gives callers a real error to handle.
33+
if owed_a == 0 && owed_b == 0 {
34+
return err!(AmmError::NothingToClaim);
35+
}
36+
37+
let authority_bump = context.bumps.pool_authority;
38+
let authority_seeds = &[
39+
&context.accounts.pool_config.config.to_bytes(),
40+
&context.accounts.mint_a.key().to_bytes(),
41+
&context.accounts.mint_b.key().to_bytes(),
42+
AUTHORITY_SEED,
43+
&[authority_bump],
44+
];
45+
let signer_seeds = &[&authority_seeds[..]];
46+
47+
// Transfer the owed token-A fees from the pool reserve to the admin.
48+
if owed_a > 0 {
49+
token::transfer_checked(
50+
CpiContext::new_with_signer(
51+
context.accounts.token_program.key(),
52+
TransferChecked {
53+
from: context.accounts.pool_a.to_account_info(),
54+
mint: context.accounts.mint_a.to_account_info(),
55+
to: context.accounts.admin_token_a.to_account_info(),
56+
authority: context.accounts.pool_authority.to_account_info(),
57+
},
58+
signer_seeds,
59+
),
60+
owed_a,
61+
context.accounts.mint_a.decimals,
62+
)?;
63+
}
64+
65+
// Transfer the owed token-B fees from the pool reserve to the admin.
66+
if owed_b > 0 {
67+
token::transfer_checked(
68+
CpiContext::new_with_signer(
69+
context.accounts.token_program.key(),
70+
TransferChecked {
71+
from: context.accounts.pool_b.to_account_info(),
72+
mint: context.accounts.mint_b.to_account_info(),
73+
to: context.accounts.admin_token_b.to_account_info(),
74+
authority: context.accounts.pool_authority.to_account_info(),
75+
},
76+
signer_seeds,
77+
),
78+
owed_b,
79+
context.accounts.mint_b.decimals,
80+
)?;
81+
}
82+
83+
// Reset the accumulators. Done after the transfers so a failed CPI
84+
// leaves the on-chain bookkeeping intact (the admin can retry).
85+
let pool_config = &mut context.accounts.pool_config;
86+
pool_config.admin_fees_owed_a = 0;
87+
pool_config.admin_fees_owed_b = 0;
88+
89+
msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b);
90+
91+
Ok(())
92+
}
93+
94+
#[derive(Accounts)]
95+
pub struct ClaimAdminFeesAccounts<'info> {
96+
#[account(
97+
seeds = [CONFIG_SEED],
98+
bump,
99+
has_one = admin,
100+
)]
101+
pub config: Account<'info, Config>,
102+
103+
#[account(
104+
mut,
105+
seeds = [
106+
pool_config.config.as_ref(),
107+
pool_config.mint_a.key().as_ref(),
108+
pool_config.mint_b.key().as_ref(),
109+
],
110+
bump,
111+
has_one = config,
112+
has_one = mint_a,
113+
has_one = mint_b,
114+
)]
115+
pub pool_config: Account<'info, PoolConfig>,
116+
117+
/// CHECK: PDA that owns the pool reserves; signs the outbound transfers.
118+
#[account(
119+
seeds = [
120+
pool_config.config.as_ref(),
121+
mint_a.key().as_ref(),
122+
mint_b.key().as_ref(),
123+
AUTHORITY_SEED,
124+
],
125+
bump,
126+
)]
127+
pub pool_authority: AccountInfo<'info>,
128+
129+
pub mint_a: Box<Account<'info, Mint>>,
130+
131+
pub mint_b: Box<Account<'info, Mint>>,
132+
133+
/// The pool's token-A reserve. The admin's owed token-A fees are paid out
134+
/// of this account.
135+
#[account(
136+
mut,
137+
associated_token::mint = mint_a,
138+
associated_token::authority = pool_authority,
139+
)]
140+
pub pool_a: Box<Account<'info, TokenAccount>>,
141+
142+
/// The pool's token-B reserve. The admin's owed token-B fees are paid out
143+
/// of this account.
144+
#[account(
145+
mut,
146+
associated_token::mint = mint_b,
147+
associated_token::authority = pool_authority,
148+
)]
149+
pub pool_b: Box<Account<'info, TokenAccount>>,
150+
151+
/// Must match the address stored in `Config.admin` (enforced by
152+
/// `has_one = admin` above).
153+
pub admin: Signer<'info>,
154+
155+
/// Admin's token-A receiving account. Must already exist; the admin is
156+
/// expected to create it themselves before calling. Keeps this handler
157+
/// small (no `init_if_needed`).
158+
#[account(
159+
mut,
160+
token::mint = mint_a,
161+
token::authority = admin,
162+
)]
163+
pub admin_token_a: Box<Account<'info, TokenAccount>>,
164+
165+
/// Admin's token-B receiving account. Same constraints as `admin_token_a`.
166+
#[account(
167+
mut,
168+
token::mint = mint_b,
169+
token::authority = admin,
170+
)]
171+
pub admin_token_b: Box<Account<'info, TokenAccount>>,
172+
173+
pub token_program: Program<'info, Token>,
174+
}

tokens/token-swap/anchor/programs/token-swap/src/instructions/create_amm.rs

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::{constants::CONFIG_SEED, errors::*, state::Config};
4+
5+
pub fn handle_create_config(
6+
mut context: Context<CreateConfigAccounts>,
7+
fee: u16,
8+
admin_share_bps: u16,
9+
) -> Result<()> {
10+
let bump = context.bumps.config;
11+
let config = &mut context.accounts.config;
12+
config.admin = context.accounts.admin.key();
13+
config.fee = fee;
14+
config.admin_share_bps = admin_share_bps;
15+
config.bump = bump;
16+
17+
Ok(())
18+
}
19+
20+
#[derive(Accounts)]
21+
#[instruction(fee: u16, admin_share_bps: u16)]
22+
pub struct CreateConfigAccounts<'info> {
23+
#[account(
24+
init,
25+
payer = payer,
26+
space = Config::DISCRIMINATOR.len() + Config::INIT_SPACE,
27+
seeds = [CONFIG_SEED],
28+
bump,
29+
constraint = fee < 10000 @ AmmError::InvalidFee,
30+
constraint = admin_share_bps < 10000 @ AmmError::AdminShareTooHigh,
31+
)]
32+
pub config: Account<'info, Config>,
33+
34+
/// The admin of the AMM
35+
/// CHECK: Read only, delegatable creation
36+
pub admin: AccountInfo<'info>,
37+
38+
/// The account paying for all rents
39+
#[account(mut)]
40+
pub payer: Signer<'info>,
41+
42+
/// Solana ecosystem accounts
43+
pub system_program: Program<'info, System>,
44+
}

0 commit comments

Comments
 (0)