Skip to content

Commit 54dd196

Browse files
committed
feat(token-swap): constant-product AMM with protocol fees, slippage protection, and u128 math
Adds a production-shape constant-product AMM (CPAMM) example for both the Anchor and Quasar frameworks, covering the complete lifecycle of a DEX: deployment, pool creation, liquidity provision, token swaps, and admin fee management. ## Instruction handlers - `create_config` — initialises the singleton Config PDA with trading fee and admin-share parameters (both in basis points). One per deployed program. - `create_pool` — initialises a PoolConfig PDA, an LP-token mint, and two pool reserve token accounts for a given (mint_a, mint_b) pair. - `deposit_liquidity` — transfers tokens from the depositor to the pool and mints LP tokens. Amounts are clamped to the current pool ratio (Uniswap V2 mint() pattern); supports a minimum-LP-tokens slippage floor. - `withdraw_liquidity` — burns LP tokens and returns a proportional share of the effective reserves (vault balance minus admin's owed slice) to the LP. Per-side slippage floors supported. - `swap_tokens` — constant-product swap with a configurable trading fee split between LPs and the admin. Supports a minimum-output slippage floor and re-verifies the x*y=k invariant after every trade as defence-in-depth. - `claim_admin_fees` — lets the Config.admin sweep their accumulated fee claim from both sides of a pool. Resets the accumulators to zero. ## Key design properties - Admin protocol-fee mechanism: Config.admin_share_bps splits each swap's trading fee between LPs and the program operator. Admin fees accrue as virtual claims on the existing pool reserves (no extra CPI per swap) and are swept on demand via claim_admin_fees. - Effective-reserve accounting: all LP math operates on pool_X.amount - admin_fees_owed_X so the admin's owed slice does not distort LP pricing or yield. - u128 checked arithmetic throughout: no floating-point, multiply-before- divide, floor rounding in the pool's favour. - Slippage protection on every state-changing instruction. - Constant-product invariant check on every swap as defence-in-depth. - Singleton Config at seeds [b"config"]; unique PoolConfig per (config, mint_a, mint_b) with mint_a < mint_b. - LP positions as SPL tokens for composability. ## Tests - Anchor: 18 LiteSVM integration tests - Quasar: 14 QuasarSvm integration tests ## Documentation README covers the CPAMM formula, design rationale, full state and instruction reference, and an end-to-end program-flow walkthrough (Alice/Bob/Carol/Dave, NVDAx/TSLAx/USDC pools) with financial terms linked to Investopedia and Solana terminology on first use. https://claude.ai/code/session_01DFHVK3tVoPfz6MJMwEAGBf
1 parent 5540080 commit 54dd196

33 files changed

Lines changed: 3915 additions & 976 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ node_modules/
2222

