Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,4 @@ node_modules/
/target
deploy
.claude/*
# Exception: skills are shared team resources
!.claude/skills/
!.claude/skills/**
!.claude/skills/
346 changes: 313 additions & 33 deletions tokens/token-swap/README.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions tokens/token-swap/anchor/programs/token-swap/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ 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"
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.

[dev-dependencies]
litesvm = "0.11.0"
Expand Down
10 changes: 10 additions & 0 deletions tokens/token-swap/anchor/programs/token-swap/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ 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";

#[constant]
pub const AUTHORITY_SEED: &[u8] = b"authority";

Expand Down
64 changes: 61 additions & 3 deletions tokens/token-swap/anchor/programs/token-swap/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,4 +56,26 @@ 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 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 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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
use anchor_lang::prelude::*;
use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, 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<ClaimAdminFeesAccountConstraints>) -> 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 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
// 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);
}

// 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 = &[
config_bytes.as_ref(),
mint_a_bytes.as_ref(),
mint_b_bytes.as_ref(),
AUTHORITY_SEED,
&[authority_bump],
];
let signer_seeds = &[&authority_seeds[..]];

if owed_a > 0 {
token_interface::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,
)?;
}

if owed_b > 0 {
token_interface::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,
)?;
}

msg!("Admin swept fees: {} of mint_a, {} of mint_b", owed_a, owed_b);

Ok(())
}

#[derive(Accounts)]
pub struct ClaimAdminFeesAccountConstraints<'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: UncheckedAccount<'info>,

pub mint_a: Box<InterfaceAccount<'info, Mint>>,

pub mint_b: Box<InterfaceAccount<'info, Mint>>,

/// 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,
associated_token::token_program = token_program,
)]
pub pool_a: Box<InterfaceAccount<'info, TokenAccount>>,

/// 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,
associated_token::token_program = token_program,
)]
pub pool_b: Box<InterfaceAccount<'info, TokenAccount>>,

/// 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,
token::token_program = token_program,
)]
pub admin_token_a: Box<InterfaceAccount<'info, TokenAccount>>,

/// 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<InterfaceAccount<'info, TokenAccount>>,

pub token_program: Interface<'info, TokenInterface>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod claim_admin_fees;

pub use claim_admin_fees::*;

This file was deleted.

Loading
Loading