2323
/target
2424
deploy
25-
.claude
25+
.claude/*
26+
!.claude/skills/

tokens/token-swap/README.md

Lines changed: 313 additions & 33 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ custom-panic = []
2121

2222
[dependencies]
2323
anchor-lang = { version = "1.0.0", features = ["init-if-needed"] }
24-
anchor-spl = { version = "1.0.0", features = ["metadata"] }
25-
fixed = "1.27.0"
24+
anchor-spl = { version = "1.0.0", features = ["metadata", "spl-token-interface"] }
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ use anchor_lang::prelude::*;
33
#[constant]
44
pub const MINIMUM_LIQUIDITY: u64 = 100;
55

6+
/// Basis-points denominator. Fees and the admin's fee share are stored in
7+
/// basis points (1 bp = 1/10_000), so dividing by this converts a bp value to
8+
/// a fraction. Using the named constant keeps the 10_000 out of the math as a
9+
/// bare literal.
10+
#[constant]
11+
pub const BASIS_POINTS_DIVISOR: u64 = 10_000;
12+
13+
#[constant]
14+
pub const CONFIG_SEED: &[u8] = b"config";
15+
616
#[constant]
717
pub const AUTHORITY_SEED: &[u8] = b"authority";
818

Lines changed: 61 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,26 @@ 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 offchain
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 onchain logs name the failure mode.
72+
#[msg("Math overflow")]
73+
MathOverflow,
74+
75+
// Returned by `create_pool` when `mint_a >= mint_b`. Requiring a strict
76+
// ascending order ensures each (mint_a, mint_b) pair has exactly one
77+
// canonical pool PDA — without it, a (X, Y) pool and a (Y, X) pool would
78+
// both be valid, fragmenting liquidity.
79+
#[msg("mint_a must be less than mint_b for canonical pool ordering")]
80+
InvalidMintOrder,
2381
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, 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<ClaimAdminFeesAccountConstraints>) -> 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 offchain 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+
// Pre-copy seed bytes before the mutable borrow of pool_config.
38+
let authority_bump = context.bumps.pool_authority;
39+
let config_bytes = context.accounts.pool_config.config.to_bytes();
40+
let mint_a_bytes = context.accounts.mint_a.key().to_bytes();
41+
let mint_b_bytes = context.accounts.mint_b.key().to_bytes();
42+
43+
// Effects: zero the accumulators before the CPIs (Checks-Effects-Interactions).
44+
// If a CPI fails the whole transaction reverts, so the state reset is safe.
45+
{
46+
let pool_config = &mut context.accounts.pool_config;
47+
pool_config.admin_fees_owed_a = 0;
48+
pool_config.admin_fees_owed_b = 0;
49+
}
50+
51+
// Interactions: transfer the owed fees out of the pool reserves.
52+
let authority_seeds = &[
53+
config_bytes.as_ref(),
54+
mint_a_bytes.as_ref(),
55+
mint_b_bytes.as_ref(),
56+
AUTHORITY_SEED,
57+
&[authority_bump],
58+
];
59+
let signer_seeds = &[&authority_seeds[..]];
60+
61+
if owed_a > 0 {
62+
token_interface::transfer_checked(
63+
CpiContext::new_with_signer(
64+
context.accounts.token_program.key(),
65+
TransferChecked {
66+
from: context.accounts.pool_a.to_account_info(),
67+
mint: context.accounts.mint_a.to_account_info(),
68+
to: context.accounts.admin_token_a.to_account_info(),
69+
authority: context.accounts.pool_authority.to_account_info(),
70+
},
71+
signer_seeds,
72+
),
73+
owed_a,
74+
context.accounts.mint_a.decimals,
75+
)?;
76+
}
77+
78+
if owed_b > 0 {
79+
token_interface::transfer_checked(
80+
CpiContext::new_with_signer(
81+
context.accounts.token_program.key(),
82+
TransferChecked {
83+
from: context.accounts.pool_b.to_account_info(),
84+
mint: context.accounts.mint_b.to_account_info(),
85+
to: context.accounts.admin_token_b.to_account_info(),
86+
authority: context.accounts.pool_authority.to_account_info(),
87+
},
88+
signer_seeds,
89+
),
90+
owed_b,
91+
context.accounts.mint_b.decimals,
92+
)?;
93+
}
94+
95+
msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b);
96+
97+
Ok(())
98+
}
99+
100+
#[derive(Accounts)]
101+
pub struct ClaimAdminFeesAccountConstraints<'info> {
102+
#[account(
103+
seeds = [CONFIG_SEED],
104+
bump,
105+
has_one = admin,
106+
)]
107+
pub config: Account<'info, Config>,
108+
109+
#[account(
110+
mut,
111+
seeds = [
112+
pool_config.config.as_ref(),
113+
pool_config.mint_a.key().as_ref(),
114+
pool_config.mint_b.key().as_ref(),
115+
],
116+
bump,
117+
has_one = config,
118+
has_one = mint_a,
119+
has_one = mint_b,
120+
)]
121+
pub pool_config: Account<'info, PoolConfig>,
122+
123+
/// CHECK: PDA that owns the pool reserves; signs the outbound transfers.
124+
#[account(
125+
seeds = [
126+
pool_config.config.as_ref(),
127+
mint_a.key().as_ref(),
128+
mint_b.key().as_ref(),
129+
AUTHORITY_SEED,
130+
],
131+
bump,
132+
)]
133+
pub pool_authority: UncheckedAccount<'info>,
134+
135+
pub mint_a: Box<InterfaceAccount<'info, Mint>>,
136+
137+
pub mint_b: Box<InterfaceAccount<'info, Mint>>,
138+
139+
/// The pool's token-A reserve. The admin's owed token-A fees are paid out
140+
/// of this account.
141+
#[account(
142+
mut,
143+
associated_token::mint = mint_a,
144+
associated_token::authority = pool_authority,
145+
associated_token::token_program = token_program,
146+
)]
147+
pub pool_a: Box<InterfaceAccount<'info, TokenAccount>>,
148+
149+
/// The pool's token-B reserve. The admin's owed token-B fees are paid out
150+
/// of this account.
151+
#[account(
152+
mut,
153+
associated_token::mint = mint_b,
154+
associated_token::authority = pool_authority,
155+
associated_token::token_program = token_program,
156+
)]
157+
pub pool_b: Box<InterfaceAccount<'info, TokenAccount>>,
158+
159+
/// Must match the address stored in `Config.admin` (enforced by
160+
/// `has_one = admin` above).
161+
pub admin: Signer<'info>,
162+
163+
/// Admin's token-A receiving account. Must already exist; the admin is
164+
/// expected to create it themselves before calling. Keeps this handler
165+
/// small (no `init_if_needed`).
166+
#[account(
167+
mut,
168+
token::mint = mint_a,
169+
token::authority = admin,
170+
token::token_program = token_program,
171+
)]
172+
pub admin_token_a: Box<InterfaceAccount<'info, TokenAccount>>,
173+
174+
/// Admin's token-B receiving account. Same constraints as `admin_token_a`.
175+
#[account(
176+
mut,
177+
token::mint = mint_b,
178+
token::authority = admin,
179+
token::token_program = token_program,
180+
)]
181+
pub admin_token_b: Box<InterfaceAccount<'info, TokenAccount>>,
182+
183+
pub token_program: Interface<'info, TokenInterface>,
184+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod claim_admin_fees;
2+
3+
pub use claim_admin_fees::*;

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

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

0 commit comments

Comments
 (0)