From cda39dbdc66936449493ae9669368a3ad074c574 Mon Sep 17 00:00:00 2001 From: mikemaccana-edwardbot Date: Fri, 17 Apr 2026 23:00:31 +0000 Subject: [PATCH 1/5] feat(defi): add CLOB example ported from mikemaccana/anchor-decentralized-exchange-clob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a teaching-grade central limit order book under defi/clob/anchor. The port brings the source program from Anchor 0.32.1 to Anchor 1.0.0 and conforms it to the solana-anchor-claude-skill ruleset so it can sit alongside the other Financial Software examples. Why this example belongs in program-examples: - The existing DeFi corpus covers constant-product AMMs and peer-to-peer escrow; a CLOB rounds out the set so readers can see how limit-order exchanges work on Solana without having to read Openbook/Phoenix's much larger zero-copy codebases. - It demonstrates several patterns the simpler examples don't: PDAs authoring token vaults, per-user per-market state, and an unsettled- balance + settle step that mirrors how real exchanges decouple matching from fund movement. Program side (Anchor 1.0 migration + skill rules): - declare_id! kept; every handler uses `context` rather than `ctx`. - `context.bumps.x` direct field access, no `.get("x").unwrap()`. - All #[account] structs derive InitSpace and store `pub bump: u8`, saved in the init handler. - Every `space = ...` uses `T::DISCRIMINATOR.len() + T::INIT_SPACE` — no magic 8, no hand-sized byte math, no custom discriminator consts. - Clock::get()? instead of the anchor_lang::solana_program path. - Token accounts use anchor_spl::token_interface for Token-2022 support. - PlaceOrderAccountConstraints and SettleFundsAccountConstraints box their InterfaceAccount fields — without boxing the BPF frame exceeds the 4 KB stack-offset limit. A comment on each documents the reason. - CpiContext::new / new_with_signer now take `token_program.key()`, matching the Anchor 1.0 signature change. - MAX_ORDERS_PER_SIDE and MAX_OPEN_ORDERS_PER_USER are named constants with rationale, instead of the magic 100 / 20 in the source. - Dead matching-engine helpers from the upstream `utils/matching.rs` are removed: they were never invoked by place_order and contained an obvious quantity-accounting bug. The README's "Scope note" flags that a real matching engine is the natural next extension — better to be honest about the limit than to ship broken code. Test side (Mike-canonical pattern): - node:test via `npx tsx --test --test-reporter=spec`. - solana-kite `connect()` / `createWallets` / `createTokenMint` / `sendTransactionFromInstructions` — no @coral-xyz/anchor, no web3.js v1, no ts-mocha, no chai, no bs58. - @solana/kit types (TransactionSigner, Address, lamports). - Codama-generated TS client under dist/clob-client (built by the Anchor.toml `test` script via `npx create-codama-clients`). - TOKEN_EXTENSIONS_PROGRAM from solana-kite, PDAs via `connection.getPDAAndBump`, token accounts via `connection.getTokenAccountAddress`. - 9 tests cover the full happy path (initialize, create user, bid, ask, cancel, settle, cancel+settle buyer) plus two failure cases (invalid price, non-owner cancel). Known limitation called out in the README: surfpool (Anchor 1.0's default local validator) does not accept the websocket RPC methods Kit uses for transaction confirmation; `anchor test --validator legacy` is required until surfpool catches up. --- README.md | 6 + defi/clob/anchor/.gitignore | 8 + defi/clob/anchor/Anchor.toml | 34 ++ defi/clob/anchor/Cargo.toml | 13 + defi/clob/anchor/README.md | 46 ++ defi/clob/anchor/package.json | 24 + defi/clob/anchor/programs/clob/Cargo.toml | 27 + defi/clob/anchor/programs/clob/src/errors.rs | 40 ++ .../clob/src/instructions/cancel_order.rs | 86 ++++ .../src/instructions/create_user_account.rs | 34 ++ .../src/instructions/initialize_market.rs | 96 ++++ .../programs/clob/src/instructions/mod.rs | 11 + .../clob/src/instructions/place_order.rs | 163 ++++++ .../clob/src/instructions/settle_funds.rs | 98 ++++ defi/clob/anchor/programs/clob/src/lib.rs | 46 ++ .../anchor/programs/clob/src/state/market.rs | 32 ++ .../anchor/programs/clob/src/state/mod.rs | 9 + .../anchor/programs/clob/src/state/order.rs | 45 ++ .../programs/clob/src/state/order_book.rs | 71 +++ .../programs/clob/src/state/user_account.rs | 38 ++ defi/clob/anchor/tests/clob.test.ts | 468 ++++++++++++++++++ defi/clob/anchor/tsconfig.json | 11 + 22 files changed, 1406 insertions(+) create mode 100644 defi/clob/anchor/.gitignore create mode 100644 defi/clob/anchor/Anchor.toml create mode 100644 defi/clob/anchor/Cargo.toml create mode 100644 defi/clob/anchor/README.md create mode 100644 defi/clob/anchor/package.json create mode 100644 defi/clob/anchor/programs/clob/Cargo.toml create mode 100644 defi/clob/anchor/programs/clob/src/errors.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/mod.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/place_order.rs create mode 100644 defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs create mode 100644 defi/clob/anchor/programs/clob/src/lib.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/market.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/mod.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/order.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/order_book.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/user_account.rs create mode 100644 defi/clob/anchor/tests/clob.test.ts create mode 100644 defi/clob/anchor/tsconfig.json diff --git a/README.md b/README.md index e19d4b286..65cea370b 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,12 @@ Allow two users to swap digital assets with each other, each getting 100% of wha [anchor](./tokens/escrow/anchor) [native](./tokens/escrow/native) +### Central Limit Order Book + +Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders can be cancelled and funds settled back. A minimal teaching example of the mechanics behind Openbook and Phoenix. + +[anchor](./defi/clob/anchor) + ### Minting a token from inside a program with a PDA as the mint authority [Mint a Token from inside your own onchain program using the Token program.](./tokens/pda-mint-authority/README.md) Reminder: you don't need your own program just to mint an NFT, see the note at the top of this README. diff --git a/defi/clob/anchor/.gitignore b/defi/clob/anchor/.gitignore new file mode 100644 index 000000000..abd908750 --- /dev/null +++ b/defi/clob/anchor/.gitignore @@ -0,0 +1,8 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn +dist diff --git a/defi/clob/anchor/Anchor.toml b/defi/clob/anchor/Anchor.toml new file mode 100644 index 000000000..c9e6b3ed8 --- /dev/null +++ b/defi/clob/anchor/Anchor.toml @@ -0,0 +1,34 @@ +[toolchain] +anchor_version = "1.0.0" +solana_version = "3.1.8" +# pnpm matches the program-examples root package manager. +package_manager = "pnpm" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +clob = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# Generate the Codama TS client from the built IDL, then run node:test tests +# via tsx. Run with: `anchor test --validator legacy` +# +# The `legacy` flag selects `solana-test-validator`, which Kit can talk to. +# Anchor 1.0's default "surfpool" validator does not accept the websocket RPC +# methods Kit uses for confirmation, so tests time out waiting for their +# first transaction. Revisit when surfpool adds full Kit support. +test = "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts" + +[hooks] + +[test] +# Give the local validator enough time to become reachable before tests start. +startup_wait = 10000 +shutdown_wait = 2000 +upgradeable = false diff --git a/defi/clob/anchor/Cargo.toml b/defi/clob/anchor/Cargo.toml new file mode 100644 index 000000000..68da9ddd2 --- /dev/null +++ b/defi/clob/anchor/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = ["programs/*"] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/defi/clob/anchor/README.md b/defi/clob/anchor/README.md new file mode 100644 index 000000000..64299b28d --- /dev/null +++ b/defi/clob/anchor/README.md @@ -0,0 +1,46 @@ +# Anchor CLOB + +A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price; their tokens sit in a program-owned vault until the order is cancelled. Cancellation credits the refund to an internal balance and a later `settle_funds` call moves those tokens back to the user. + +This is a teaching example. It is deliberately small — the real CLOBs on Solana (Openbook, Phoenix) use zero-copy slab data structures and much more sophisticated matching and fee logic. + +## Concepts + +- **Market** — one trading pair, e.g. `BASE/QUOTE`. Stored at a PDA seeded by the two mints. The market account is the signer of its two token vaults. +- **Order Book** — a PDA per market holding two `Vec`s: bids (sorted descending by price) and asks (sorted ascending). Price-time priority is implicit in the order they are inserted. +- **User Account** — one per user per market. Tracks the user's open order ids and two "unsettled" balances (base and quote) representing tokens the program owes the user but has not yet transferred back. +- **Order** — a PDA per placed order, seeded by `(market, order_id)`. Stores price, original and filled quantity, status (`Open`, `PartiallyFilled`, `Filled`, `Cancelled`) and the owner. + +## Instructions + +| Name | What it does | +|-----------------------|--------------| +| `initialize_market` | Create the market, order book and two token vaults for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. | +| `create_user_account` | Initialise the caller's per-market user account. | +| `place_order` | Add a limit order to the book and lock the funds it would need if filled: bids lock `price × quantity` of quote; asks lock `quantity` of base. | +| `cancel_order` | Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. | +| `settle_funds` | Move all unsettled base and quote from the market's vaults back to the owner's token accounts. Signs with the market PDA. | + +### Scope note + +The program stores the book and locks funds on placement, but does **not** currently run a matching engine inside `place_order`. Crossed orders (a bid at or above the best ask) will sit side-by-side in the book rather than trade. Adding matching requires passing the opposing orders (and their owners' user accounts and token accounts) as remaining accounts and clearing the filled amounts across both sides; it's a natural next extension. + +## Build + +```shell +anchor build +``` + +## Test + +```shell +anchor test --validator legacy +``` + +The `--validator legacy` flag is required: Anchor 1.0's default "surfpool" validator does not yet accept the websocket RPC methods Solana Kit uses for transaction confirmation, so tests hang waiting for their first transaction. `solana-test-validator` works. + +The test script (defined in `Anchor.toml`) first runs `npx create-codama-clients` to generate a TypeScript client from the built IDL into `dist/clob-client/`, then executes the `node:test` suite with `tsx`. + +## Credit + +Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the [Solana Anchor coding skill](https://github.com/mikemaccana/solana-anchor-claude-skill) (Kit + Kite + Codama, `node:test`, no `@coral-xyz/anchor`, no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). diff --git a/defi/clob/anchor/package.json b/defi/clob/anchor/package.json new file mode 100644 index 000000000..17c5589f1 --- /dev/null +++ b/defi/clob/anchor/package.json @@ -0,0 +1,24 @@ +{ + "name": "clob", + "version": "0.1.0", + "license": "ISC", + "type": "module", + "scripts": { + "build": "anchor build", + "codama": "npx create-codama-clients", + "test": "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts" + }, + "dependencies": { + "@solana/kit": "^6.1.0" + }, + "devDependencies": { + "@codama/nodes-from-anchor": "^1.3.8", + "@codama/renderers": "^1.0.34", + "@codama/renderers-js": "^1.7.0", + "@types/node": "^20.11.0", + "codama": "^1.5.0", + "solana-kite": "^3.2.1", + "tsx": "^4.7.0", + "typescript": "^5.7.3" + } +} diff --git a/defi/clob/anchor/programs/clob/Cargo.toml b/defi/clob/anchor/programs/clob/Cargo.toml new file mode 100644 index 000000000..109dec12a --- /dev/null +++ b/defi/clob/anchor/programs/clob/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "clob" +version = "0.1.0" +description = "Central limit order book on Solana" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "clob" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = "1.0.0" +anchor-spl = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/defi/clob/anchor/programs/clob/src/errors.rs b/defi/clob/anchor/programs/clob/src/errors.rs new file mode 100644 index 000000000..10526bf5a --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/errors.rs @@ -0,0 +1,40 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum ErrorCode { + #[msg("Invalid price provided")] + InvalidPrice, + + #[msg("Invalid quantity provided")] + InvalidQuantity, + + #[msg("Order not found")] + OrderNotFound, + + #[msg("Market is currently paused")] + MarketPaused, + + #[msg("Unauthorized action")] + Unauthorized, + + #[msg("Order book is full")] + OrderBookFull, + + #[msg("User account has too many open orders")] + TooManyOpenOrders, + + #[msg("Price does not align with tick size")] + InvalidTickSize, + + #[msg("Quantity is below minimum order size")] + BelowMinOrderSize, + + #[msg("Order is not cancellable in current status")] + OrderNotCancellable, + + #[msg("Numerical overflow occurred")] + NumericalOverflow, + + #[msg("Fee basis points out of range")] + InvalidFeeBasisPoints, +} diff --git a/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs b/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs new file mode 100644 index 000000000..ce55f31ad --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs @@ -0,0 +1,86 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::{ + remaining_quantity, remove_open_order, remove_order, Market, Order, OrderBook, OrderSide, + OrderStatus, UserAccount, ORDER_BOOK_SEED, ORDER_SEED, USER_ACCOUNT_SEED, +}; + +pub fn cancel_order(context: Context) -> Result<()> { + let order = &mut context.accounts.order; + + require!( + order.owner == context.accounts.owner.key(), + ErrorCode::Unauthorized + ); + + require!( + order.status == OrderStatus::Open || order.status == OrderStatus::PartiallyFilled, + ErrorCode::OrderNotCancellable + ); + + // Funds the order had locked in the vault are now owed back to the + // owner. Credit the appropriate unsettled balance; settle_funds moves + // those funds from the vault to the owner's token account. + let remaining = remaining_quantity(order); + if remaining > 0 { + let user_account = &mut context.accounts.user_account; + match order.side { + OrderSide::Bid => { + let quote_amount = order + .price + .checked_mul(remaining) + .ok_or(ErrorCode::NumericalOverflow)?; + user_account.unsettled_quote = user_account + .unsettled_quote + .checked_add(quote_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + OrderSide::Ask => { + user_account.unsettled_base = user_account + .unsettled_base + .checked_add(remaining) + .ok_or(ErrorCode::NumericalOverflow)?; + } + } + } + + let order_book = &mut context.accounts.order_book; + let removed = remove_order(order_book, order.order_id); + require!(removed, ErrorCode::OrderNotFound); + + let user_account = &mut context.accounts.user_account; + remove_open_order(user_account, order.order_id); + + order.status = OrderStatus::Cancelled; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CancelOrderAccountConstraints<'info> { + pub market: Account<'info, Market>, + + #[account( + mut, + seeds = [ORDER_BOOK_SEED, market.key().as_ref()], + bump = order_book.bump + )] + pub order_book: Account<'info, OrderBook>, + + #[account( + mut, + seeds = [ORDER_SEED, market.key().as_ref(), order.order_id.to_le_bytes().as_ref()], + bump = order.bump + )] + pub order: Account<'info, Order>, + + #[account( + mut, + seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = user_account.bump + )] + pub user_account: Account<'info, UserAccount>, + + pub owner: Signer<'info>, +} diff --git a/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs b/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs new file mode 100644 index 000000000..7d56f6905 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +use crate::state::{Market, UserAccount, USER_ACCOUNT_SEED}; + +pub fn create_user_account(context: Context) -> Result<()> { + let user_account = &mut context.accounts.user_account; + user_account.market = context.accounts.market.key(); + user_account.owner = context.accounts.owner.key(); + user_account.unsettled_base = 0; + user_account.unsettled_quote = 0; + user_account.open_orders = Vec::new(); + user_account.bump = context.bumps.user_account; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CreateUserAccountAccountConstraints<'info> { + #[account( + init, + payer = owner, + space = UserAccount::DISCRIMINATOR.len() + UserAccount::INIT_SPACE, + seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()], + bump + )] + pub user_account: Account<'info, UserAccount>, + + pub market: Account<'info, Market>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs new file mode 100644 index 000000000..c95d9ff81 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs @@ -0,0 +1,96 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::errors::ErrorCode; +use crate::state::{Market, OrderBook, MARKET_SEED, ORDER_BOOK_SEED}; + +// Basis-points are hundredths of a percent; 10000 bps == 100%. Fees above 100% +// would be nonsensical, so we cap here. +const MAX_FEE_BASIS_POINTS: u16 = 10_000; + +pub fn initialize_market( + context: Context, + fee_basis_points: u16, + tick_size: u64, + min_order_size: u64, +) -> Result<()> { + require!(tick_size > 0, ErrorCode::InvalidTickSize); + require!(min_order_size > 0, ErrorCode::BelowMinOrderSize); + require!( + fee_basis_points <= MAX_FEE_BASIS_POINTS, + ErrorCode::InvalidFeeBasisPoints + ); + + let market = &mut context.accounts.market; + market.authority = context.accounts.authority.key(); + market.base_mint = context.accounts.base_mint.key(); + market.quote_mint = context.accounts.quote_mint.key(); + market.base_vault = context.accounts.base_vault.key(); + market.quote_vault = context.accounts.quote_vault.key(); + market.order_book = context.accounts.order_book.key(); + market.fee_basis_points = fee_basis_points; + market.tick_size = tick_size; + market.min_order_size = min_order_size; + market.is_active = true; + market.bump = context.bumps.market; + + let order_book = &mut context.accounts.order_book; + order_book.market = context.accounts.market.key(); + order_book.bids = Vec::new(); + order_book.asks = Vec::new(); + // Start at 1 so order_id == 0 can stand for "no order" in clients if needed. + order_book.next_order_id = 1; + order_book.bump = context.bumps.order_book; + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints<'info> { + #[account( + init, + payer = authority, + space = Market::DISCRIMINATOR.len() + Market::INIT_SPACE, + seeds = [MARKET_SEED, base_mint.key().as_ref(), quote_mint.key().as_ref()], + bump + )] + pub market: Account<'info, Market>, + + #[account( + init, + payer = authority, + space = OrderBook::DISCRIMINATOR.len() + OrderBook::INIT_SPACE, + seeds = [ORDER_BOOK_SEED, market.key().as_ref()], + bump + )] + pub order_book: Account<'info, OrderBook>, + + pub base_mint: InterfaceAccount<'info, Mint>, + + pub quote_mint: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = authority, + token::mint = base_mint, + token::authority = market, + token::token_program = token_program + )] + pub base_vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = authority, + token::mint = quote_mint, + token::authority = market, + token::token_program = token_program + )] + pub quote_vault: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} diff --git a/defi/clob/anchor/programs/clob/src/instructions/mod.rs b/defi/clob/anchor/programs/clob/src/instructions/mod.rs new file mode 100644 index 000000000..4ac470849 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/mod.rs @@ -0,0 +1,11 @@ +pub mod cancel_order; +pub mod create_user_account; +pub mod initialize_market; +pub mod place_order; +pub mod settle_funds; + +pub use cancel_order::*; +pub use create_user_account::*; +pub use initialize_market::*; +pub use place_order::*; +pub use settle_funds::*; diff --git a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs new file mode 100644 index 000000000..576a71f72 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs @@ -0,0 +1,163 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::ErrorCode; +use crate::state::{ + add_ask, add_bid, add_open_order, Market, Order, OrderBook, OrderSide, OrderStatus, + UserAccount, MAX_ORDERS_PER_SIDE, ORDER_BOOK_SEED, ORDER_SEED, USER_ACCOUNT_SEED, +}; + +// Mirror of UserAccount.open_orders max_len. Kept as a constant so the +// PlaceOrder check reads clearly and the limit is documented in one place. +const MAX_OPEN_ORDERS_PER_USER: usize = 20; + +pub fn place_order( + context: Context, + side: OrderSide, + price: u64, + quantity: u64, +) -> Result<()> { + let market = &context.accounts.market; + + require!(market.is_active, ErrorCode::MarketPaused); + require!(price > 0, ErrorCode::InvalidPrice); + require!(price % market.tick_size == 0, ErrorCode::InvalidTickSize); + require!( + quantity >= market.min_order_size, + ErrorCode::BelowMinOrderSize + ); + + let order_book = &mut context.accounts.order_book; + require!( + order_book.bids.len() + order_book.asks.len() < MAX_ORDERS_PER_SIDE * 2, + ErrorCode::OrderBookFull + ); + + let user_account = &mut context.accounts.user_account; + require!( + user_account.open_orders.len() < MAX_OPEN_ORDERS_PER_USER, + ErrorCode::TooManyOpenOrders + ); + + let order_id = order_book.next_order_id; + order_book.next_order_id = order_book.next_order_id.saturating_add(1); + + let order = &mut context.accounts.order; + order.market = market.key(); + order.owner = context.accounts.owner.key(); + order.order_id = order_id; + order.side = side; + order.price = price; + order.original_quantity = quantity; + order.filled_quantity = 0; + order.status = OrderStatus::Open; + order.timestamp = Clock::get()?.unix_timestamp; + order.bump = context.bumps.order; + + // Lock up the funds the order would need if filled. Bids lock quote + // (price * quantity); asks lock base (quantity). Funds sit in the vault + // until the order is cancelled (returned to unsettled) or settled. + let (source_account, mint_account_info, decimals, transfer_amount, destination_vault) = + match side { + OrderSide::Bid => ( + context.accounts.user_quote_account.to_account_info(), + context.accounts.quote_mint.to_account_info(), + context.accounts.quote_mint.decimals, + price + .checked_mul(quantity) + .ok_or(ErrorCode::NumericalOverflow)?, + context.accounts.quote_vault.to_account_info(), + ), + OrderSide::Ask => ( + context.accounts.user_base_account.to_account_info(), + context.accounts.base_mint.to_account_info(), + context.accounts.base_mint.decimals, + quantity, + context.accounts.base_vault.to_account_info(), + ), + }; + + transfer_checked( + CpiContext::new( + context.accounts.token_program.key(), + TransferChecked { + from: source_account, + mint: mint_account_info, + to: destination_vault, + authority: context.accounts.owner.to_account_info(), + }, + ), + transfer_amount, + decimals, + )?; + + match side { + OrderSide::Bid => add_bid(order_book, order_id, price, context.accounts.owner.key()), + OrderSide::Ask => add_ask(order_book, order_id, price, context.accounts.owner.key()), + } + + add_open_order(user_account, order_id); + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(side: OrderSide, price: u64, quantity: u64)] +pub struct PlaceOrderAccountConstraints<'info> { + #[account(mut)] + pub market: Account<'info, Market>, + + #[account( + mut, + seeds = [ORDER_BOOK_SEED, market.key().as_ref()], + bump = order_book.bump + )] + pub order_book: Account<'info, OrderBook>, + + #[account( + init, + payer = owner, + space = Order::DISCRIMINATOR.len() + Order::INIT_SPACE, + seeds = [ + ORDER_SEED, + market.key().as_ref(), + order_book.next_order_id.to_le_bytes().as_ref() + ], + bump + )] + pub order: Account<'info, Order>, + + #[account( + mut, + seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = user_account.bump + )] + pub user_account: Account<'info, UserAccount>, + + // InterfaceAccount on the stack is ~1 KB each; with 6 of them this struct + // blows the 4 KB stack-offset limit on BPF. Boxing moves each to the heap. + #[account(mut)] + pub base_vault: Box>, + + #[account(mut)] + pub quote_vault: Box>, + + #[account(mut)] + pub user_base_account: Box>, + + #[account(mut)] + pub user_quote_account: Box>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + + pub system_program: Program<'info, System>, +} diff --git a/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs b/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs new file mode 100644 index 000000000..84f2d3e39 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs @@ -0,0 +1,98 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::state::{Market, UserAccount, MARKET_SEED, USER_ACCOUNT_SEED}; + +pub fn settle_funds(context: Context) -> Result<()> { + let user_account = &mut context.accounts.user_account; + let market = &context.accounts.market; + + let base_amount = user_account.unsettled_base; + let quote_amount = user_account.unsettled_quote; + + // Seeds to sign as the market PDA (the authority of both vaults). Built + // once and reused for the two possible transfers. + let market_bump = [market.bump]; + let signer_seeds: [&[u8]; 4] = [ + MARKET_SEED, + market.base_mint.as_ref(), + market.quote_mint.as_ref(), + &market_bump, + ]; + let signer_seeds = &[&signer_seeds[..]]; + + if base_amount > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.base_vault.to_account_info(), + mint: context.accounts.base_mint.to_account_info(), + to: context.accounts.user_base_account.to_account_info(), + authority: market.to_account_info(), + }, + signer_seeds, + ), + base_amount, + context.accounts.base_mint.decimals, + )?; + user_account.unsettled_base = 0; + } + + if quote_amount > 0 { + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.quote_vault.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.user_quote_account.to_account_info(), + authority: market.to_account_info(), + }, + signer_seeds, + ), + quote_amount, + context.accounts.quote_mint.decimals, + )?; + user_account.unsettled_quote = 0; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct SettleFundsAccountConstraints<'info> { + #[account(mut)] + pub market: Account<'info, Market>, + + #[account( + mut, + seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = user_account.bump + )] + pub user_account: Account<'info, UserAccount>, + + // Boxed for the same reason as in PlaceOrderAccountConstraints — + // InterfaceAccount is too large to keep on the BPF stack in bulk. + #[account(mut)] + pub base_vault: Box>, + + #[account(mut)] + pub quote_vault: Box>, + + #[account(mut)] + pub user_base_account: Box>, + + #[account(mut)] + pub user_quote_account: Box>, + + pub base_mint: Box>, + + pub quote_mint: Box>, + + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/defi/clob/anchor/programs/clob/src/lib.rs b/defi/clob/anchor/programs/clob/src/lib.rs new file mode 100644 index 000000000..7e713437b --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/lib.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +declare_id!("C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"); + +pub mod errors; +pub mod instructions; +pub mod state; + +use instructions::*; + +#[program] +pub mod clob { + use super::*; + + pub fn initialize_market( + context: Context, + fee_basis_points: u16, + tick_size: u64, + min_order_size: u64, + ) -> Result<()> { + instructions::initialize_market(context, fee_basis_points, tick_size, min_order_size) + } + + pub fn create_user_account( + context: Context, + ) -> Result<()> { + instructions::create_user_account(context) + } + + pub fn place_order( + context: Context, + side: state::OrderSide, + price: u64, + quantity: u64, + ) -> Result<()> { + instructions::place_order(context, side, price, quantity) + } + + pub fn cancel_order(context: Context) -> Result<()> { + instructions::cancel_order(context) + } + + pub fn settle_funds(context: Context) -> Result<()> { + instructions::settle_funds(context) + } +} diff --git a/defi/clob/anchor/programs/clob/src/state/market.rs b/defi/clob/anchor/programs/clob/src/state/market.rs new file mode 100644 index 000000000..30c82e158 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/market.rs @@ -0,0 +1,32 @@ +use anchor_lang::prelude::*; + +pub const MARKET_SEED: &[u8] = b"market"; + +// A Market is one trading pair (base/quote) with its own vaults and order book. +// The market PDA itself is the authority of the token vaults, so funds can only +// move out via program-signed CPIs (place/cancel/settle). +#[derive(InitSpace)] +#[account] +pub struct Market { + pub authority: Pubkey, + + pub base_mint: Pubkey, + + pub quote_mint: Pubkey, + + pub base_vault: Pubkey, + + pub quote_vault: Pubkey, + + pub order_book: Pubkey, + + pub fee_basis_points: u16, + + pub tick_size: u64, + + pub min_order_size: u64, + + pub is_active: bool, + + pub bump: u8, +} diff --git a/defi/clob/anchor/programs/clob/src/state/mod.rs b/defi/clob/anchor/programs/clob/src/state/mod.rs new file mode 100644 index 000000000..59f622d5b --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/mod.rs @@ -0,0 +1,9 @@ +pub mod market; +pub mod order; +pub mod order_book; +pub mod user_account; + +pub use market::*; +pub use order::*; +pub use order_book::*; +pub use user_account::*; diff --git a/defi/clob/anchor/programs/clob/src/state/order.rs b/defi/clob/anchor/programs/clob/src/state/order.rs new file mode 100644 index 000000000..24e4d264e --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/order.rs @@ -0,0 +1,45 @@ +use anchor_lang::prelude::*; + +pub const ORDER_SEED: &[u8] = b"order"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum OrderSide { + Bid, + Ask, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace)] +pub enum OrderStatus { + Open, + PartiallyFilled, + Filled, + Cancelled, +} + +#[derive(InitSpace)] +#[account] +pub struct Order { + pub market: Pubkey, + + pub owner: Pubkey, + + pub order_id: u64, + + pub side: OrderSide, + + pub price: u64, + + pub original_quantity: u64, + + pub filled_quantity: u64, + + pub status: OrderStatus, + + pub timestamp: i64, + + pub bump: u8, +} + +pub fn remaining_quantity(order: &Order) -> u64 { + order.original_quantity.saturating_sub(order.filled_quantity) +} diff --git a/defi/clob/anchor/programs/clob/src/state/order_book.rs b/defi/clob/anchor/programs/clob/src/state/order_book.rs new file mode 100644 index 000000000..1821a5c4b --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/order_book.rs @@ -0,0 +1,71 @@ +use anchor_lang::prelude::*; + +pub const ORDER_BOOK_SEED: &[u8] = b"order_book"; + +// Per-side capacity of the order book. 100 bids + 100 asks is a pragmatic +// ceiling for a teaching example: the whole OrderBook account stays under +// ~10 KB so it fits in a single transaction's account limit without needing +// realloc. Production CLOBs (Openbook, Phoenix) use zero-copy slabs to +// support tens of thousands of orders; that's out of scope here. +pub const MAX_ORDERS_PER_SIDE: usize = 100; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, InitSpace)] +pub struct OrderEntry { + pub order_id: u64, + + pub price: u64, + + pub owner: Pubkey, +} + +#[derive(InitSpace)] +#[account] +pub struct OrderBook { + pub market: Pubkey, + + // Bids are sorted descending by price (best bid first). + #[max_len(100)] + pub bids: Vec, + + // Asks are sorted ascending by price (best ask first). + #[max_len(100)] + pub asks: Vec, + + pub next_order_id: u64, + + pub bump: u8, +} + +pub fn add_bid(book: &mut OrderBook, order_id: u64, price: u64, owner: Pubkey) { + let entry = OrderEntry { order_id, price, owner }; + let insert_position = book + .bids + .iter() + .position(|bid| bid.price < price) + .unwrap_or(book.bids.len()); + book.bids.insert(insert_position, entry); +} + +pub fn add_ask(book: &mut OrderBook, order_id: u64, price: u64, owner: Pubkey) { + let entry = OrderEntry { order_id, price, owner }; + let insert_position = book + .asks + .iter() + .position(|ask| ask.price > price) + .unwrap_or(book.asks.len()); + book.asks.insert(insert_position, entry); +} + +pub fn remove_order(book: &mut OrderBook, order_id: u64) -> bool { + if let Some(position) = book.bids.iter().position(|entry| entry.order_id == order_id) { + book.bids.remove(position); + return true; + } + + if let Some(position) = book.asks.iter().position(|entry| entry.order_id == order_id) { + book.asks.remove(position); + return true; + } + + false +} diff --git a/defi/clob/anchor/programs/clob/src/state/user_account.rs b/defi/clob/anchor/programs/clob/src/state/user_account.rs new file mode 100644 index 000000000..4e683e32f --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/user_account.rs @@ -0,0 +1,38 @@ +use anchor_lang::prelude::*; + +pub const USER_ACCOUNT_SEED: &[u8] = b"user"; + +// Per-user, per-market account. Tracks open order ids and amounts owed back +// to the user (unsettled_*). Settlement moves those amounts from the vaults +// to the user's token accounts in settle_funds. +#[derive(InitSpace)] +#[account] +pub struct UserAccount { + pub market: Pubkey, + + pub owner: Pubkey, + + pub unsettled_base: u64, + + pub unsettled_quote: u64, + + // 20 is chosen to match the matching engine's upper bound: a single user + // shouldn't be able to spam the book. Keep the cap in sync with the + // TooManyOpenOrders check in place_order. + #[max_len(20)] + pub open_orders: Vec, + + pub bump: u8, +} + +pub fn add_open_order(account: &mut UserAccount, order_id: u64) { + if !account.open_orders.contains(&order_id) { + account.open_orders.push(order_id); + } +} + +pub fn remove_open_order(account: &mut UserAccount, order_id: u64) { + if let Some(position) = account.open_orders.iter().position(|&id| id == order_id) { + account.open_orders.remove(position); + } +} diff --git a/defi/clob/anchor/tests/clob.test.ts b/defi/clob/anchor/tests/clob.test.ts new file mode 100644 index 000000000..acfc5c933 --- /dev/null +++ b/defi/clob/anchor/tests/clob.test.ts @@ -0,0 +1,468 @@ +import assert from "node:assert"; +import { before, describe, test } from "node:test"; +import { type Address, generateKeyPairSigner, lamports, type TransactionSigner } from "@solana/kit"; +import { type Connection, connect, TOKEN_EXTENSIONS_PROGRAM } from "solana-kite"; +import { fetchMarket, fetchOrder, fetchOrderBook, fetchUserAccount } from "../dist/clob-client/accounts/index.js"; +import { + getCancelOrderInstructionAsync, + getCreateUserAccountInstructionAsync, + getInitializeMarketInstructionAsync, + getPlaceOrderInstructionAsync, + getSettleFundsInstructionAsync, +} from "../dist/clob-client/instructions/index.js"; +import { CLOB_PROGRAM_ADDRESS } from "../dist/clob-client/programs/index.js"; +import { OrderSide, OrderStatus } from "../dist/clob-client/types/index.js"; + +const ONE_SOL = lamports(1_000_000_000n); + +// Nine decimals matches SOL and most SPL mints; keeps mint_tokens math intuitive. +const MINT_DECIMALS = 9; + +// The trading pair here is "BASE/QUOTE", so 1 BASE costs `price` QUOTE in the book. +const TICK_SIZE = 1n; +const MIN_ORDER_SIZE = 1n; +const FEE_BASIS_POINTS = 10; + +// Chosen so price * quantity stays comfortably inside u64 and the traders +// have enough funding for multiple orders in the same test suite. +const BID_PRICE = 100n; +const BID_QUANTITY = 10n; +const ASK_PRICE = 100n; +const ASK_QUANTITY = 5n; + +// Derive the order PDA for a given order_id. Codama doesn't generate this +// helper because the seed depends on a runtime counter on the order book. +async function deriveOrderAddress(connection: Connection, market: Address, orderId: bigint): Promise
{ + const orderIdBytes = new Uint8Array(new BigUint64Array([orderId]).buffer); + const pda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["order", market, orderIdBytes]); + return pda.pda; +} + +describe("CLOB", () => { + let connection: Connection; + let authority: TransactionSigner; + let buyer: TransactionSigner; + let seller: TransactionSigner; + let baseMint: Address; + let quoteMint: Address; + let marketAddress: Address; + let orderBookAddress: Address; + let baseVault: Address; + let quoteVault: Address; + let buyerUserAccount: Address; + let sellerUserAccount: Address; + let bidOrderId: bigint; + let askOrderId: bigint; + + before(async () => { + connection = await connect(); + + [authority, buyer, seller] = await connection.createWallets(3, { + airdropAmount: ONE_SOL, + }); + + baseMint = await connection.createTokenMint({ + mintAuthority: authority, + decimals: MINT_DECIMALS, + name: "Base Token", + symbol: "BASE", + uri: "https://example.com/base-token", + }); + + quoteMint = await connection.createTokenMint({ + mintAuthority: authority, + decimals: MINT_DECIMALS, + name: "Quote Token", + symbol: "QUOTE", + uri: "https://example.com/quote-token", + }); + + const marketPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["market", baseMint, quoteMint]); + marketAddress = marketPda.pda; + + const orderBookPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["order_book", marketAddress]); + orderBookAddress = orderBookPda.pda; + }); + + test("initializes a market", async () => { + // Vault token accounts are created in-line by the instruction — we only + // need to provide fresh keypairs to act as their addresses. + const baseVaultSigner = await generateKeyPairSigner(); + const quoteVaultSigner = await generateKeyPairSigner(); + + const instruction = await getInitializeMarketInstructionAsync({ + baseMint, + quoteMint, + baseVault: baseVaultSigner, + quoteVault: quoteVaultSigner, + authority, + feeBasisPoints: FEE_BASIS_POINTS, + tickSize: TICK_SIZE, + minOrderSize: MIN_ORDER_SIZE, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + + await connection.sendTransactionFromInstructions({ + feePayer: authority, + instructions: [instruction], + }); + + const marketAccount = await fetchMarket(connection.rpc, marketAddress); + assert.strictEqual(marketAccount.data.baseMint, baseMint); + assert.strictEqual(marketAccount.data.quoteMint, quoteMint); + assert.strictEqual(marketAccount.data.feeBasisPoints, FEE_BASIS_POINTS); + assert.strictEqual(marketAccount.data.tickSize, TICK_SIZE); + assert.strictEqual(marketAccount.data.minOrderSize, MIN_ORDER_SIZE); + assert.strictEqual(marketAccount.data.isActive, true); + + baseVault = marketAccount.data.baseVault; + quoteVault = marketAccount.data.quoteVault; + + const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); + assert.strictEqual(orderBook.data.market, marketAddress); + assert.strictEqual(orderBook.data.nextOrderId, 1n); + assert.strictEqual(orderBook.data.bids.length, 0); + assert.strictEqual(orderBook.data.asks.length, 0); + }); + + test("creates user accounts for buyer and seller", async () => { + const buyerPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["user", marketAddress, buyer.address]); + buyerUserAccount = buyerPda.pda; + + const sellerPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["user", marketAddress, seller.address]); + sellerUserAccount = sellerPda.pda; + + const buyerInstruction = await getCreateUserAccountInstructionAsync({ + market: marketAddress, + owner: buyer, + }); + await connection.sendTransactionFromInstructions({ + feePayer: buyer, + instructions: [buyerInstruction], + }); + + const sellerInstruction = await getCreateUserAccountInstructionAsync({ + market: marketAddress, + owner: seller, + }); + await connection.sendTransactionFromInstructions({ + feePayer: seller, + instructions: [sellerInstruction], + }); + + const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); + assert.strictEqual(buyerAccount.data.market, marketAddress); + assert.strictEqual(buyerAccount.data.owner, buyer.address); + assert.strictEqual(buyerAccount.data.unsettledBase, 0n); + assert.strictEqual(buyerAccount.data.unsettledQuote, 0n); + assert.strictEqual(buyerAccount.data.openOrders.length, 0); + + const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); + assert.strictEqual(sellerAccount.data.owner, seller.address); + }); + + test("buyer places a bid (locks quote in the vault)", async () => { + // Fund the buyer with quote tokens (they pay in quote for base). Also + // create an empty base token account so the instruction can still use it + // as `user_base_account` (it's not touched on a bid, only asks). + const buyerQuoteFunding = 1_000n * 10n ** BigInt(MINT_DECIMALS); + await connection.mintTokens(quoteMint, authority, buyerQuoteFunding, buyer.address); + await connection.mintTokens(baseMint, authority, 0n, buyer.address); + + const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); + const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); + + const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); + bidOrderId = orderBook.data.nextOrderId; + const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); + + const instruction = await getPlaceOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: bidOrderAddress, + userAccount: buyerUserAccount, + baseVault, + quoteVault, + userBaseAccount: buyerBaseAccount, + userQuoteAccount: buyerQuoteAccount, + baseMint, + quoteMint, + owner: buyer, + side: OrderSide.Bid, + price: BID_PRICE, + quantity: BID_QUANTITY, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + + await connection.sendTransactionFromInstructions({ + feePayer: buyer, + instructions: [instruction], + }); + + const order = await fetchOrder(connection.rpc, bidOrderAddress); + assert.strictEqual(order.data.price, BID_PRICE); + assert.strictEqual(order.data.originalQuantity, BID_QUANTITY); + assert.strictEqual(order.data.filledQuantity, 0n); + assert.strictEqual(order.data.side, OrderSide.Bid); + assert.strictEqual(order.data.status, OrderStatus.Open); + assert.strictEqual(order.data.owner, buyer.address); + + // The book should now have one bid and no asks. + const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); + assert.strictEqual(updatedBook.data.bids.length, 1); + assert.strictEqual(updatedBook.data.asks.length, 0); + assert.strictEqual(updatedBook.data.bids[0].price, BID_PRICE); + assert.strictEqual(updatedBook.data.bids[0].orderId, bidOrderId); + + // The buyer's open-orders list should include this order. + const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); + assert.strictEqual(buyerAccount.data.openOrders.length, 1); + assert.strictEqual(buyerAccount.data.openOrders[0], bidOrderId); + }); + + test("seller places an ask (locks base in the vault)", async () => { + const sellerBaseFunding = 1_000n * 10n ** BigInt(MINT_DECIMALS); + await connection.mintTokens(baseMint, authority, sellerBaseFunding, seller.address); + await connection.mintTokens(quoteMint, authority, 0n, seller.address); + + const sellerBaseAccount = await connection.getTokenAccountAddress(seller.address, baseMint, true); + const sellerQuoteAccount = await connection.getTokenAccountAddress(seller.address, quoteMint, true); + + const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); + askOrderId = orderBook.data.nextOrderId; + const askOrderAddress = await deriveOrderAddress(connection, marketAddress, askOrderId); + + const instruction = await getPlaceOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: askOrderAddress, + userAccount: sellerUserAccount, + baseVault, + quoteVault, + userBaseAccount: sellerBaseAccount, + userQuoteAccount: sellerQuoteAccount, + baseMint, + quoteMint, + owner: seller, + side: OrderSide.Ask, + price: ASK_PRICE, + quantity: ASK_QUANTITY, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + + await connection.sendTransactionFromInstructions({ + feePayer: seller, + instructions: [instruction], + }); + + const order = await fetchOrder(connection.rpc, askOrderAddress); + assert.strictEqual(order.data.side, OrderSide.Ask); + assert.strictEqual(order.data.price, ASK_PRICE); + assert.strictEqual(order.data.originalQuantity, ASK_QUANTITY); + assert.strictEqual(order.data.status, OrderStatus.Open); + + const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); + assert.strictEqual(updatedBook.data.bids.length, 1); + assert.strictEqual(updatedBook.data.asks.length, 1); + assert.strictEqual(updatedBook.data.asks[0].orderId, askOrderId); + }); + + test("rejects a bid whose price does not align with the tick size", async () => { + // tick_size is 1 in these tests, so we briefly need a market where tick + // matters. Instead of redeploying we check the check still fires: price + // 0 is rejected by the InvalidPrice guard before the tick check, which + // is a sibling validation. This test asserts the instruction as a whole + // refuses an obviously bad price. + const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); + const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); + + const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); + const orderId = orderBook.data.nextOrderId; + const orderAddress = await deriveOrderAddress(connection, marketAddress, orderId); + + const instruction = await getPlaceOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: orderAddress, + userAccount: buyerUserAccount, + baseVault, + quoteVault, + userBaseAccount: buyerBaseAccount, + userQuoteAccount: buyerQuoteAccount, + baseMint, + quoteMint, + owner: buyer, + side: OrderSide.Bid, + price: 0n, + quantity: BID_QUANTITY, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + + await assert.rejects( + connection.sendTransactionFromInstructions({ + feePayer: buyer, + instructions: [instruction], + }), + "Placing an order at price 0 must fail", + ); + }); + + test("seller cancels the open ask and is credited the locked base", async () => { + const askOrderAddress = await deriveOrderAddress(connection, marketAddress, askOrderId); + + const instruction = await getCancelOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: askOrderAddress, + userAccount: sellerUserAccount, + owner: seller, + }); + + await connection.sendTransactionFromInstructions({ + feePayer: seller, + instructions: [instruction], + }); + + const order = await fetchOrder(connection.rpc, askOrderAddress); + assert.strictEqual(order.data.status, OrderStatus.Cancelled); + + // Cancelling an open ask returns the full original_quantity of base + // tokens to the seller as unsettled_base (nothing was filled). + const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); + assert.strictEqual(sellerAccount.data.unsettledBase, ASK_QUANTITY); + assert.strictEqual(sellerAccount.data.unsettledQuote, 0n); + assert.strictEqual(sellerAccount.data.openOrders.length, 0); + + // The order book should no longer carry the ask. + const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); + assert.strictEqual(updatedBook.data.asks.length, 0); + assert.strictEqual(updatedBook.data.bids.length, 1); + }); + + test("a non-owner cannot cancel an order", async () => { + const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); + + const instruction = await getCancelOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: bidOrderAddress, + // Seller tries to cancel the buyer's bid, using their own user account. + userAccount: sellerUserAccount, + owner: seller, + }); + + await assert.rejects( + connection.sendTransactionFromInstructions({ + feePayer: seller, + instructions: [instruction], + }), + "A non-owner must not be able to cancel someone else's order", + ); + }); + + test("settle_funds moves unsettled base from the vault to the seller", async () => { + const sellerBaseAccount = await connection.getTokenAccountAddress(seller.address, baseMint, true); + const sellerQuoteAccount = await connection.getTokenAccountAddress(seller.address, quoteMint, true); + + const balanceBefore = await connection.getTokenAccountBalance({ + tokenAccount: sellerBaseAccount, + useTokenExtensions: true, + }); + + const instruction = await getSettleFundsInstructionAsync({ + market: marketAddress, + userAccount: sellerUserAccount, + baseVault, + quoteVault, + userBaseAccount: sellerBaseAccount, + userQuoteAccount: sellerQuoteAccount, + baseMint, + quoteMint, + owner: seller, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + + await connection.sendTransactionFromInstructions({ + feePayer: seller, + instructions: [instruction], + }); + + const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); + assert.strictEqual(sellerAccount.data.unsettledBase, 0n); + assert.strictEqual(sellerAccount.data.unsettledQuote, 0n); + + const balanceAfter = await connection.getTokenAccountBalance({ + tokenAccount: sellerBaseAccount, + useTokenExtensions: true, + }); + assert.strictEqual( + balanceAfter.amount - balanceBefore.amount, + ASK_QUANTITY, + "Seller should have received the cancelled ask quantity of base tokens", + ); + }); + + test("buyer cancels their bid and then settles the refunded quote", async () => { + const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); + + const cancelInstruction = await getCancelOrderInstructionAsync({ + market: marketAddress, + orderBook: orderBookAddress, + order: bidOrderAddress, + userAccount: buyerUserAccount, + owner: buyer, + }); + await connection.sendTransactionFromInstructions({ + feePayer: buyer, + instructions: [cancelInstruction], + }); + + const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); + // Cancelling a bid credits back price * quantity in quote. + assert.strictEqual(buyerAccount.data.unsettledQuote, BID_PRICE * BID_QUANTITY); + assert.strictEqual(buyerAccount.data.unsettledBase, 0n); + + const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); + const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); + + const quoteBalanceBefore = await connection.getTokenAccountBalance({ + tokenAccount: buyerQuoteAccount, + useTokenExtensions: true, + }); + + const settleInstruction = await getSettleFundsInstructionAsync({ + market: marketAddress, + userAccount: buyerUserAccount, + baseVault, + quoteVault, + userBaseAccount: buyerBaseAccount, + userQuoteAccount: buyerQuoteAccount, + baseMint, + quoteMint, + owner: buyer, + tokenProgram: TOKEN_EXTENSIONS_PROGRAM, + }); + await connection.sendTransactionFromInstructions({ + feePayer: buyer, + instructions: [settleInstruction], + }); + + const quoteBalanceAfter = await connection.getTokenAccountBalance({ + tokenAccount: buyerQuoteAccount, + useTokenExtensions: true, + }); + assert.strictEqual( + quoteBalanceAfter.amount - quoteBalanceBefore.amount, + BID_PRICE * BID_QUANTITY, + "Buyer should have been refunded the full locked quote amount", + ); + + // Settling leaves the order book empty and the buyer with no open orders. + const finalBook = await fetchOrderBook(connection.rpc, orderBookAddress); + assert.strictEqual(finalBook.data.bids.length, 0); + assert.strictEqual(finalBook.data.asks.length, 0); + + const finalBuyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); + assert.strictEqual(finalBuyerAccount.data.openOrders.length, 0); + }); +}); diff --git a/defi/clob/anchor/tsconfig.json b/defi/clob/anchor/tsconfig.json new file mode 100644 index 000000000..cd5b3286c --- /dev/null +++ b/defi/clob/anchor/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true + } +} From d1c01f67bca84171b632362b11e7ae9426bd361a Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Sat, 18 Apr 2026 16:12:50 +0000 Subject: [PATCH 2/5] test(defi/clob): replace JS tests with LiteSVM Rust, align with repo conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the CLOB example in line with the repo's Anchor 1.0 + LiteSVM Rust test convention established by tokens/escrow and defi/asset-leasing. Previously CLOB shipped a TypeScript suite backed by Codama-generated clients and @solana/kit, which was a one-off in the current examples set. Why: - Every other defi/*/anchor and the tokens/escrow example now uses LiteSVM-driven Rust tests that `include_bytes!` the built .so, share a common test-stack (litesvm + solana-kite + solana-signer), and run under a plain `cargo test`. Contributors moving between examples had to relearn the CLOB harness (Codama client generation, surfpool-vs-legacy validator selection, tsx + node:test runner). - The JS suite also required `anchor test --validator legacy` because Anchor 1.0's default surfpool validator does not expose the websocket RPC methods Kit uses for confirmation. Switching to LiteSVM Rust sidesteps that entirely — no validator at all. Changes: - Anchor.toml: drop anchor_version, package_manager, [hooks], [test] blocks. Set `[scripts] test = "cargo test"` matching asset-leasing. Keep solana_version = 3.1.8 pinned so BPF toolchain stays in lock-step. - programs/clob/Cargo.toml: add [dev-dependencies] for litesvm 0.11, solana-signer 3.0, solana-keypair 3.0.1, solana-kite 0.3. Versions match the rest of the repo to avoid drift. - programs/clob/tests/test_clob.rs: 13 LiteSVM tests covering initialize_market (happy path + zero-tick + oversized-fee rejection), create_user_account, place_order (bid locks quote, ask locks base, zero-price / unaligned-tick / below-min rejections), cancel_order (owner refund credited to unsettled, non-owner rejected), and settle_funds (drains unsettled balance from vault to user ATA for both an ask cancel and a bid cancel + full refund round-trip). - programs/clob/src/lib.rs and instructions/*.rs: rename the verbose `*AccountConstraints` struct suffix to the plain `InitializeMarket`, `PlaceOrder`, etc. naming that every other Anchor example in the repo uses. Rename handler functions from bare `initialize_market` to `handle_initialize_market` to match escrow and asset-leasing. - README.md: replace the TS/Codama test instructions with the Rust LiteSVM flow; drop the surfpool caveat (no longer relevant). - Remove tests/clob.test.ts, package.json, tsconfig.json: no JS/TS scaffolding needed now that testing is pure Rust. - .gitignore: drop the now-unused `dist` entry (the Codama client output directory). Scope note carried into the tests: the program does not run a matching engine, it only keeps the book and escrow the funds. The test file's header comment calls this out so the reader does not look for cross-order settlement tests that cannot exist yet. Test result: `anchor build && cargo test` → 13 passed, 0 failed. --- defi/clob/anchor/.gitignore | 1 - defi/clob/anchor/Anchor.toml | 26 +- defi/clob/anchor/README.md | 8 +- defi/clob/anchor/package.json | 24 - defi/clob/anchor/programs/clob/Cargo.toml | 9 + .../clob/src/instructions/cancel_order.rs | 4 +- .../src/instructions/create_user_account.rs | 4 +- .../src/instructions/initialize_market.rs | 6 +- .../clob/src/instructions/place_order.rs | 6 +- .../clob/src/instructions/settle_funds.rs | 6 +- defi/clob/anchor/programs/clob/src/lib.rs | 44 +- .../anchor/programs/clob/tests/test_clob.rs | 885 ++++++++++++++++++ defi/clob/anchor/tests/clob.test.ts | 468 --------- defi/clob/anchor/tsconfig.json | 11 - 14 files changed, 946 insertions(+), 556 deletions(-) delete mode 100644 defi/clob/anchor/package.json create mode 100644 defi/clob/anchor/programs/clob/tests/test_clob.rs delete mode 100644 defi/clob/anchor/tests/clob.test.ts delete mode 100644 defi/clob/anchor/tsconfig.json diff --git a/defi/clob/anchor/.gitignore b/defi/clob/anchor/.gitignore index abd908750..2e0446b07 100644 --- a/defi/clob/anchor/.gitignore +++ b/defi/clob/anchor/.gitignore @@ -5,4 +5,3 @@ target node_modules test-ledger .yarn -dist diff --git a/defi/clob/anchor/Anchor.toml b/defi/clob/anchor/Anchor.toml index c9e6b3ed8..aed6d136e 100644 --- a/defi/clob/anchor/Anchor.toml +++ b/defi/clob/anchor/Anchor.toml @@ -1,8 +1,7 @@ [toolchain] -anchor_version = "1.0.0" +# Pin Solana to the version used across the repo's Anchor 1.0 examples so the +# bundled test validator and BPF toolchain stay in lock-step. solana_version = "3.1.8" -# pnpm matches the program-examples root package manager. -package_manager = "pnpm" [features] resolution = true @@ -12,23 +11,10 @@ skip-lint = false clob = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx" [provider] -cluster = "localnet" +cluster = "Localnet" wallet = "~/.config/solana/id.json" [scripts] -# Generate the Codama TS client from the built IDL, then run node:test tests -# via tsx. Run with: `anchor test --validator legacy` -# -# The `legacy` flag selects `solana-test-validator`, which Kit can talk to. -# Anchor 1.0's default "surfpool" validator does not accept the websocket RPC -# methods Kit uses for confirmation, so tests time out waiting for their -# first transaction. Revisit when surfpool adds full Kit support. -test = "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts" - -[hooks] - -[test] -# Give the local validator enough time to become reachable before tests start. -startup_wait = 10000 -shutdown_wait = 2000 -upgradeable = false +# LiteSVM Rust tests live under `programs/clob/tests/` and include the built +# `.so` via `include_bytes!`, so a fresh `anchor build` must run first. +test = "cargo test" diff --git a/defi/clob/anchor/README.md b/defi/clob/anchor/README.md index 64299b28d..47f55cb50 100644 --- a/defi/clob/anchor/README.md +++ b/defi/clob/anchor/README.md @@ -34,13 +34,11 @@ anchor build ## Test ```shell -anchor test --validator legacy +anchor test ``` -The `--validator legacy` flag is required: Anchor 1.0's default "surfpool" validator does not yet accept the websocket RPC methods Solana Kit uses for transaction confirmation, so tests hang waiting for their first transaction. `solana-test-validator` works. - -The test script (defined in `Anchor.toml`) first runs `npx create-codama-clients` to generate a TypeScript client from the built IDL into `dist/clob-client/`, then executes the `node:test` suite with `tsx`. +Tests are pure Rust, running against [LiteSVM](https://github.com/LiteSVM/litesvm). They live in `programs/clob/tests/test_clob.rs` and include the built `.so` via `include_bytes!`, so a fresh `anchor build` must run first. `anchor test` does this automatically; alternatively run `anchor build && cargo test`. ## Credit -Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the [Solana Anchor coding skill](https://github.com/mikemaccana/solana-anchor-claude-skill) (Kit + Kite + Codama, `node:test`, no `@coral-xyz/anchor`, no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). +Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). diff --git a/defi/clob/anchor/package.json b/defi/clob/anchor/package.json deleted file mode 100644 index 17c5589f1..000000000 --- a/defi/clob/anchor/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "clob", - "version": "0.1.0", - "license": "ISC", - "type": "module", - "scripts": { - "build": "anchor build", - "codama": "npx create-codama-clients", - "test": "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts" - }, - "dependencies": { - "@solana/kit": "^6.1.0" - }, - "devDependencies": { - "@codama/nodes-from-anchor": "^1.3.8", - "@codama/renderers": "^1.0.34", - "@codama/renderers-js": "^1.7.0", - "@types/node": "^20.11.0", - "codama": "^1.5.0", - "solana-kite": "^3.2.1", - "tsx": "^4.7.0", - "typescript": "^5.7.3" - } -} diff --git a/defi/clob/anchor/programs/clob/Cargo.toml b/defi/clob/anchor/programs/clob/Cargo.toml index 109dec12a..0d1d236fb 100644 --- a/defi/clob/anchor/programs/clob/Cargo.toml +++ b/defi/clob/anchor/programs/clob/Cargo.toml @@ -23,5 +23,14 @@ custom-panic = [] anchor-lang = "1.0.0" anchor-spl = "1.0.0" +[dev-dependencies] +# Match the test stack used by tokens/escrow, defi/asset-leasing, and the +# other LiteSVM-based Anchor examples so contributors can move between them +# without version drift. +litesvm = "0.11.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.3.0" + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs b/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs index ce55f31ad..039b5d7b1 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs @@ -6,7 +6,7 @@ use crate::state::{ OrderStatus, UserAccount, ORDER_BOOK_SEED, ORDER_SEED, USER_ACCOUNT_SEED, }; -pub fn cancel_order(context: Context) -> Result<()> { +pub fn handle_cancel_order(context: Context) -> Result<()> { let order = &mut context.accounts.order; require!( @@ -58,7 +58,7 @@ pub fn cancel_order(context: Context) -> Result<( } #[derive(Accounts)] -pub struct CancelOrderAccountConstraints<'info> { +pub struct CancelOrder<'info> { pub market: Account<'info, Market>, #[account( diff --git a/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs b/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs index 7d56f6905..a46242ced 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use crate::state::{Market, UserAccount, USER_ACCOUNT_SEED}; -pub fn create_user_account(context: Context) -> Result<()> { +pub fn handle_create_user_account(context: Context) -> Result<()> { let user_account = &mut context.accounts.user_account; user_account.market = context.accounts.market.key(); user_account.owner = context.accounts.owner.key(); @@ -15,7 +15,7 @@ pub fn create_user_account(context: Context } #[derive(Accounts)] -pub struct CreateUserAccountAccountConstraints<'info> { +pub struct CreateUserAccount<'info> { #[account( init, payer = owner, diff --git a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs index c95d9ff81..54fc1c101 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs @@ -8,8 +8,8 @@ use crate::state::{Market, OrderBook, MARKET_SEED, ORDER_BOOK_SEED}; // would be nonsensical, so we cap here. const MAX_FEE_BASIS_POINTS: u16 = 10_000; -pub fn initialize_market( - context: Context, +pub fn handle_initialize_market( + context: Context, fee_basis_points: u16, tick_size: u64, min_order_size: u64, @@ -46,7 +46,7 @@ pub fn initialize_market( } #[derive(Accounts)] -pub struct InitializeMarketAccountConstraints<'info> { +pub struct InitializeMarket<'info> { #[account( init, payer = authority, diff --git a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs index 576a71f72..a8ef25d8d 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs @@ -13,8 +13,8 @@ use crate::state::{ // PlaceOrder check reads clearly and the limit is documented in one place. const MAX_OPEN_ORDERS_PER_USER: usize = 20; -pub fn place_order( - context: Context, +pub fn handle_place_order( + context: Context, side: OrderSide, price: u64, quantity: u64, @@ -105,7 +105,7 @@ pub fn place_order( #[derive(Accounts)] #[instruction(side: OrderSide, price: u64, quantity: u64)] -pub struct PlaceOrderAccountConstraints<'info> { +pub struct PlaceOrder<'info> { #[account(mut)] pub market: Account<'info, Market>, diff --git a/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs b/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs index 84f2d3e39..da271f198 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/settle_funds.rs @@ -5,7 +5,7 @@ use anchor_spl::token_interface::{ use crate::state::{Market, UserAccount, MARKET_SEED, USER_ACCOUNT_SEED}; -pub fn settle_funds(context: Context) -> Result<()> { +pub fn handle_settle_funds(context: Context) -> Result<()> { let user_account = &mut context.accounts.user_account; let market = &context.accounts.market; @@ -63,7 +63,7 @@ pub fn settle_funds(context: Context) -> Result<( } #[derive(Accounts)] -pub struct SettleFundsAccountConstraints<'info> { +pub struct SettleFunds<'info> { #[account(mut)] pub market: Account<'info, Market>, @@ -74,7 +74,7 @@ pub struct SettleFundsAccountConstraints<'info> { )] pub user_account: Account<'info, UserAccount>, - // Boxed for the same reason as in PlaceOrderAccountConstraints — + // Boxed for the same reason as in PlaceOrder — // InterfaceAccount is too large to keep on the BPF stack in bulk. #[account(mut)] pub base_vault: Box>, diff --git a/defi/clob/anchor/programs/clob/src/lib.rs b/defi/clob/anchor/programs/clob/src/lib.rs index 7e713437b..5f10bbb91 100644 --- a/defi/clob/anchor/programs/clob/src/lib.rs +++ b/defi/clob/anchor/programs/clob/src/lib.rs @@ -1,46 +1,62 @@ use anchor_lang::prelude::*; -declare_id!("C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"); - pub mod errors; pub mod instructions; pub mod state; use instructions::*; +declare_id!("C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"); + #[program] pub mod clob { use super::*; + /// Create a new market for a (base, quote) pair. Deploys the market PDA, + /// the order book PDA, and the two PDA-authority vaults that hold locked + /// funds while orders are open. pub fn initialize_market( - context: Context, + context: Context, fee_basis_points: u16, tick_size: u64, min_order_size: u64, ) -> Result<()> { - instructions::initialize_market(context, fee_basis_points, tick_size, min_order_size) + instructions::initialize_market::handle_initialize_market( + context, + fee_basis_points, + tick_size, + min_order_size, + ) } - pub fn create_user_account( - context: Context, - ) -> Result<()> { - instructions::create_user_account(context) + /// Create a per-user, per-market account that tracks a user's open orders + /// and unsettled balances. + pub fn create_user_account(context: Context) -> Result<()> { + instructions::create_user_account::handle_create_user_account(context) } + /// Place a bid or ask. Locks the required funds (quote for bids, base for + /// asks) into the market vault and inserts the order into the book at the + /// correct price-time-priority position. pub fn place_order( - context: Context, + context: Context, side: state::OrderSide, price: u64, quantity: u64, ) -> Result<()> { - instructions::place_order(context, side, price, quantity) + instructions::place_order::handle_place_order(context, side, price, quantity) } - pub fn cancel_order(context: Context) -> Result<()> { - instructions::cancel_order(context) + /// Cancel an open (or partially filled) order. Credits the remaining + /// locked amount back to the owner's unsettled balance; the actual token + /// transfer happens on settle_funds. + pub fn cancel_order(context: Context) -> Result<()> { + instructions::cancel_order::handle_cancel_order(context) } - pub fn settle_funds(context: Context) -> Result<()> { - instructions::settle_funds(context) + /// Move accumulated unsettled balances out of the market vault and into + /// the user's token accounts. No-op if both balances are zero. + pub fn settle_funds(context: Context) -> Result<()> { + instructions::settle_funds::handle_settle_funds(context) } } diff --git a/defi/clob/anchor/programs/clob/tests/test_clob.rs b/defi/clob/anchor/programs/clob/tests/test_clob.rs new file mode 100644 index 000000000..e1d58e54c --- /dev/null +++ b/defi/clob/anchor/programs/clob/tests/test_clob.rs @@ -0,0 +1,885 @@ +//! LiteSVM tests for the CLOB program. +//! +//! Covers the full lifecycle that the program supports: initialise a market, +//! create user accounts, place bids/asks (locking the appropriate vault), +//! reject invalid prices / tick-aligned prices / undersized quantities, +//! cancel orders (which credits unsettled balances), enforce cancel +//! authorisation, and settle funds out of the vaults. +//! +//! Note: this example's `place_order` does NOT cross the book. It is a +//! "book keeper" example — the matching engine is intentionally left out to +//! keep the example scoped to CLOB data structures, vault escrow, and the +//! unsettled-balance pattern. Tests therefore do not exercise crossing. + +use { + anchor_lang::{ + solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +// Keep test-side seeds in sync with `programs/clob/src/state/*`. Duplicated +// rather than imported so tests stay self-contained and exercise the same +// byte strings a client SDK would use. +const MARKET_SEED: &[u8] = b"market"; +const ORDER_BOOK_SEED: &[u8] = b"order_book"; +const ORDER_SEED: &[u8] = b"order"; +const USER_ACCOUNT_SEED: &[u8] = b"user"; + +// Six decimals matches USDC and keeps "1 token" == 1_000_000 base units, +// which keeps the arithmetic in the assertions easy to read. +const MINT_DECIMALS: u8 = 6; + +// Market parameters used across every test. `tick_size = 1` is permissive +// enough for most scenarios; a dedicated test overrides it to verify the +// tick check fires. +const FEE_BASIS_POINTS: u16 = 10; +const TICK_SIZE: u64 = 1; +const MIN_ORDER_SIZE: u64 = 1; + +// Funding for each trader's token accounts. Large enough to cover every +// order placed in the tests with room to spare. +const TRADER_STARTING_BALANCE: u64 = 1_000_000_000; + +// Shared order sizing — chosen so price * quantity stays well inside u64 +// and the seller's ask sits at the same price as the buyer's bid (matching +// is not implemented, they just coexist in the book). +const BID_PRICE: u64 = 100; +const BID_QUANTITY: u64 = 10; +const ASK_PRICE: u64 = 100; +const ASK_QUANTITY: u64 = 5; + +fn token_program_id() -> Pubkey { + // The program accepts either SPL Token or Token-2022 via `TokenInterface`; + // we use classic SPL Token for tests because solana-kite's helpers create + // classic-token mints. + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn market_pdas(program_id: &Pubkey, base_mint: &Pubkey, quote_mint: &Pubkey) -> (Pubkey, Pubkey) { + let (market, _) = Pubkey::find_program_address( + &[MARKET_SEED, base_mint.as_ref(), quote_mint.as_ref()], + program_id, + ); + let (order_book, _) = + Pubkey::find_program_address(&[ORDER_BOOK_SEED, market.as_ref()], program_id); + (market, order_book) +} + +fn user_account_pda(program_id: &Pubkey, market: &Pubkey, owner: &Pubkey) -> Pubkey { + let (user_account, _) = Pubkey::find_program_address( + &[USER_ACCOUNT_SEED, market.as_ref(), owner.as_ref()], + program_id, + ); + user_account +} + +fn order_pda(program_id: &Pubkey, market: &Pubkey, order_id: u64) -> Pubkey { + let (order, _) = Pubkey::find_program_address( + &[ORDER_SEED, market.as_ref(), &order_id.to_le_bytes()], + program_id, + ); + order +} + +// --------------------------------------------------------------------------- +// Scenario: a market with a buyer and a seller, both funded in both mints. +// --------------------------------------------------------------------------- + +struct Scenario { + svm: LiteSVM, + program_id: Pubkey, + // `payer` funds the mint authority + ATA creations during setup but is + // not used directly by the tests afterwards. + #[allow(dead_code)] + payer: Keypair, + authority: Keypair, + buyer: Keypair, + seller: Keypair, + base_mint: Pubkey, + quote_mint: Pubkey, + base_vault: Keypair, + quote_vault: Keypair, + market: Pubkey, + order_book: Pubkey, + buyer_base_ata: Pubkey, + buyer_quote_ata: Pubkey, + seller_base_ata: Pubkey, + seller_quote_ata: Pubkey, + buyer_user_account: Pubkey, + seller_user_account: Pubkey, +} + +fn full_setup() -> Scenario { + let program_id = clob::id(); + let mut svm = LiteSVM::new(); + let program_bytes = include_bytes!("../../../target/deploy/clob.so"); + svm.add_program(program_id, program_bytes).unwrap(); + + // 100 SOL for the payer is overkill, but rent + a few init-ATA hops add + // up and a generous balance keeps setup logic simple. + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let authority = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let buyer = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let seller = create_wallet(&mut svm, 10_000_000_000).unwrap(); + + let base_mint = create_token_mint(&mut svm, &authority, MINT_DECIMALS, None).unwrap(); + let quote_mint = create_token_mint(&mut svm, &authority, MINT_DECIMALS, None).unwrap(); + + // Create and fund every trader's ATAs up-front so individual tests do + // not need to worry about mint/ATA side effects, only about CLOB state. + let buyer_base_ata = + create_associated_token_account(&mut svm, &buyer.pubkey(), &base_mint, &payer).unwrap(); + let buyer_quote_ata = + create_associated_token_account(&mut svm, &buyer.pubkey(), "e_mint, &payer).unwrap(); + let seller_base_ata = + create_associated_token_account(&mut svm, &seller.pubkey(), &base_mint, &payer).unwrap(); + let seller_quote_ata = + create_associated_token_account(&mut svm, &seller.pubkey(), "e_mint, &payer).unwrap(); + + mint_tokens_to_token_account( + &mut svm, + &base_mint, + &seller_base_ata, + TRADER_STARTING_BALANCE, + &authority, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut svm, + "e_mint, + &buyer_quote_ata, + TRADER_STARTING_BALANCE, + &authority, + ) + .unwrap(); + + let (market, order_book) = market_pdas(&program_id, &base_mint, "e_mint); + let buyer_user_account = user_account_pda(&program_id, &market, &buyer.pubkey()); + let seller_user_account = user_account_pda(&program_id, &market, &seller.pubkey()); + + // Vaults are plain token accounts created in-line by initialize_market + // (not PDAs). Tests generate fresh keypairs to serve as their addresses. + let base_vault = Keypair::new(); + let quote_vault = Keypair::new(); + + Scenario { + svm, + program_id, + payer, + authority, + buyer, + seller, + base_mint, + quote_mint, + base_vault, + quote_vault, + market, + order_book, + buyer_base_ata, + buyer_quote_ata, + seller_base_ata, + seller_quote_ata, + buyer_user_account, + seller_user_account, + } +} + +// --------------------------------------------------------------------------- +// Instruction builders — one per program entry point. +// --------------------------------------------------------------------------- + +fn build_initialize_market_ix( + sc: &Scenario, + fee_basis_points: u16, + tick_size: u64, + min_order_size: u64, +) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::InitializeMarket { + fee_basis_points, + tick_size, + min_order_size, + } + .data(), + clob::accounts::InitializeMarket { + market: sc.market, + order_book: sc.order_book, + base_mint: sc.base_mint, + quote_mint: sc.quote_mint, + base_vault: sc.base_vault.pubkey(), + quote_vault: sc.quote_vault.pubkey(), + authority: sc.authority.pubkey(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_create_user_account_ix(sc: &Scenario, owner: &Pubkey) -> Instruction { + let user_account = user_account_pda(&sc.program_id, &sc.market, owner); + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::CreateUserAccount {}.data(), + clob::accounts::CreateUserAccount { + user_account, + market: sc.market, + owner: *owner, + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_place_order_ix( + sc: &Scenario, + owner: &Keypair, + user_account: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, + side: clob::state::OrderSide, + order_id: u64, + price: u64, + quantity: u64, +) -> Instruction { + let order = order_pda(&sc.program_id, &sc.market, order_id); + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::PlaceOrder { + side, + price, + quantity, + } + .data(), + clob::accounts::PlaceOrder { + market: sc.market, + order_book: sc.order_book, + order, + user_account, + base_vault: sc.base_vault.pubkey(), + quote_vault: sc.quote_vault.pubkey(), + user_base_account, + user_quote_account, + base_mint: sc.base_mint, + quote_mint: sc.quote_mint, + owner: owner.pubkey(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_cancel_order_ix( + sc: &Scenario, + owner: &Pubkey, + user_account: Pubkey, + order_id: u64, +) -> Instruction { + let order = order_pda(&sc.program_id, &sc.market, order_id); + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::CancelOrder {}.data(), + clob::accounts::CancelOrder { + market: sc.market, + order_book: sc.order_book, + order, + user_account, + owner: *owner, + } + .to_account_metas(None), + ) +} + +fn build_settle_funds_ix( + sc: &Scenario, + owner: &Pubkey, + user_account: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::SettleFunds {}.data(), + clob::accounts::SettleFunds { + market: sc.market, + user_account, + base_vault: sc.base_vault.pubkey(), + quote_vault: sc.quote_vault.pubkey(), + user_base_account, + user_quote_account, + base_mint: sc.base_mint, + quote_mint: sc.quote_mint, + owner: *owner, + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + +// Convenience: run `initialize_market` with the shared test parameters and +// both user-account creations so tests that just want a ready-to-trade +// market do not have to repeat the boilerplate. +fn initialize_market_and_users(sc: &mut Scenario) { + let init_ix = build_initialize_market_ix(sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![init_ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ) + .unwrap(); + + let buyer_ix = build_create_user_account_ix(sc, &sc.buyer.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![buyer_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + let seller_ix = build_create_user_account_ix(sc, &sc.seller.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![seller_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn initialize_market_sets_market_and_order_book() { + let mut sc = full_setup(); + + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ) + .unwrap(); + + // The market PDA is owned by the program and non-empty. + let market_account = sc.svm.get_account(&sc.market).expect("market PDA missing"); + assert_eq!(market_account.owner, sc.program_id); + assert!(!market_account.data.is_empty()); + + let order_book_account = sc + .svm + .get_account(&sc.order_book) + .expect("order book PDA missing"); + assert_eq!(order_book_account.owner, sc.program_id); + + // Vaults were created with the market as authority; easiest check is + // simply that they exist with a zero balance. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + 0 + ); +} + +#[test] +fn create_user_account_tracks_market_and_owner() { + let mut sc = full_setup(); + + let init_ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![init_ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_user_account_ix(&sc, &sc.buyer.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + let user_account = sc + .svm + .get_account(&sc.buyer_user_account) + .expect("user account PDA missing"); + assert_eq!(user_account.owner, sc.program_id); +} + +#[test] +fn place_bid_locks_quote_in_vault() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // The first order ever placed gets id = 1 (see initialize_market.rs). + let bid_order_id = 1u64; + let ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + bid_order_id, + BID_PRICE, + BID_QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()) + .unwrap(); + + // A bid locks price * quantity in the quote vault. + let locked_quote = BID_PRICE * BID_QUANTITY; + assert_eq!( + get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + locked_quote + ); + // Buyer's quote ATA dropped by exactly that. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), + TRADER_STARTING_BALANCE - locked_quote + ); + // Base vault untouched — bids never move base tokens. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + 0 + ); + + // Order PDA exists and is owned by the program. + let order_account = sc + .svm + .get_account(&order_pda(&sc.program_id, &sc.market, bid_order_id)) + .expect("order PDA missing"); + assert_eq!(order_account.owner, sc.program_id); +} + +#[test] +fn place_ask_locks_base_in_vault() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let ask_order_id = 1u64; + let ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + ask_order_id, + ASK_PRICE, + ASK_QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey()) + .unwrap(); + + // An ask locks `quantity` of base tokens in the base vault. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + ASK_QUANTITY + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE - ASK_QUANTITY + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + 0 + ); +} + +#[test] +fn place_order_rejects_zero_price() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let order_id = 1u64; + let ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + order_id, + // Price 0 trips InvalidPrice before tick-size is even considered. + 0, + BID_QUANTITY, + ); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ); + assert!(result.is_err(), "order at price 0 must be rejected"); +} + +#[test] +fn place_order_rejects_unaligned_tick() { + let mut sc = full_setup(); + + // Override default TICK_SIZE for this test so we can place a mis-aligned + // price and see the tick check fire. + let unusual_tick_size: u64 = 50; + let init_ix = + build_initialize_market_ix(&sc, FEE_BASIS_POINTS, unusual_tick_size, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![init_ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_user_account_ix(&sc, &sc.buyer.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // 75 is not a multiple of 50 — must be rejected by the tick check. + let unaligned_price: u64 = 75; + let ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + 1, + unaligned_price, + BID_QUANTITY, + ); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ); + assert!( + result.is_err(), + "unaligned price must be rejected by tick check" + ); +} + +#[test] +fn place_order_rejects_below_min_order_size() { + let mut sc = full_setup(); + + // Force a higher min_order_size so we can place an order below it. + let elevated_min_order_size: u64 = 10; + let init_ix = + build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, elevated_min_order_size); + send_transaction_from_instructions( + &mut sc.svm, + vec![init_ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_user_account_ix(&sc, &sc.seller.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + let too_small_quantity: u64 = 1; + let ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + 1, + ASK_PRICE, + too_small_quantity, + ); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.seller], + &sc.seller.pubkey(), + ); + assert!( + result.is_err(), + "quantity below min_order_size must be rejected" + ); +} + +#[test] +fn cancel_ask_credits_unsettled_base() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Seller places an ask, then cancels it. The full locked base should be + // credited to unsettled_base (no settlement yet). + let ask_order_id = 1u64; + let place_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + ask_order_id, + ASK_PRICE, + ASK_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![place_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_user_account, + ask_order_id, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![cancel_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + // Funds are still in the vault — cancel does not move tokens, it only + // updates the unsettled balance. Settlement is a separate step. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + ASK_QUANTITY + ); + // Seller's ATA hasn't received anything back yet. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE - ASK_QUANTITY + ); +} + +#[test] +fn cancel_order_rejects_non_owner() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Buyer places a bid; seller tries to cancel it using their own user + // account. The program's `order.owner == signer` check must reject. + let bid_order_id = 1u64; + let place_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + bid_order_id, + BID_PRICE, + BID_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![place_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + let attack_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_user_account, + bid_order_id, + ); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![attack_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ); + assert!( + result.is_err(), + "non-owner must not be able to cancel an order" + ); +} + +#[test] +fn settle_funds_moves_unsettled_base_to_user() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Seller places + cancels an ask → credits unsettled_base. + let ask_order_id = 1u64; + let place_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + ask_order_id, + ASK_PRICE, + ASK_QUANTITY, + ); + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_user_account, + ask_order_id, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![place_ix, cancel_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + let settle_ix = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + // Vault drained, seller got their base tokens back in full. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn cancel_and_settle_bid_refunds_full_quote() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let bid_order_id = 1u64; + let place_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + bid_order_id, + BID_PRICE, + BID_QUANTITY, + ); + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_user_account, + bid_order_id, + ); + let settle_ix = build_settle_funds_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![place_ix, cancel_ix, settle_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Vault drained, buyer got the full price*quantity of quote back. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn initialize_market_rejects_zero_tick_size() { + let mut sc = full_setup(); + + let zero_tick_size: u64 = 0; + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, zero_tick_size, MIN_ORDER_SIZE); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ); + assert!(result.is_err(), "tick_size == 0 must be rejected"); +} + +#[test] +fn initialize_market_rejects_oversized_fee() { + let mut sc = full_setup(); + + // 10_000 bps == 100% is the cap; anything higher must fail. + let over_cap_fee_basis_points: u16 = 10_001; + let ix = build_initialize_market_ix( + &sc, + over_cap_fee_basis_points, + TICK_SIZE, + MIN_ORDER_SIZE, + ); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &sc.authority.pubkey(), + ); + assert!( + result.is_err(), + "fee_basis_points above 10_000 must be rejected" + ); +} diff --git a/defi/clob/anchor/tests/clob.test.ts b/defi/clob/anchor/tests/clob.test.ts deleted file mode 100644 index acfc5c933..000000000 --- a/defi/clob/anchor/tests/clob.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -import assert from "node:assert"; -import { before, describe, test } from "node:test"; -import { type Address, generateKeyPairSigner, lamports, type TransactionSigner } from "@solana/kit"; -import { type Connection, connect, TOKEN_EXTENSIONS_PROGRAM } from "solana-kite"; -import { fetchMarket, fetchOrder, fetchOrderBook, fetchUserAccount } from "../dist/clob-client/accounts/index.js"; -import { - getCancelOrderInstructionAsync, - getCreateUserAccountInstructionAsync, - getInitializeMarketInstructionAsync, - getPlaceOrderInstructionAsync, - getSettleFundsInstructionAsync, -} from "../dist/clob-client/instructions/index.js"; -import { CLOB_PROGRAM_ADDRESS } from "../dist/clob-client/programs/index.js"; -import { OrderSide, OrderStatus } from "../dist/clob-client/types/index.js"; - -const ONE_SOL = lamports(1_000_000_000n); - -// Nine decimals matches SOL and most SPL mints; keeps mint_tokens math intuitive. -const MINT_DECIMALS = 9; - -// The trading pair here is "BASE/QUOTE", so 1 BASE costs `price` QUOTE in the book. -const TICK_SIZE = 1n; -const MIN_ORDER_SIZE = 1n; -const FEE_BASIS_POINTS = 10; - -// Chosen so price * quantity stays comfortably inside u64 and the traders -// have enough funding for multiple orders in the same test suite. -const BID_PRICE = 100n; -const BID_QUANTITY = 10n; -const ASK_PRICE = 100n; -const ASK_QUANTITY = 5n; - -// Derive the order PDA for a given order_id. Codama doesn't generate this -// helper because the seed depends on a runtime counter on the order book. -async function deriveOrderAddress(connection: Connection, market: Address, orderId: bigint): Promise
{ - const orderIdBytes = new Uint8Array(new BigUint64Array([orderId]).buffer); - const pda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["order", market, orderIdBytes]); - return pda.pda; -} - -describe("CLOB", () => { - let connection: Connection; - let authority: TransactionSigner; - let buyer: TransactionSigner; - let seller: TransactionSigner; - let baseMint: Address; - let quoteMint: Address; - let marketAddress: Address; - let orderBookAddress: Address; - let baseVault: Address; - let quoteVault: Address; - let buyerUserAccount: Address; - let sellerUserAccount: Address; - let bidOrderId: bigint; - let askOrderId: bigint; - - before(async () => { - connection = await connect(); - - [authority, buyer, seller] = await connection.createWallets(3, { - airdropAmount: ONE_SOL, - }); - - baseMint = await connection.createTokenMint({ - mintAuthority: authority, - decimals: MINT_DECIMALS, - name: "Base Token", - symbol: "BASE", - uri: "https://example.com/base-token", - }); - - quoteMint = await connection.createTokenMint({ - mintAuthority: authority, - decimals: MINT_DECIMALS, - name: "Quote Token", - symbol: "QUOTE", - uri: "https://example.com/quote-token", - }); - - const marketPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["market", baseMint, quoteMint]); - marketAddress = marketPda.pda; - - const orderBookPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["order_book", marketAddress]); - orderBookAddress = orderBookPda.pda; - }); - - test("initializes a market", async () => { - // Vault token accounts are created in-line by the instruction — we only - // need to provide fresh keypairs to act as their addresses. - const baseVaultSigner = await generateKeyPairSigner(); - const quoteVaultSigner = await generateKeyPairSigner(); - - const instruction = await getInitializeMarketInstructionAsync({ - baseMint, - quoteMint, - baseVault: baseVaultSigner, - quoteVault: quoteVaultSigner, - authority, - feeBasisPoints: FEE_BASIS_POINTS, - tickSize: TICK_SIZE, - minOrderSize: MIN_ORDER_SIZE, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - - await connection.sendTransactionFromInstructions({ - feePayer: authority, - instructions: [instruction], - }); - - const marketAccount = await fetchMarket(connection.rpc, marketAddress); - assert.strictEqual(marketAccount.data.baseMint, baseMint); - assert.strictEqual(marketAccount.data.quoteMint, quoteMint); - assert.strictEqual(marketAccount.data.feeBasisPoints, FEE_BASIS_POINTS); - assert.strictEqual(marketAccount.data.tickSize, TICK_SIZE); - assert.strictEqual(marketAccount.data.minOrderSize, MIN_ORDER_SIZE); - assert.strictEqual(marketAccount.data.isActive, true); - - baseVault = marketAccount.data.baseVault; - quoteVault = marketAccount.data.quoteVault; - - const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); - assert.strictEqual(orderBook.data.market, marketAddress); - assert.strictEqual(orderBook.data.nextOrderId, 1n); - assert.strictEqual(orderBook.data.bids.length, 0); - assert.strictEqual(orderBook.data.asks.length, 0); - }); - - test("creates user accounts for buyer and seller", async () => { - const buyerPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["user", marketAddress, buyer.address]); - buyerUserAccount = buyerPda.pda; - - const sellerPda = await connection.getPDAAndBump(CLOB_PROGRAM_ADDRESS, ["user", marketAddress, seller.address]); - sellerUserAccount = sellerPda.pda; - - const buyerInstruction = await getCreateUserAccountInstructionAsync({ - market: marketAddress, - owner: buyer, - }); - await connection.sendTransactionFromInstructions({ - feePayer: buyer, - instructions: [buyerInstruction], - }); - - const sellerInstruction = await getCreateUserAccountInstructionAsync({ - market: marketAddress, - owner: seller, - }); - await connection.sendTransactionFromInstructions({ - feePayer: seller, - instructions: [sellerInstruction], - }); - - const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); - assert.strictEqual(buyerAccount.data.market, marketAddress); - assert.strictEqual(buyerAccount.data.owner, buyer.address); - assert.strictEqual(buyerAccount.data.unsettledBase, 0n); - assert.strictEqual(buyerAccount.data.unsettledQuote, 0n); - assert.strictEqual(buyerAccount.data.openOrders.length, 0); - - const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); - assert.strictEqual(sellerAccount.data.owner, seller.address); - }); - - test("buyer places a bid (locks quote in the vault)", async () => { - // Fund the buyer with quote tokens (they pay in quote for base). Also - // create an empty base token account so the instruction can still use it - // as `user_base_account` (it's not touched on a bid, only asks). - const buyerQuoteFunding = 1_000n * 10n ** BigInt(MINT_DECIMALS); - await connection.mintTokens(quoteMint, authority, buyerQuoteFunding, buyer.address); - await connection.mintTokens(baseMint, authority, 0n, buyer.address); - - const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); - const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); - - const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); - bidOrderId = orderBook.data.nextOrderId; - const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); - - const instruction = await getPlaceOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: bidOrderAddress, - userAccount: buyerUserAccount, - baseVault, - quoteVault, - userBaseAccount: buyerBaseAccount, - userQuoteAccount: buyerQuoteAccount, - baseMint, - quoteMint, - owner: buyer, - side: OrderSide.Bid, - price: BID_PRICE, - quantity: BID_QUANTITY, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - - await connection.sendTransactionFromInstructions({ - feePayer: buyer, - instructions: [instruction], - }); - - const order = await fetchOrder(connection.rpc, bidOrderAddress); - assert.strictEqual(order.data.price, BID_PRICE); - assert.strictEqual(order.data.originalQuantity, BID_QUANTITY); - assert.strictEqual(order.data.filledQuantity, 0n); - assert.strictEqual(order.data.side, OrderSide.Bid); - assert.strictEqual(order.data.status, OrderStatus.Open); - assert.strictEqual(order.data.owner, buyer.address); - - // The book should now have one bid and no asks. - const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); - assert.strictEqual(updatedBook.data.bids.length, 1); - assert.strictEqual(updatedBook.data.asks.length, 0); - assert.strictEqual(updatedBook.data.bids[0].price, BID_PRICE); - assert.strictEqual(updatedBook.data.bids[0].orderId, bidOrderId); - - // The buyer's open-orders list should include this order. - const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); - assert.strictEqual(buyerAccount.data.openOrders.length, 1); - assert.strictEqual(buyerAccount.data.openOrders[0], bidOrderId); - }); - - test("seller places an ask (locks base in the vault)", async () => { - const sellerBaseFunding = 1_000n * 10n ** BigInt(MINT_DECIMALS); - await connection.mintTokens(baseMint, authority, sellerBaseFunding, seller.address); - await connection.mintTokens(quoteMint, authority, 0n, seller.address); - - const sellerBaseAccount = await connection.getTokenAccountAddress(seller.address, baseMint, true); - const sellerQuoteAccount = await connection.getTokenAccountAddress(seller.address, quoteMint, true); - - const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); - askOrderId = orderBook.data.nextOrderId; - const askOrderAddress = await deriveOrderAddress(connection, marketAddress, askOrderId); - - const instruction = await getPlaceOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: askOrderAddress, - userAccount: sellerUserAccount, - baseVault, - quoteVault, - userBaseAccount: sellerBaseAccount, - userQuoteAccount: sellerQuoteAccount, - baseMint, - quoteMint, - owner: seller, - side: OrderSide.Ask, - price: ASK_PRICE, - quantity: ASK_QUANTITY, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - - await connection.sendTransactionFromInstructions({ - feePayer: seller, - instructions: [instruction], - }); - - const order = await fetchOrder(connection.rpc, askOrderAddress); - assert.strictEqual(order.data.side, OrderSide.Ask); - assert.strictEqual(order.data.price, ASK_PRICE); - assert.strictEqual(order.data.originalQuantity, ASK_QUANTITY); - assert.strictEqual(order.data.status, OrderStatus.Open); - - const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); - assert.strictEqual(updatedBook.data.bids.length, 1); - assert.strictEqual(updatedBook.data.asks.length, 1); - assert.strictEqual(updatedBook.data.asks[0].orderId, askOrderId); - }); - - test("rejects a bid whose price does not align with the tick size", async () => { - // tick_size is 1 in these tests, so we briefly need a market where tick - // matters. Instead of redeploying we check the check still fires: price - // 0 is rejected by the InvalidPrice guard before the tick check, which - // is a sibling validation. This test asserts the instruction as a whole - // refuses an obviously bad price. - const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); - const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); - - const orderBook = await fetchOrderBook(connection.rpc, orderBookAddress); - const orderId = orderBook.data.nextOrderId; - const orderAddress = await deriveOrderAddress(connection, marketAddress, orderId); - - const instruction = await getPlaceOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: orderAddress, - userAccount: buyerUserAccount, - baseVault, - quoteVault, - userBaseAccount: buyerBaseAccount, - userQuoteAccount: buyerQuoteAccount, - baseMint, - quoteMint, - owner: buyer, - side: OrderSide.Bid, - price: 0n, - quantity: BID_QUANTITY, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - - await assert.rejects( - connection.sendTransactionFromInstructions({ - feePayer: buyer, - instructions: [instruction], - }), - "Placing an order at price 0 must fail", - ); - }); - - test("seller cancels the open ask and is credited the locked base", async () => { - const askOrderAddress = await deriveOrderAddress(connection, marketAddress, askOrderId); - - const instruction = await getCancelOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: askOrderAddress, - userAccount: sellerUserAccount, - owner: seller, - }); - - await connection.sendTransactionFromInstructions({ - feePayer: seller, - instructions: [instruction], - }); - - const order = await fetchOrder(connection.rpc, askOrderAddress); - assert.strictEqual(order.data.status, OrderStatus.Cancelled); - - // Cancelling an open ask returns the full original_quantity of base - // tokens to the seller as unsettled_base (nothing was filled). - const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); - assert.strictEqual(sellerAccount.data.unsettledBase, ASK_QUANTITY); - assert.strictEqual(sellerAccount.data.unsettledQuote, 0n); - assert.strictEqual(sellerAccount.data.openOrders.length, 0); - - // The order book should no longer carry the ask. - const updatedBook = await fetchOrderBook(connection.rpc, orderBookAddress); - assert.strictEqual(updatedBook.data.asks.length, 0); - assert.strictEqual(updatedBook.data.bids.length, 1); - }); - - test("a non-owner cannot cancel an order", async () => { - const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); - - const instruction = await getCancelOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: bidOrderAddress, - // Seller tries to cancel the buyer's bid, using their own user account. - userAccount: sellerUserAccount, - owner: seller, - }); - - await assert.rejects( - connection.sendTransactionFromInstructions({ - feePayer: seller, - instructions: [instruction], - }), - "A non-owner must not be able to cancel someone else's order", - ); - }); - - test("settle_funds moves unsettled base from the vault to the seller", async () => { - const sellerBaseAccount = await connection.getTokenAccountAddress(seller.address, baseMint, true); - const sellerQuoteAccount = await connection.getTokenAccountAddress(seller.address, quoteMint, true); - - const balanceBefore = await connection.getTokenAccountBalance({ - tokenAccount: sellerBaseAccount, - useTokenExtensions: true, - }); - - const instruction = await getSettleFundsInstructionAsync({ - market: marketAddress, - userAccount: sellerUserAccount, - baseVault, - quoteVault, - userBaseAccount: sellerBaseAccount, - userQuoteAccount: sellerQuoteAccount, - baseMint, - quoteMint, - owner: seller, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - - await connection.sendTransactionFromInstructions({ - feePayer: seller, - instructions: [instruction], - }); - - const sellerAccount = await fetchUserAccount(connection.rpc, sellerUserAccount); - assert.strictEqual(sellerAccount.data.unsettledBase, 0n); - assert.strictEqual(sellerAccount.data.unsettledQuote, 0n); - - const balanceAfter = await connection.getTokenAccountBalance({ - tokenAccount: sellerBaseAccount, - useTokenExtensions: true, - }); - assert.strictEqual( - balanceAfter.amount - balanceBefore.amount, - ASK_QUANTITY, - "Seller should have received the cancelled ask quantity of base tokens", - ); - }); - - test("buyer cancels their bid and then settles the refunded quote", async () => { - const bidOrderAddress = await deriveOrderAddress(connection, marketAddress, bidOrderId); - - const cancelInstruction = await getCancelOrderInstructionAsync({ - market: marketAddress, - orderBook: orderBookAddress, - order: bidOrderAddress, - userAccount: buyerUserAccount, - owner: buyer, - }); - await connection.sendTransactionFromInstructions({ - feePayer: buyer, - instructions: [cancelInstruction], - }); - - const buyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); - // Cancelling a bid credits back price * quantity in quote. - assert.strictEqual(buyerAccount.data.unsettledQuote, BID_PRICE * BID_QUANTITY); - assert.strictEqual(buyerAccount.data.unsettledBase, 0n); - - const buyerBaseAccount = await connection.getTokenAccountAddress(buyer.address, baseMint, true); - const buyerQuoteAccount = await connection.getTokenAccountAddress(buyer.address, quoteMint, true); - - const quoteBalanceBefore = await connection.getTokenAccountBalance({ - tokenAccount: buyerQuoteAccount, - useTokenExtensions: true, - }); - - const settleInstruction = await getSettleFundsInstructionAsync({ - market: marketAddress, - userAccount: buyerUserAccount, - baseVault, - quoteVault, - userBaseAccount: buyerBaseAccount, - userQuoteAccount: buyerQuoteAccount, - baseMint, - quoteMint, - owner: buyer, - tokenProgram: TOKEN_EXTENSIONS_PROGRAM, - }); - await connection.sendTransactionFromInstructions({ - feePayer: buyer, - instructions: [settleInstruction], - }); - - const quoteBalanceAfter = await connection.getTokenAccountBalance({ - tokenAccount: buyerQuoteAccount, - useTokenExtensions: true, - }); - assert.strictEqual( - quoteBalanceAfter.amount - quoteBalanceBefore.amount, - BID_PRICE * BID_QUANTITY, - "Buyer should have been refunded the full locked quote amount", - ); - - // Settling leaves the order book empty and the buyer with no open orders. - const finalBook = await fetchOrderBook(connection.rpc, orderBookAddress); - assert.strictEqual(finalBook.data.bids.length, 0); - assert.strictEqual(finalBook.data.asks.length, 0); - - const finalBuyerAccount = await fetchUserAccount(connection.rpc, buyerUserAccount); - assert.strictEqual(finalBuyerAccount.data.openOrders.length, 0); - }); -}); diff --git a/defi/clob/anchor/tsconfig.json b/defi/clob/anchor/tsconfig.json deleted file mode 100644 index cd5b3286c..000000000 --- a/defi/clob/anchor/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "esModuleInterop": true, - "skipLibCheck": true, - "strict": true - } -} From b2c8c2d658981efb9a754454dc1431f01b311522 Mon Sep 17 00:00:00 2001 From: Edward Date: Sat, 18 Apr 2026 20:59:54 +0000 Subject: [PATCH 3/5] feat(defi/clob): add price-time priority matching engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously place_order booked orders and escrowed funds but never crossed them — a CLOB with no matching is pointless. This commit completes the job: incoming orders walk the opposite side of the book using price-time priority, match at the resting (maker's) price, credit fills to unsettled_* balances, and route a configurable taker fee to a dedicated fee vault. Matching semantics ------------------ - A taker bid walks asks lowest-first; a taker ask walks bids highest-first. Fills stop when either the taker is exhausted or the next resting order's price fails the limit check. - Fills happen at the MAKER'S price (price improvement for the taker). The taker's locked-up-front quote that isn't spent is refunded to their unsettled_quote. - Time priority is implicit in the OrderBook's sorted Vecs: at the same price, the earliest insertion is at the lower index and fills first. - Any unmatched remainder rests on the book as a new maker order with the original limit price. Fee model --------- Single taker_fee (basis points) deducted from the gross quote of each fill and routed to a new market-owned fee_vault (one CPI per place_order, aggregated across fills). Makers never pay an explicit maker fee. See programs/clob/src/instructions/place_order.rs for the trade-offs vs a taker-funded (extra-transfer) model. New instruction --------------- withdraw_fees: authority-gated drain of the fee vault into the authority's quote token account. No-ops on an empty vault so it is safe to call on a schedule. Remaining accounts pattern -------------------------- Maker Order PDAs and their owners' UserAccount PDAs are passed as remaining_accounts in pairs, in book-walk order. The program re-verifies each pair against the live book and rejects mismatches. Tests ----- 13 existing LiteSVM tests untouched and still pass; 10 new tests cover: fully-crossing bid, fully-crossing ask, partial-fill of resting order, partial-fill of taker, multi-level crossing with price priority, time priority at a tie, price-improvement rebate, fee maths, withdraw_fees drain, and settle_funds after matching. --- README.md | 2 +- defi/clob/anchor/README.md | 52 +- defi/clob/anchor/programs/clob/src/errors.rs | 15 + .../src/instructions/initialize_market.rs | 12 + .../programs/clob/src/instructions/mod.rs | 2 + .../clob/src/instructions/place_order.rs | 393 ++++++- .../clob/src/instructions/withdraw_fees.rs | 77 ++ defi/clob/anchor/programs/clob/src/lib.rs | 24 +- .../anchor/programs/clob/src/state/market.rs | 7 + .../programs/clob/src/state/matching.rs | 96 ++ .../anchor/programs/clob/src/state/mod.rs | 2 + .../anchor/programs/clob/tests/test_clob.rs | 958 +++++++++++++++++- 12 files changed, 1575 insertions(+), 65 deletions(-) create mode 100644 defi/clob/anchor/programs/clob/src/instructions/withdraw_fees.rs create mode 100644 defi/clob/anchor/programs/clob/src/state/matching.rs diff --git a/README.md b/README.md index 65cea370b..064ad7b55 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Allow two users to swap digital assets with each other, each getting 100% of wha ### Central Limit Order Book -Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders can be cancelled and funds settled back. A minimal teaching example of the mechanics behind Openbook and Phoenix. +Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders cross against the opposing side using price-time priority. Fees route to a dedicated fee vault, maker/taker proceeds land in unsettled balances, and funds are withdrawn via `settle_funds`. A minimal teaching example of the mechanics behind Openbook and Phoenix. [anchor](./defi/clob/anchor) diff --git a/defi/clob/anchor/README.md b/defi/clob/anchor/README.md index 47f55cb50..6e7644dfd 100644 --- a/defi/clob/anchor/README.md +++ b/defi/clob/anchor/README.md @@ -1,29 +1,63 @@ # Anchor CLOB -A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price; their tokens sit in a program-owned vault until the order is cancelled. Cancellation credits the refund to an internal balance and a later `settle_funds` call moves those tokens back to the user. +A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price. Incoming orders cross against resting orders on the opposite side of the book using **price-time priority** — taker proceeds land in the user's `unsettled_*` balance and are withdrawn later via `settle_funds`. Unmatched remainders rest on the book as new maker orders. -This is a teaching example. It is deliberately small — the real CLOBs on Solana (Openbook, Phoenix) use zero-copy slab data structures and much more sophisticated matching and fee logic. +This is a teaching example. It is deliberately small — the real CLOBs on Solana (Openbook, Phoenix) use zero-copy slab data structures and much more sophisticated matching, cancellation, and fee logic. ## Concepts -- **Market** — one trading pair, e.g. `BASE/QUOTE`. Stored at a PDA seeded by the two mints. The market account is the signer of its two token vaults. -- **Order Book** — a PDA per market holding two `Vec`s: bids (sorted descending by price) and asks (sorted ascending). Price-time priority is implicit in the order they are inserted. +- **Market** — one trading pair, e.g. `BASE/QUOTE`. Stored at a PDA seeded by the two mints. The market account is the signer of its three token vaults (base, quote, fee). +- **Order Book** — a PDA per market holding two `Vec`s: bids (sorted descending by price) and asks (sorted ascending). Price-time priority is implicit in the Vec order: best price is index 0, and within a price level the earliest insertion is first. - **User Account** — one per user per market. Tracks the user's open order ids and two "unsettled" balances (base and quote) representing tokens the program owes the user but has not yet transferred back. - **Order** — a PDA per placed order, seeded by `(market, order_id)`. Stores price, original and filled quantity, status (`Open`, `PartiallyFilled`, `Filled`, `Cancelled`) and the owner. +- **Fee vault** — a separate token account (quote mint) that accumulates taker fees. The market PDA is its authority; only `withdraw_fees` can drain it, and only the market's stored `authority` may call that. ## Instructions | Name | What it does | |-----------------------|--------------| -| `initialize_market` | Create the market, order book and two token vaults for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. | +| `initialize_market` | Create the market, order book, base vault, quote vault, and fee vault for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. | | `create_user_account` | Initialise the caller's per-market user account. | -| `place_order` | Add a limit order to the book and lock the funds it would need if filled: bids lock `price × quantity` of quote; asks lock `quantity` of base. | +| `place_order` | Lock the required funds (bids lock `price × quantity` of quote; asks lock `quantity` of base), then cross against the opposing side of the book (price-time priority). Taker proceeds land in `unsettled_base`/`unsettled_quote`; any unmatched remainder rests on the book. Callers pass resting-order PDAs and their owners' `UserAccount` PDAs as `remaining_accounts`, in pairs, in book order. | | `cancel_order` | Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. | | `settle_funds` | Move all unsettled base and quote from the market's vaults back to the owner's token accounts. Signs with the market PDA. | +| `withdraw_fees` | Authority-only. Drains the fee vault into the authority's quote token account. Safe to call with an empty fee vault — it no-ops rather than reverting. | -### Scope note +### Matching semantics -The program stores the book and locks funds on placement, but does **not** currently run a matching engine inside `place_order`. Crossed orders (a bid at or above the best ask) will sit side-by-side in the book rather than trade. Adding matching requires passing the opposing orders (and their owners' user accounts and token accounts) as remaining accounts and clearing the filled amounts across both sides; it's a natural next extension. +`place_order` walks the opposite side of the book in price-time priority order: + +- A **taker bid** walks asks lowest-first. For each ask whose price `<=` the bid's limit, a fill occurs at the ask's (maker's) price, for `min(taker_remaining, maker_remaining)` quantity. Stops when the bid is filled or the next ask's price exceeds the bid's limit. +- A **taker ask** mirrors: walk bids highest-first, fill at the bid's price while the bid's price `>=` the ask's limit. +- **Price improvement** — a bid at 1000 crossing an ask at 900 fills at 900. The taker locked `1000 × qty` of quote up front; the `100 × qty` they didn't need is refunded to their `unsettled_quote`. +- **Time priority** — two orders at the same price fill in the order they were inserted. The oldest resting order wins. + +### Fee model + +A single `fee_basis_points` value (0–10_000) applies to the taker fee on the quote side of each fill: + +``` +gross = fill_price * fill_quantity +fee = gross * fee_basis_points / 10_000 # rounded down +``` + +- The fee is deducted from the gross quote flowing between the two traders, and transferred to the market's `fee_vault` via one CPI per `place_order` call (aggregated across fills to keep CU cost down). +- In this example the fee is effectively maker-funded (the maker receives `gross − fee`) rather than taker-funded (where the taker would bring extra quote to cover the fee on top of the gross). This keeps the instruction simple — no per-fill CPI from the taker's ATA — and matches how Openbook v2 and Phoenix tend to operate. If you need strictly maker-neutral fees, add a second `transfer_checked` from the taker's ATA to the `fee_vault` for each fill. +- Makers never pay an explicit maker fee in this example. + +### Remaining accounts + +`place_order`'s matching needs to mutate each resting maker's `Order` (to bump `filled_quantity` and flip `status`) and their `UserAccount` (to credit `unsettled_*` and drop filled orders from `open_orders`). Those accounts are passed as `remaining_accounts` in pairs: + +``` +remaining_accounts = [ + maker_1_order, maker_1_user_account, + maker_2_order, maker_2_user_account, + ... +] +``` + +Ordered the way the book will walk them: lowest-priced ask first for a taker bid, highest-priced bid first for a taker ask. The program re-verifies the pairs against the live order book (rejecting out-of-order or unknown order ids) before applying any fills. ## Build @@ -41,4 +75,4 @@ Tests are pure Rust, running against [LiteSVM](https://github.com/LiteSVM/litesv ## Credit -Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). +Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). Matching engine added in a subsequent pass. diff --git a/defi/clob/anchor/programs/clob/src/errors.rs b/defi/clob/anchor/programs/clob/src/errors.rs index 10526bf5a..f7bb9cd6d 100644 --- a/defi/clob/anchor/programs/clob/src/errors.rs +++ b/defi/clob/anchor/programs/clob/src/errors.rs @@ -37,4 +37,19 @@ pub enum ErrorCode { #[msg("Fee basis points out of range")] InvalidFeeBasisPoints, + + #[msg("Fee vault does not match the market's fee vault")] + InvalidFeeVault, + + #[msg("Maker account provided does not correspond to a resting order on the book")] + MakerAccountMismatch, + + #[msg("Not enough maker accounts supplied to cross the incoming order")] + MissingMakerAccounts, + + #[msg("Maker order and maker user account owner mismatch")] + MakerOwnerMismatch, + + #[msg("Only the market authority can withdraw fees")] + NotMarketAuthority, } diff --git a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs index 54fc1c101..f4cfaf9f2 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs @@ -27,6 +27,7 @@ pub fn handle_initialize_market( market.quote_mint = context.accounts.quote_mint.key(); market.base_vault = context.accounts.base_vault.key(); market.quote_vault = context.accounts.quote_vault.key(); + market.fee_vault = context.accounts.fee_vault.key(); market.order_book = context.accounts.order_book.key(); market.fee_basis_points = fee_basis_points; market.tick_size = tick_size; @@ -87,6 +88,17 @@ pub struct InitializeMarket<'info> { )] pub quote_vault: InterfaceAccount<'info, TokenAccount>, + // Taker fees accumulate here (quote mint). Separate from quote_vault so + // maker-owed balances and market-earned fees can't be confused. + #[account( + init, + payer = authority, + token::mint = quote_mint, + token::authority = market, + token::token_program = token_program + )] + pub fee_vault: InterfaceAccount<'info, TokenAccount>, + #[account(mut)] pub authority: Signer<'info>, diff --git a/defi/clob/anchor/programs/clob/src/instructions/mod.rs b/defi/clob/anchor/programs/clob/src/instructions/mod.rs index 4ac470849..19fb61b5a 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/mod.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/mod.rs @@ -3,9 +3,11 @@ pub mod create_user_account; pub mod initialize_market; pub mod place_order; pub mod settle_funds; +pub mod withdraw_fees; pub use cancel_order::*; pub use create_user_account::*; pub use initialize_market::*; pub use place_order::*; pub use settle_funds::*; +pub use withdraw_fees::*; diff --git a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs index a8ef25d8d..950d1286a 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs @@ -5,16 +5,28 @@ use anchor_spl::token_interface::{ use crate::errors::ErrorCode; use crate::state::{ - add_ask, add_bid, add_open_order, Market, Order, OrderBook, OrderSide, OrderStatus, - UserAccount, MAX_ORDERS_PER_SIDE, ORDER_BOOK_SEED, ORDER_SEED, USER_ACCOUNT_SEED, + add_ask, add_bid, add_open_order, plan_fills, remove_open_order, Market, Order, OrderBook, + OrderSide, OrderStatus, UserAccount, MARKET_SEED, MAX_ORDERS_PER_SIDE, ORDER_BOOK_SEED, + ORDER_SEED, USER_ACCOUNT_SEED, }; // Mirror of UserAccount.open_orders max_len. Kept as a constant so the // PlaceOrder check reads clearly and the limit is documented in one place. const MAX_OPEN_ORDERS_PER_USER: usize = 20; -pub fn handle_place_order( - context: Context, +// Basis-points denominator. 10_000 bps == 100% — standard in TradFi and CEXes. +const BASIS_POINTS_DENOMINATOR: u128 = 10_000; + +// Remaining accounts are passed in groups of 2 per resting order we intend +// to cross: [maker_order, maker_user_account]. We keep it at 2 (instead of +// also threading the maker's ATAs) because fills land in the maker's +// unsettled_* balance — the maker drains them later via settle_funds. This +// mirrors how Openbook v2 works and keeps the per-fill account footprint +// small. +const ACCOUNTS_PER_MAKER: usize = 2; + +pub fn handle_place_order<'info>( + context: Context<'info, PlaceOrder<'info>>, side: OrderSide, price: u64, quantity: u64, @@ -29,36 +41,15 @@ pub fn handle_place_order( ErrorCode::BelowMinOrderSize ); - let order_book = &mut context.accounts.order_book; - require!( - order_book.bids.len() + order_book.asks.len() < MAX_ORDERS_PER_SIDE * 2, - ErrorCode::OrderBookFull - ); - - let user_account = &mut context.accounts.user_account; require!( - user_account.open_orders.len() < MAX_OPEN_ORDERS_PER_USER, + context.accounts.user_account.open_orders.len() < MAX_OPEN_ORDERS_PER_USER, ErrorCode::TooManyOpenOrders ); - let order_id = order_book.next_order_id; - order_book.next_order_id = order_book.next_order_id.saturating_add(1); - - let order = &mut context.accounts.order; - order.market = market.key(); - order.owner = context.accounts.owner.key(); - order.order_id = order_id; - order.side = side; - order.price = price; - order.original_quantity = quantity; - order.filled_quantity = 0; - order.status = OrderStatus::Open; - order.timestamp = Clock::get()?.unix_timestamp; - order.bump = context.bumps.order; - // Lock up the funds the order would need if filled. Bids lock quote - // (price * quantity); asks lock base (quantity). Funds sit in the vault - // until the order is cancelled (returned to unsettled) or settled. + // (price * quantity); asks lock base (quantity). This always happens — + // matching consumes from the locked pot (already in the vault), and any + // unmatched remainder rests as a maker order with its lock still in place. let (source_account, mint_account_info, decimals, transfer_amount, destination_vault) = match side { OrderSide::Bid => ( @@ -93,12 +84,336 @@ pub fn handle_place_order( decimals, )?; - match side { - OrderSide::Bid => add_bid(order_book, order_id, price, context.accounts.owner.key()), - OrderSide::Ask => add_ask(order_book, order_id, price, context.accounts.owner.key()), + // --------------------------------------------------------------- + // Matching + // --------------------------------------------------------------- + // + // Caller passes, in the transaction's remaining_accounts slot, pairs of + // (maker_order, maker_user_account) in the same order the taker expects + // to cross them (best-priced first, then time priority at a tie). We + // deserialise them up front so we can both plan fills (quantities come + // from the live Order accounts) and mutate them below. + let maker_accounts = &context.remaining_accounts; + require!( + maker_accounts.len() % ACCOUNTS_PER_MAKER == 0, + ErrorCode::MissingMakerAccounts + ); + let maker_pair_count = maker_accounts.len() / ACCOUNTS_PER_MAKER; + + // Parallel Vec of resting-side quantities-remaining, one per book entry, + // so the matching planner can decide fill sizes without touching the + // Order accounts itself. Defaults to 0 for entries the caller didn't + // pass in — those get skipped by the planner (they would also have been + // unreachable given price-time priority ordering). + let order_book = &mut context.accounts.order_book; + // Note: we don't reject yet if the book is "full" (bids + asks == + // 2 * MAX_ORDERS_PER_SIDE). A taker that fully crosses removes resting + // orders before needing to add its own, so it's legitimate even on a + // full book. We re-check below, *after* matching, right before adding + // any remainder to the book. + + let resting_entries = match side { + OrderSide::Bid => &order_book.asks, + OrderSide::Ask => &order_book.bids, + }; + + let mut resting_quantities: Vec = vec![0u64; resting_entries.len()]; + // Track which maker_pair index (if any) each resting book slot maps to. + // We only need this for slots the caller actually supplied — entries + // beyond the supplied set can't be crossed in this transaction. + for maker_pair_index in 0..maker_pair_count { + let maker_order_info = &maker_accounts[maker_pair_index * ACCOUNTS_PER_MAKER]; + let maker_order = Account::::try_from(maker_order_info)?; + + // Find the corresponding book entry. The caller is expected to pass + // makers in price-time priority order, but we don't trust that — + // we look up by order_id and reject mismatches. + let book_position = resting_entries + .iter() + .position(|entry| entry.order_id == maker_order.order_id) + .ok_or(ErrorCode::MakerAccountMismatch)?; + + require!(maker_order.market == market.key(), ErrorCode::MakerAccountMismatch); + resting_quantities[book_position] = + maker_order.original_quantity.saturating_sub(maker_order.filled_quantity); + + // We also want the maker_pair_index to line up with the book slot + // so after planning we can fetch the right (order, user_account) + // pair. But the planner walks book slots in order, not maker_pair + // order. To keep it simple, we require the caller to pass makers + // starting at book slot 0 and going in book order — which is the + // natural way to walk anyway. Enforce that here: + require!( + book_position == maker_pair_index, + ErrorCode::MakerAccountMismatch + ); } - add_open_order(user_account, order_id); + let (fills, taker_remaining) = plan_fills( + order_book, + &resting_quantities, + side, + price, + quantity, + ); + + // Accumulate taker's accounting deltas so we only touch the taker's + // UserAccount once. base_received counts base tokens the taker gains; + // quote_rebate is quote the taker locked but doesn't need to spend + // (price improvement between their limit and the resting maker's + // price), refunded to unsettled_quote for bids. + let taker_user_account = &mut context.accounts.user_account; + let mut taker_base_received: u64 = 0; + let mut taker_quote_rebate: u64 = 0; + let mut taker_quote_received: u64 = 0; + // Total fee to move from vault (quote side) to fee_vault. Aggregating + // into one CPI at the end halves the CU cost vs one CPI per fill. + let mut total_fee_quote: u64 = 0; + + for fill in &fills { + // Maker-side mutations: find the maker_pair for this fill. + let maker_pair_index = fill.resting_index; + let maker_order_info = &maker_accounts[maker_pair_index * ACCOUNTS_PER_MAKER]; + let maker_user_info = &maker_accounts[maker_pair_index * ACCOUNTS_PER_MAKER + 1]; + + let mut maker_order = Account::::try_from(maker_order_info)?; + let mut maker_user_account = Account::::try_from(maker_user_info)?; + + require!( + maker_order.owner == maker_user_account.owner, + ErrorCode::MakerOwnerMismatch + ); + require!( + maker_user_account.market == market.key(), + ErrorCode::MakerAccountMismatch + ); + + // Fee model (simple, maker-funded, no extra taker deposit): + // + // gross = fill_price * fill_quantity (quote tokens per fill) + // fee = gross * fee_bps / 10_000 (rounded down) + // maker gets gross - fee, + // fee_vault gets fee, + // taker pays 'gross' net (out of their pre-locked quote). + // + // Strictly "makers pay nothing" would require the taker to bring + // (gross + fee) which means pulling more from the taker's ATA on + // every fill — a per-fill CPI that inflates CU cost and account + // lists. Real CLOBs (Openbook v2, Phoenix) use a similar deduct- + // from-gross pattern for simplicity; the fee can be thought of as + // the maker pricing their ask a fraction higher to cover it. Swap + // to a taker-funded model by adding a second transfer_checked from + // the taker's ATA to fee_vault if you need strict maker-neutral + // fees. + let gross_quote: u64 = fill + .fill_price + .checked_mul(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + + let fee_quote: u64 = (gross_quote as u128) + .checked_mul(market.fee_basis_points as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; + + match side { + // Taker Bid, resting Ask. Taker pays quote, gets base. + OrderSide::Bid => { + // Net quote flowing to the maker after the protocol fee + // (see fee-model comment above). + let net_quote_to_maker = gross_quote + .checked_sub(fee_quote) + .ok_or(ErrorCode::NumericalOverflow)?; + maker_user_account.unsettled_quote = maker_user_account + .unsettled_quote + .checked_add(net_quote_to_maker) + .ok_or(ErrorCode::NumericalOverflow)?; + + taker_base_received = taker_base_received + .checked_add(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + + // Price improvement: taker locked (price * quantity) but + // only needs (fill_price * fill_quantity) for this fill. + // Refund the difference to the taker's unsettled_quote. + let locked_for_this_fill: u64 = price + .checked_mul(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + let rebate: u64 = locked_for_this_fill + .checked_sub(gross_quote) + .ok_or(ErrorCode::NumericalOverflow)?; + taker_quote_rebate = taker_quote_rebate + .checked_add(rebate) + .ok_or(ErrorCode::NumericalOverflow)?; + } + // Taker Ask, resting Bid. Taker gives base, gets quote. + OrderSide::Ask => { + // Maker (resting bidder) receives base. + maker_user_account.unsettled_base = maker_user_account + .unsettled_base + .checked_add(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + + // Maker bid locked (bid_price * bid_qty) of quote up front + // — so fill_price * fill_quantity of that locked pool now + // flows to the taker as quote received, minus the taker + // fee. No rebate to the maker (they locked exactly what + // they're spending). + let net_quote_to_taker = gross_quote + .checked_sub(fee_quote) + .ok_or(ErrorCode::NumericalOverflow)?; + taker_quote_received = taker_quote_received + .checked_add(net_quote_to_taker) + .ok_or(ErrorCode::NumericalOverflow)?; + } + } + + total_fee_quote = total_fee_quote + .checked_add(fee_quote) + .ok_or(ErrorCode::NumericalOverflow)?; + + // Update the maker Order: bump filled_quantity, flip status. + maker_order.filled_quantity = maker_order + .filled_quantity + .checked_add(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + + let maker_fully_filled = + maker_order.filled_quantity >= maker_order.original_quantity; + maker_order.status = if maker_fully_filled { + OrderStatus::Filled + } else { + OrderStatus::PartiallyFilled + }; + + // If the resting order is fully filled, drop it from the maker's + // open_orders. Book removal happens in a second pass below (we + // collect the order_ids now and remove them in reverse order so + // indexes stay valid). + if maker_fully_filled { + remove_open_order(&mut maker_user_account, maker_order.order_id); + } + + // Persist the mutations back to the account datas — exit() runs + // Anchor's realloc/serialise + discriminator check path. + maker_order.exit(context.program_id)?; + maker_user_account.exit(context.program_id)?; + } + + // Remove fully-filled resting orders from the book. Descend so indexes + // don't shift under us. + let mut fully_filled_indexes: Vec = fills + .iter() + .filter(|fill| { + // We know the maker's quantity from resting_quantities; if the + // fill equals that, they're fully filled. + resting_quantities[fill.resting_index] == fill.fill_quantity + }) + .map(|fill| fill.resting_index) + .collect(); + fully_filled_indexes.sort_unstable(); + fully_filled_indexes.dedup(); + + let resting_mut: &mut Vec = match side { + OrderSide::Bid => &mut order_book.asks, + OrderSide::Ask => &mut order_book.bids, + }; + for index in fully_filled_indexes.iter().rev() { + resting_mut.remove(*index); + } + + // Move accumulated fee from quote_vault → fee_vault (one CPI signed + // by the market PDA). + if total_fee_quote > 0 { + let market_bump = [market.bump]; + let signer_seeds: [&[u8]; 4] = [ + MARKET_SEED, + market.base_mint.as_ref(), + market.quote_mint.as_ref(), + &market_bump, + ]; + let signer_seeds = &[&signer_seeds[..]]; + + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.quote_vault.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.fee_vault.to_account_info(), + authority: market.to_account_info(), + }, + signer_seeds, + ), + total_fee_quote, + context.accounts.quote_mint.decimals, + )?; + } + + // Apply taker accounting deltas in a single mutation. + taker_user_account.unsettled_base = taker_user_account + .unsettled_base + .checked_add(taker_base_received) + .ok_or(ErrorCode::NumericalOverflow)?; + taker_user_account.unsettled_quote = taker_user_account + .unsettled_quote + .checked_add(taker_quote_rebate) + .ok_or(ErrorCode::NumericalOverflow)? + .checked_add(taker_quote_received) + .ok_or(ErrorCode::NumericalOverflow)?; + + // --------------------------------------------------------------- + // Any remainder becomes a maker order — init the Order PDA, add to + // the book, add to the owner's open_orders. + // + // If the taker was fully matched we still init the Order account + // (Anchor already allocated rent for it in this instruction) but mark + // it Filled immediately. That keeps the account valid for downstream + // indexers that may have read the next_order_id before the transaction. + // --------------------------------------------------------------- + let order_id = order_book.next_order_id; + order_book.next_order_id = order_book.next_order_id.saturating_add(1); + + let order = &mut context.accounts.order; + order.market = market.key(); + order.owner = context.accounts.owner.key(); + order.order_id = order_id; + order.side = side; + order.price = price; + order.original_quantity = quantity; + order.filled_quantity = quantity.saturating_sub(taker_remaining); + order.timestamp = Clock::get()?.unix_timestamp; + order.bump = context.bumps.order; + + if taker_remaining == 0 { + // Taker fully matched — nothing rests on the book. + order.status = OrderStatus::Filled; + } else { + // Check book capacity only now — a taker that removed resting + // orders above may have freed space even on a previously-full + // book. + require!( + order_book.bids.len() + order_book.asks.len() < MAX_ORDERS_PER_SIDE * 2, + ErrorCode::OrderBookFull + ); + + order.status = if taker_remaining < quantity { + OrderStatus::PartiallyFilled + } else { + OrderStatus::Open + }; + match side { + OrderSide::Bid => { + add_bid(order_book, order_id, price, context.accounts.owner.key()) + } + OrderSide::Ask => { + add_ask(order_book, order_id, price, context.accounts.owner.key()) + } + } + add_open_order(taker_user_account, order_id); + } Ok(()) } @@ -106,7 +421,10 @@ pub fn handle_place_order( #[derive(Accounts)] #[instruction(side: OrderSide, price: u64, quantity: u64)] pub struct PlaceOrder<'info> { - #[account(mut)] + #[account( + mut, + has_one = fee_vault @ ErrorCode::InvalidFeeVault, + )] pub market: Account<'info, Market>, #[account( @@ -136,7 +454,7 @@ pub struct PlaceOrder<'info> { )] pub user_account: Account<'info, UserAccount>, - // InterfaceAccount on the stack is ~1 KB each; with 6 of them this struct + // InterfaceAccount on the stack is ~1 KB each; with 7 of them this struct // blows the 4 KB stack-offset limit on BPF. Boxing moves each to the heap. #[account(mut)] pub base_vault: Box>, @@ -144,6 +462,11 @@ pub struct PlaceOrder<'info> { #[account(mut)] pub quote_vault: Box>, + // Taker fees are routed here. Constrained via `has_one = fee_vault` on + // `market` above so the program can trust it without re-checking. + #[account(mut)] + pub fee_vault: Box>, + #[account(mut)] pub user_base_account: Box>, diff --git a/defi/clob/anchor/programs/clob/src/instructions/withdraw_fees.rs b/defi/clob/anchor/programs/clob/src/instructions/withdraw_fees.rs new file mode 100644 index 000000000..2b44463fc --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/withdraw_fees.rs @@ -0,0 +1,77 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::ErrorCode; +use crate::state::{Market, MARKET_SEED}; + +/// Drain the market's accumulated taker fees into the authority's token +/// account. Authority-only — arbitrary callers must not be able to siphon +/// the fee vault. Transfers the current balance of the fee vault in full; +/// a partial-withdraw flavour could take an amount parameter, left out here +/// to keep the example focused. +pub fn handle_withdraw_fees(context: Context) -> Result<()> { + let market = &context.accounts.market; + + require!( + context.accounts.authority.key() == market.authority, + ErrorCode::NotMarketAuthority + ); + + let fee_balance = context.accounts.fee_vault.amount; + if fee_balance == 0 { + // Nothing to do — exit quietly rather than failing, so this + // instruction is safe to call on a cron/heartbeat even when there + // haven't been any fills since the last run. + return Ok(()); + } + + let market_bump = [market.bump]; + let signer_seeds: [&[u8]; 4] = [ + MARKET_SEED, + market.base_mint.as_ref(), + market.quote_mint.as_ref(), + &market_bump, + ]; + let signer_seeds = &[&signer_seeds[..]]; + + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + TransferChecked { + from: context.accounts.fee_vault.to_account_info(), + mint: context.accounts.quote_mint.to_account_info(), + to: context.accounts.authority_quote_account.to_account_info(), + authority: market.to_account_info(), + }, + signer_seeds, + ), + fee_balance, + context.accounts.quote_mint.decimals, + )?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawFees<'info> { + #[account( + mut, + has_one = fee_vault @ ErrorCode::InvalidFeeVault, + )] + pub market: Account<'info, Market>, + + // Boxed to keep the struct under the BPF stack limit (see PlaceOrder). + #[account(mut)] + pub fee_vault: Box>, + + #[account(mut)] + pub authority_quote_account: Box>, + + pub quote_mint: Box>, + + pub authority: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/defi/clob/anchor/programs/clob/src/lib.rs b/defi/clob/anchor/programs/clob/src/lib.rs index 5f10bbb91..8749c1d9d 100644 --- a/defi/clob/anchor/programs/clob/src/lib.rs +++ b/defi/clob/anchor/programs/clob/src/lib.rs @@ -35,11 +35,19 @@ pub mod clob { instructions::create_user_account::handle_create_user_account(context) } - /// Place a bid or ask. Locks the required funds (quote for bids, base for - /// asks) into the market vault and inserts the order into the book at the - /// correct price-time-priority position. - pub fn place_order( - context: Context, + /// Place a bid or ask. Locks the required funds (quote for bids, base + /// for asks) into the market vault, crosses against the opposing side + /// of the book using price-time priority (best price first, earliest + /// timestamp at a tie), credits fills to maker/taker `unsettled_*` + /// balances, routes the taker fee to the fee vault, and rests any + /// unmatched remainder on the book at the caller's limit price. + /// + /// Callers supply resting orders to cross against as + /// `remaining_accounts`, in pairs of + /// `(maker_order_pda, maker_user_account_pda)`, ordered by the + /// book's price-time priority (i.e. best ask first for a taker bid). + pub fn place_order<'info>( + context: Context<'info, PlaceOrder<'info>>, side: state::OrderSide, price: u64, quantity: u64, @@ -59,4 +67,10 @@ pub mod clob { pub fn settle_funds(context: Context) -> Result<()> { instructions::settle_funds::handle_settle_funds(context) } + + /// Drain the fee vault into the market authority's token account. + /// Authority-gated — only the market's stored `authority` may call this. + pub fn withdraw_fees(context: Context) -> Result<()> { + instructions::withdraw_fees::handle_withdraw_fees(context) + } } diff --git a/defi/clob/anchor/programs/clob/src/state/market.rs b/defi/clob/anchor/programs/clob/src/state/market.rs index 30c82e158..eb9576ba1 100644 --- a/defi/clob/anchor/programs/clob/src/state/market.rs +++ b/defi/clob/anchor/programs/clob/src/state/market.rs @@ -18,6 +18,13 @@ pub struct Market { pub quote_vault: Pubkey, + // Dedicated token account (quote mint) that accumulates taker fees. + // Kept separate from `quote_vault` so user-owed balances and + // market-earned fees cannot be confused. The market PDA signs transfers + // out of it, so only program instructions (notably `withdraw_fees`) can + // drain it. + pub fee_vault: Pubkey, + pub order_book: Pubkey, pub fee_basis_points: u16, diff --git a/defi/clob/anchor/programs/clob/src/state/matching.rs b/defi/clob/anchor/programs/clob/src/state/matching.rs new file mode 100644 index 000000000..623dbb102 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/matching.rs @@ -0,0 +1,96 @@ +//! Matching engine helpers. Pure logic (no CPIs) that decides which resting +//! orders an incoming taker order would cross. The caller (place_order) +//! turns these decisions into token movements and account mutations. +//! +//! Price-time priority is implicit in the OrderBook's sorted Vecs: +//! - asks are sorted ascending (best ask first) +//! - bids are sorted descending (best bid first) +//! so "walk from index 0" is the correct price priority; and within a price +//! level, insertion order gives time priority (first-in fills first). + +use crate::state::{OrderBook, OrderEntry, OrderSide}; + +/// One matched fill between the incoming taker order and a resting maker +/// order. `taker_remaining_after` is the taker's quantity after this fill +/// has been applied; matching stops when it reaches 0. +pub struct Fill { + /// Index into `order_book.asks` (taker Bid) or `order_book.bids` + /// (taker Ask) that this fill applies to. Kept so place_order can look + /// the same entry up again to update its `quantity` on the book. + pub resting_index: usize, + + /// order_id of the resting order being hit. place_order uses this to + /// sanity-check the maker Order account passed as a remaining account. + pub resting_order_id: u64, + + /// Quantity filled (in base tokens). + pub fill_quantity: u64, + + /// Price at which the fill occurs (always the resting order's price — + /// standard CLOB: maker's posted price wins; taker may get price + /// improvement vs their limit). + pub fill_price: u64, +} + +/// Walk the opposite side of the book and produce the list of fills that +/// should occur for the incoming taker order. Does not mutate the book; +/// place_order applies the results. `resting_quantities` is indexed in +/// parallel with the resting side's Vec and gives each entry's current +/// quantity-remaining (which place_order tracks externally because the +/// book entry itself is just {order_id, price, owner} today — we pass it +/// in rather than storing it on OrderEntry so the existing on-chain layout +/// doesn't change). +/// +/// Returns (fills, taker_remaining). taker_remaining is what's left over +/// that should rest on the book at the taker's limit price. +pub fn plan_fills( + order_book: &OrderBook, + resting_quantities: &[u64], + incoming_side: OrderSide, + incoming_price: u64, + incoming_quantity: u64, +) -> (Vec, u64) { + let resting_entries: &Vec = match incoming_side { + OrderSide::Bid => &order_book.asks, + OrderSide::Ask => &order_book.bids, + }; + + let mut fills: Vec = Vec::new(); + let mut taker_remaining = incoming_quantity; + + for (index, resting) in resting_entries.iter().enumerate() { + if taker_remaining == 0 { + break; + } + + // Crossing condition: bid matches if incoming >= resting; ask + // matches if incoming <= resting. + let crosses = match incoming_side { + OrderSide::Bid => incoming_price >= resting.price, + OrderSide::Ask => incoming_price <= resting.price, + }; + if !crosses { + // Sides are sorted by price-priority, so once we fail to cross + // we'll fail to cross every subsequent entry too. + break; + } + + let resting_remaining = resting_quantities[index]; + if resting_remaining == 0 { + // Defensive: shouldn't happen because fully-filled orders are + // removed from the book, but skip rather than crash. + continue; + } + + let fill_quantity = taker_remaining.min(resting_remaining); + fills.push(Fill { + resting_index: index, + resting_order_id: resting.order_id, + fill_quantity, + fill_price: resting.price, + }); + taker_remaining = taker_remaining.saturating_sub(fill_quantity); + } + + (fills, taker_remaining) +} diff --git a/defi/clob/anchor/programs/clob/src/state/mod.rs b/defi/clob/anchor/programs/clob/src/state/mod.rs index 59f622d5b..c7a50b46d 100644 --- a/defi/clob/anchor/programs/clob/src/state/mod.rs +++ b/defi/clob/anchor/programs/clob/src/state/mod.rs @@ -1,9 +1,11 @@ pub mod market; +pub mod matching; pub mod order; pub mod order_book; pub mod user_account; pub use market::*; +pub use matching::*; pub use order::*; pub use order_book::*; pub use user_account::*; diff --git a/defi/clob/anchor/programs/clob/tests/test_clob.rs b/defi/clob/anchor/programs/clob/tests/test_clob.rs index e1d58e54c..b91cfdafa 100644 --- a/defi/clob/anchor/programs/clob/tests/test_clob.rs +++ b/defi/clob/anchor/programs/clob/tests/test_clob.rs @@ -3,17 +3,19 @@ //! Covers the full lifecycle that the program supports: initialise a market, //! create user accounts, place bids/asks (locking the appropriate vault), //! reject invalid prices / tick-aligned prices / undersized quantities, -//! cancel orders (which credits unsettled balances), enforce cancel -//! authorisation, and settle funds out of the vaults. -//! -//! Note: this example's `place_order` does NOT cross the book. It is a -//! "book keeper" example — the matching engine is intentionally left out to -//! keep the example scoped to CLOB data structures, vault escrow, and the -//! unsettled-balance pattern. Tests therefore do not exercise crossing. +//! cancel orders (which credits unsettled balances), settle funds out of +//! the vaults, and — in the matching block near the bottom — cross incoming +//! orders against resting orders using price-time priority, charge the +//! configured taker fee to a fee vault, and drain the fee vault via +//! `withdraw_fees`. use { anchor_lang::{ - solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + system_program, + }, InstructionData, ToAccountMetas, }, litesvm::LiteSVM, @@ -110,6 +112,9 @@ struct Scenario { quote_mint: Pubkey, base_vault: Keypair, quote_vault: Keypair, + // Fees accumulate here (quote mint). Created fresh per Scenario; the + // market PDA is the signer, same as the other two vaults. + fee_vault: Keypair, market: Pubkey, order_book: Pubkey, buyer_base_ata: Pubkey, @@ -172,6 +177,7 @@ fn full_setup() -> Scenario { // (not PDAs). Tests generate fresh keypairs to serve as their addresses. let base_vault = Keypair::new(); let quote_vault = Keypair::new(); + let fee_vault = Keypair::new(); Scenario { svm, @@ -184,6 +190,7 @@ fn full_setup() -> Scenario { quote_mint, base_vault, quote_vault, + fee_vault, market, order_book, buyer_base_ata, @@ -220,6 +227,7 @@ fn build_initialize_market_ix( quote_mint: sc.quote_mint, base_vault: sc.base_vault.pubkey(), quote_vault: sc.quote_vault.pubkey(), + fee_vault: sc.fee_vault.pubkey(), authority: sc.authority.pubkey(), token_program: token_program_id(), system_program: system_program::id(), @@ -271,6 +279,7 @@ fn build_place_order_ix( user_account, base_vault: sc.base_vault.pubkey(), quote_vault: sc.quote_vault.pubkey(), + fee_vault: sc.fee_vault.pubkey(), user_base_account, user_quote_account, base_mint: sc.base_mint, @@ -283,6 +292,68 @@ fn build_place_order_ix( ) } +/// Build a `place_order` instruction with maker (order, user_account) PDA +/// pairs appended as remaining accounts. The CLOB expects them in the same +/// order the resting book will be walked — best-priced first (lowest ask +/// for a taker bid, highest bid for a taker ask), and within a price level +/// earliest-first. Every maker pair must be writable: the program mutates +/// the maker's Order (filled_quantity, status) and their UserAccount +/// (unsettled_* and open_orders). +#[allow(clippy::too_many_arguments)] +fn build_place_order_with_makers_ix( + sc: &Scenario, + owner: &Keypair, + user_account: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, + side: clob::state::OrderSide, + order_id: u64, + price: u64, + quantity: u64, + maker_pairs: &[(u64, Pubkey)], +) -> Instruction { + let mut ix = build_place_order_ix( + sc, + owner, + user_account, + user_base_account, + user_quote_account, + side, + order_id, + price, + quantity, + ); + + for (maker_order_id, maker_user_account) in maker_pairs { + let maker_order = order_pda(&sc.program_id, &sc.market, *maker_order_id); + ix.accounts + .push(AccountMeta::new(maker_order, false)); + ix.accounts + .push(AccountMeta::new(*maker_user_account, false)); + } + + ix +} + +fn build_withdraw_fees_ix( + sc: &Scenario, + authority_quote_account: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &clob::instruction::WithdrawFees {}.data(), + clob::accounts::WithdrawFees { + market: sc.market, + fee_vault: sc.fee_vault.pubkey(), + authority_quote_account, + quote_mint: sc.quote_mint, + authority: sc.authority.pubkey(), + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + fn build_cancel_order_ix( sc: &Scenario, owner: &Pubkey, @@ -338,7 +409,7 @@ fn initialize_market_and_users(sc: &mut Scenario) { send_transaction_from_instructions( &mut sc.svm, vec![init_ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ) .unwrap(); @@ -374,7 +445,7 @@ fn initialize_market_sets_market_and_order_book() { send_transaction_from_instructions( &mut sc.svm, vec![ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ) .unwrap(); @@ -410,7 +481,7 @@ fn create_user_account_tracks_market_and_owner() { send_transaction_from_instructions( &mut sc.svm, vec![init_ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ) .unwrap(); @@ -551,7 +622,7 @@ fn place_order_rejects_unaligned_tick() { send_transaction_from_instructions( &mut sc.svm, vec![init_ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ) .unwrap(); @@ -601,7 +672,7 @@ fn place_order_rejects_below_min_order_size() { send_transaction_from_instructions( &mut sc.svm, vec![init_ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ) .unwrap(); @@ -854,7 +925,7 @@ fn initialize_market_rejects_zero_tick_size() { let result = send_transaction_from_instructions( &mut sc.svm, vec![ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ); assert!(result.is_err(), "tick_size == 0 must be rejected"); @@ -875,7 +946,7 @@ fn initialize_market_rejects_oversized_fee() { let result = send_transaction_from_instructions( &mut sc.svm, vec![ix], - &[&sc.authority, &sc.base_vault, &sc.quote_vault], + &[&sc.authority, &sc.base_vault, &sc.quote_vault, &sc.fee_vault], &sc.authority.pubkey(), ); assert!( @@ -883,3 +954,860 @@ fn initialize_market_rejects_oversized_fee() { "fee_basis_points above 10_000 must be rejected" ); } + +// --------------------------------------------------------------------------- +// Matching-engine tests +// +// These exercise the price-time priority crossing logic added to place_order. +// Constants are named per-test (rather than shared at the top of the file) +// so each test reads self-contained and the maths is easy to follow. +// --------------------------------------------------------------------------- + +// UserAccount field offsets after the 8-byte Anchor discriminator. Layout +// (see programs/clob/src/state/user_account.rs): +// market: Pubkey (32) +// owner: Pubkey (32) +// unsettled_base: u64 (8) +// unsettled_quote: u64 (8) +// ... +// Borsh-decoding manually (rather than pulling UserAccount via try_from) +// keeps the tests readable and side-steps rent-checked deserialise paths. +const USER_ACCOUNT_UNSETTLED_BASE_OFFSET: usize = 8 + 32 + 32; +const USER_ACCOUNT_UNSETTLED_QUOTE_OFFSET: usize = USER_ACCOUNT_UNSETTLED_BASE_OFFSET + 8; + +// Order layout after 8-byte discriminator (see state/order.rs): +// market: Pubkey (32) +// owner: Pubkey (32) +// order_id: u64 (8) +// side: u8 (Borsh-encoded enum tag) (1) +// price: u64 (8) +// original_quantity: u64 (8) +// filled_quantity: u64 (8) +const ORDER_FILLED_QUANTITY_OFFSET: usize = 8 + 32 + 32 + 8 + 1 + 8 + 8; +const ORDER_STATUS_OFFSET: usize = ORDER_FILLED_QUANTITY_OFFSET + 8; +const ORDER_STATUS_OPEN: u8 = 0; +const ORDER_STATUS_PARTIALLY_FILLED: u8 = 1; +const ORDER_STATUS_FILLED: u8 = 2; + +fn read_user_unsettled(svm: &LiteSVM, user_account: &Pubkey) -> (u64, u64) { + let data = svm + .get_account(user_account) + .expect("user account missing") + .data + .clone(); + let base = u64::from_le_bytes( + data[USER_ACCOUNT_UNSETTLED_BASE_OFFSET..USER_ACCOUNT_UNSETTLED_BASE_OFFSET + 8] + .try_into() + .unwrap(), + ); + let quote = u64::from_le_bytes( + data[USER_ACCOUNT_UNSETTLED_QUOTE_OFFSET..USER_ACCOUNT_UNSETTLED_QUOTE_OFFSET + 8] + .try_into() + .unwrap(), + ); + (base, quote) +} + +fn read_order_fill_and_status(svm: &LiteSVM, order: &Pubkey) -> (u64, u8) { + let data = svm + .get_account(order) + .expect("order account missing") + .data + .clone(); + let filled = u64::from_le_bytes( + data[ORDER_FILLED_QUANTITY_OFFSET..ORDER_FILLED_QUANTITY_OFFSET + 8] + .try_into() + .unwrap(), + ); + let status = data[ORDER_STATUS_OFFSET]; + (filled, status) +} + +#[test] +fn taker_bid_fully_crosses_best_ask() { + // Seller rests an ask, buyer's bid fully eats it. Check base flows to + // buyer's unsettled_base, quote net-of-fee flows to seller's + // unsettled_quote, and fee_vault receives the expected bps. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + // 1000 * 100 = 100_000 quote flows, and 100_000 * 10 bps / 10_000 = 100 + // fee — big enough to be non-zero after integer division, tiny enough + // that trader starting balances easily cover it. + const PRICE: u64 = 1000; + const QUANTITY: u64 = 100; + const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY; + const EXPECTED_FEE: u64 = EXPECTED_GROSS_QUOTE * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_NET_TO_MAKER: u64 = EXPECTED_GROSS_QUOTE - EXPECTED_FEE; + + // Seller posts the resting ask. + let maker_ask_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![maker_ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + // Buyer's taker bid at the same price, same qty — fully crosses. + const TAKER_BID_ID: u64 = 2; + let taker_bid_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![taker_bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Fee vault received exactly fee_bps of the gross. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + EXPECTED_FEE + ); + + let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_user_account); + assert_eq!(buyer_base, QUANTITY); + // No price improvement here — buyer's limit == maker's price — so no + // quote rebate lands in the taker's unsettled_quote. + assert_eq!(buyer_quote, 0); + + let (_seller_base, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_user_account); + assert_eq!(seller_quote, EXPECTED_NET_TO_MAKER); + + // The resting maker order should have been removed from the book and + // marked Filled. + let maker_order = order_pda(&sc.program_id, &sc.market, MAKER_ASK_ID); + let (filled, status) = read_order_fill_and_status(&sc.svm, &maker_order); + assert_eq!(filled, QUANTITY); + assert_eq!(status, ORDER_STATUS_FILLED); +} + +#[test] +fn taker_ask_fully_crosses_best_bid() { + // Mirror of the bid test. Buyer rests a bid, seller's ask fully eats it. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_BID_ID: u64 = 1; + const PRICE: u64 = 1000; + const QUANTITY: u64 = 100; + const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY; + const EXPECTED_FEE: u64 = EXPECTED_GROSS_QUOTE * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_NET_TO_TAKER: u64 = EXPECTED_GROSS_QUOTE - EXPECTED_FEE; + + let maker_bid_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + MAKER_BID_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![maker_bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + const TAKER_ASK_ID: u64 = 2; + let taker_ask_ix = build_place_order_with_makers_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + TAKER_ASK_ID, + PRICE, + QUANTITY, + &[(MAKER_BID_ID, sc.buyer_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![taker_ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + EXPECTED_FEE + ); + // Maker (buyer) received the base tokens they paid for. + let (buyer_base, _buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_user_account); + assert_eq!(buyer_base, QUANTITY); + + // Taker (seller) received the net-of-fee quote. + let (_seller_base, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_user_account); + assert_eq!(seller_quote, EXPECTED_NET_TO_TAKER); +} + +#[test] +fn taker_partially_fills_resting_order_rest_stays_on_book() { + // Seller rests ask qty=100. Buyer bids qty=40 at the same price. + // The ask stays on the book with qty=60 remaining; the taker fully + // matches and rests nothing. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const MAKER_ASK_QUANTITY: u64 = 100; + const TAKER_BID_QUANTITY: u64 = 40; + const PRICE: u64 = 1000; + + let ask_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + MAKER_ASK_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + const TAKER_BID_ID: u64 = 2; + let bid_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + TAKER_BID_QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Maker order: still PartiallyFilled, filled_quantity == TAKER_BID_QUANTITY. + let maker_order = order_pda(&sc.program_id, &sc.market, MAKER_ASK_ID); + let (filled, status) = read_order_fill_and_status(&sc.svm, &maker_order); + assert_eq!(filled, TAKER_BID_QUANTITY); + assert_eq!(status, ORDER_STATUS_PARTIALLY_FILLED); + + // Base vault still holds the un-filled portion (seller's lock, minus + // what was delivered to the taker's unsettled_base — which never left + // the vault, just got re-tagged as owed to the buyer). + // + // Total base in vault stays == MAKER_ASK_QUANTITY, because fills are + // bucket-accounting inside the single vault. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + MAKER_ASK_QUANTITY + ); + + // Taker received TAKER_BID_QUANTITY base tokens. + let (buyer_base, _) = read_user_unsettled(&sc.svm, &sc.buyer_user_account); + assert_eq!(buyer_base, TAKER_BID_QUANTITY); +} + +#[test] +fn taker_partially_filled_remainder_rests_on_book() { + // Seller rests ask qty=40. Buyer bids qty=100 at the same price. + // Buyer eats the whole ask and the remaining 60 rests on the book as a + // new bid. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const MAKER_ASK_QUANTITY: u64 = 40; + const TAKER_BID_QUANTITY: u64 = 100; + const PRICE: u64 = 1000; + + let ask_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + MAKER_ASK_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + const TAKER_BID_ID: u64 = 2; + let bid_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + TAKER_BID_QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Maker ask is fully filled. + let maker_order = order_pda(&sc.program_id, &sc.market, MAKER_ASK_ID); + let (filled, status) = read_order_fill_and_status(&sc.svm, &maker_order); + assert_eq!(filled, MAKER_ASK_QUANTITY); + assert_eq!(status, ORDER_STATUS_FILLED); + + // Taker's own order is PartiallyFilled with `filled_quantity` equal + // to what the maker supplied. + let taker_order = order_pda(&sc.program_id, &sc.market, TAKER_BID_ID); + let (taker_filled, taker_status) = read_order_fill_and_status(&sc.svm, &taker_order); + assert_eq!(taker_filled, MAKER_ASK_QUANTITY); + assert_eq!(taker_status, ORDER_STATUS_PARTIALLY_FILLED); + + // The taker's own Order PDA holds the true remaining-on-book quantity + // (original_quantity - filled_quantity). On-book quantity isn't stored + // on OrderEntry directly — see state/order_book.rs — so this is the + // source of truth both here and at runtime. + assert_eq!( + TAKER_BID_QUANTITY - taker_filled, + TAKER_BID_QUANTITY - MAKER_ASK_QUANTITY + ); +} + +#[test] +fn taker_crosses_multiple_resting_orders_best_price_first() { + // Two resting asks at different prices: 900 and 1000. A taker bid big + // enough to chew through both must hit 900 first (best price for the + // taker), then 1000. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const BEST_ASK_ID: u64 = 1; + const BEST_ASK_PRICE: u64 = 900; + const BEST_ASK_QUANTITY: u64 = 30; + + const SECOND_ASK_ID: u64 = 2; + const SECOND_ASK_PRICE: u64 = 1000; + const SECOND_ASK_QUANTITY: u64 = 50; + + // Taker bids at the worse of the two resting prices so both cross. + const TAKER_BID_ID: u64 = 3; + const TAKER_BID_PRICE: u64 = 1000; + const TAKER_BID_QUANTITY: u64 = BEST_ASK_QUANTITY + SECOND_ASK_QUANTITY; + + // Need to post both asks and both rest — seller places two in sequence. + let ask_one_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + BEST_ASK_ID, + BEST_ASK_PRICE, + BEST_ASK_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_one_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + let ask_two_ix = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + SECOND_ASK_ID, + SECOND_ASK_PRICE, + SECOND_ASK_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_two_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + // Taker walks in book order: best ask (900) then second (1000). + let taker_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + TAKER_BID_PRICE, + TAKER_BID_QUANTITY, + &[ + (BEST_ASK_ID, sc.seller_user_account), + (SECOND_ASK_ID, sc.seller_user_account), + ], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![taker_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Both resting asks are fully filled. + let order_one = order_pda(&sc.program_id, &sc.market, BEST_ASK_ID); + let order_two = order_pda(&sc.program_id, &sc.market, SECOND_ASK_ID); + assert_eq!(read_order_fill_and_status(&sc.svm, &order_one).1, ORDER_STATUS_FILLED); + assert_eq!(read_order_fill_and_status(&sc.svm, &order_two).1, ORDER_STATUS_FILLED); + + // Taker got `TAKER_BID_QUANTITY` base tokens. + let (buyer_base, buyer_quote_rebate) = read_user_unsettled(&sc.svm, &sc.buyer_user_account); + assert_eq!(buyer_base, TAKER_BID_QUANTITY); + + // Price-improvement rebate: taker locked at 1000/unit but 30 units + // filled at 900. Rebate = (1000 - 900) * 30 = 3_000. + const PRICE_IMPROVEMENT_REBATE: u64 = (TAKER_BID_PRICE - BEST_ASK_PRICE) * BEST_ASK_QUANTITY; + assert_eq!(buyer_quote_rebate, PRICE_IMPROVEMENT_REBATE); + + // Seller's net unsettled_quote = sum of (fill_price * fill_qty - fee) + // across both fills. + let gross_one: u64 = BEST_ASK_PRICE * BEST_ASK_QUANTITY; + let gross_two: u64 = SECOND_ASK_PRICE * SECOND_ASK_QUANTITY; + let fee_one: u64 = gross_one * FEE_BASIS_POINTS as u64 / 10_000; + let fee_two: u64 = gross_two * FEE_BASIS_POINTS as u64 / 10_000; + let expected_seller_quote = (gross_one - fee_one) + (gross_two - fee_two); + let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_user_account); + assert_eq!(seller_quote, expected_seller_quote); +} + +#[test] +fn resting_orders_at_same_price_fill_by_time_priority() { + // Two resting asks at price 1000: first from seller, then from a second + // seller. Taker only buys enough to cross the first one. The second + // must stay on the book untouched. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Bootstrap a third wallet (second seller) with base tokens. + let second_seller = create_wallet(&mut sc.svm, 10_000_000_000).unwrap(); + let second_seller_base_ata = create_associated_token_account( + &mut sc.svm, + &second_seller.pubkey(), + &sc.base_mint, + &sc.payer, + ) + .unwrap(); + let second_seller_quote_ata = create_associated_token_account( + &mut sc.svm, + &second_seller.pubkey(), + &sc.quote_mint, + &sc.payer, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut sc.svm, + &sc.base_mint, + &second_seller_base_ata, + TRADER_STARTING_BALANCE, + &sc.authority, + ) + .unwrap(); + let second_seller_user_account = user_account_pda(&sc.program_id, &sc.market, &second_seller.pubkey()); + let __ix1 = build_create_user_account_ix(&sc, &second_seller.pubkey()); + send_transaction_from_instructions(&mut sc.svm, vec![__ix1], &[&second_seller], + &second_seller.pubkey()).unwrap(); + + const FIRST_ASK_ID: u64 = 1; + const SECOND_ASK_ID: u64 = 2; + const ASK_PRICE_SHARED: u64 = 1000; + const ASK_QUANTITY_EACH: u64 = 20; + + // Seller 1 first in. + let __ix2 = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + FIRST_ASK_ID, + ASK_PRICE_SHARED, + ASK_QUANTITY_EACH, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix2], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + // Seller 2 second in at the same price. + let __ix3 = build_place_order_ix( + &sc, + &second_seller, + second_seller_user_account, + second_seller_base_ata, + second_seller_quote_ata, + clob::state::OrderSide::Ask, + SECOND_ASK_ID, + ASK_PRICE_SHARED, + ASK_QUANTITY_EACH, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix3], &[&second_seller], + &second_seller.pubkey()).unwrap(); + + // Taker bid buys only enough to cross seller 1's ask. + const TAKER_BID_ID: u64 = 3; + let taker_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + ASK_PRICE_SHARED, + ASK_QUANTITY_EACH, + &[(FIRST_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![taker_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Time priority: seller 1 filled, seller 2 still open. + let order_one = order_pda(&sc.program_id, &sc.market, FIRST_ASK_ID); + let order_two = order_pda(&sc.program_id, &sc.market, SECOND_ASK_ID); + assert_eq!(read_order_fill_and_status(&sc.svm, &order_one).1, ORDER_STATUS_FILLED); + assert_eq!(read_order_fill_and_status(&sc.svm, &order_two).1, ORDER_STATUS_OPEN); +} + +#[test] +fn taker_bid_gets_price_improvement_from_resting_ask() { + // Taker limit 1000, resting ask at 900. Taker pays 900 (maker's price), + // gets 100-per-unit price improvement rebated to their unsettled_quote. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const MAKER_ASK_PRICE: u64 = 900; + const TAKER_BID_PRICE: u64 = 1000; + const QUANTITY: u64 = 50; + + // Maker ask. + let __ix4 = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + MAKER_ASK_PRICE, + QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix4], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + + // Taker bid — limit 1000. + const TAKER_BID_ID: u64 = 2; + let taker_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + TAKER_BID_PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![taker_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Maker got 900-per-unit (minus fee), not 1000. + let gross_to_maker: u64 = MAKER_ASK_PRICE * QUANTITY; + let fee: u64 = gross_to_maker * FEE_BASIS_POINTS as u64 / 10_000; + let expected_net_to_maker: u64 = gross_to_maker - fee; + let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_user_account); + assert_eq!(seller_quote, expected_net_to_maker); + + // Taker locked (TAKER_BID_PRICE * QUANTITY) of quote up front; only + // (MAKER_ASK_PRICE * QUANTITY) was spent. The difference is the + // price-improvement rebate. + let expected_rebate: u64 = (TAKER_BID_PRICE - MAKER_ASK_PRICE) * QUANTITY; + let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_user_account); + assert_eq!(buyer_base, QUANTITY); + assert_eq!(buyer_quote, expected_rebate); +} + +#[test] +fn fee_vault_receives_exactly_bps_of_taker_gross() { + // Simpler standalone check of the fee maths: fee_vault must equal + // (taker gross quote) * fee_bps / 10_000 after a single fill. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 500; + const QUANTITY: u64 = 200; + const GROSS: u64 = PRICE * QUANTITY; + const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + + let __ix5 = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix5], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + + const TAKER_BID_ID: u64 = 2; + let __ix6 = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix6], &[&sc.buyer], &sc.buyer.pubkey()).unwrap(); + + + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + EXPECTED_FEE + ); +} + +#[test] +fn authority_can_withdraw_fees_after_match() { + // Run a fill, confirm fee vault has something, withdraw to authority. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Authority needs a quote ATA to receive the withdrawn fees. + let authority_quote_ata = create_associated_token_account( + &mut sc.svm, + &sc.authority.pubkey(), + &sc.quote_mint, + &sc.payer, + ) + .unwrap(); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 2000; + const QUANTITY: u64 = 50; + const GROSS: u64 = PRICE * QUANTITY; + const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + + let __ix7 = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix7], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + + const TAKER_BID_ID: u64 = 2; + let __ix8 = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix8], &[&sc.buyer], &sc.buyer.pubkey()).unwrap(); + + + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + EXPECTED_FEE + ); + + let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + send_transaction_from_instructions( + &mut sc.svm, + vec![withdraw_ix], + &[&sc.authority], + &sc.authority.pubkey(), + ) + .unwrap(); + + // Fee vault drained, authority received the fees. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &authority_quote_ata).unwrap(), + EXPECTED_FEE + ); +} + +#[test] +fn settle_funds_after_match_pays_out_both_unsettled_balances() { + // End-to-end: match, then call settle_funds for both sides. Both + // traders must receive the tokens the match credited to their + // unsettled_* balances. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 1000; + const QUANTITY: u64 = 100; + const GROSS: u64 = PRICE * QUANTITY; + const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; + const EXPECTED_NET_QUOTE_TO_SELLER: u64 = GROSS - EXPECTED_FEE; + + // Maker posts and taker crosses. + let __ix9 = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + clob::state::OrderSide::Ask, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix9], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + const TAKER_BID_ID: u64 = 2; + let __ix10 = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + clob::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_user_account)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix10], &[&sc.buyer], &sc.buyer.pubkey()).unwrap(); + + + // Settle both sides. + let __ix11 = build_settle_funds_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_user_account, + sc.buyer_base_ata, + sc.buyer_quote_ata, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix11], &[&sc.buyer], + &sc.buyer.pubkey()).unwrap(); + let __ix12 = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_user_account, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions(&mut sc.svm, vec![__ix12], &[&sc.seller], + &sc.seller.pubkey()).unwrap(); + + // Buyer should now hold `QUANTITY` extra base tokens and have paid the + // gross quote (starting balance minus gross). No price improvement + // here, so nothing else to refund. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_base_ata).unwrap(), + QUANTITY + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), + TRADER_STARTING_BALANCE - GROSS + ); + + // Seller should now hold (starting - QUANTITY) base and + // EXPECTED_NET_QUOTE_TO_SELLER quote. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE - QUANTITY + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_quote_ata).unwrap(), + EXPECTED_NET_QUOTE_TO_SELLER + ); +} + From df0a7308e213f99b50ce3d7e4ee0ac7dd52701c9 Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:56:52 +0000 Subject: [PATCH 4/5] docs(clob): rewrite README to the repo-wide quality bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous README was a ~78-line stub. This version describes the program as it actually exists today, including the matching engine landed in ea960844. Structure matches the rest of the overhaul: 1. What does this program do? (onchain mechanics first, with tradfi terms — limit order, order book, maker/taker, price-time priority — briefly explained in plain English before they get used) 2. Glossary (account, PDA, CPI, bps, bid/ask, tick size, unsettled balance, price improvement, remaining_accounts, etc.) 3. Accounts and PDAs (four program PDAs + three vaults; full field lists; note the vaults are not PDAs, they are regular token accounts whose authority is the Market PDA) 4. Instruction lifecycle walkthrough (six instructions, in the order a user encounters them; per-ix signers / accounts / PDAs created / token-flow diagrams / state changes / checks) 5. The matching engine — step by step (the critical section: how place_order uses remaining_accounts; the plan/apply/clean/ fee/rest five-step structure; fee math; price improvement; worked fill walkthroughs including a multi-maker sweep) 6. Full-lifecycle worked examples (clean match + settle, partial fill + remainder, cancel + settle round trip) 7. Safety and edge cases (full error table; guarded design choices; what the example does NOT do) 8. Running the tests (all 23 tests listed and categorised; CI note confirming anchor build runs before anchor test) 9. Extending the program (easy / moderate / harder) 1433 lines. No code changes. --- defi/clob/anchor/README.md | 1449 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1402 insertions(+), 47 deletions(-) diff --git a/defi/clob/anchor/README.md b/defi/clob/anchor/README.md index 6e7644dfd..9316c9af5 100644 --- a/defi/clob/anchor/README.md +++ b/defi/clob/anchor/README.md @@ -1,78 +1,1433 @@ -# Anchor CLOB +# CLOB — Central Limit Order Book -A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price. Incoming orders cross against resting orders on the opposite side of the book using **price-time priority** — taker proceeds land in the user's `unsettled_*` balance and are withdrawn later via `settle_funds`. Unmatched remainders rest on the book as new maker orders. +An Anchor program that runs a simple **onchain order book** for a single +SPL-token trading pair. Users post buy or sell offers at the prices they +want, the program matches crossing offers, and settles the resulting +token movements. -This is a teaching example. It is deliberately small — the real CLOBs on Solana (Openbook, Phoenix) use zero-copy slab data structures and much more sophisticated matching, cancellation, and fee logic. +This README is a teaching document. If you have never written a Solana +program before and have no background in trading, you are the target +reader. Every term that could be unfamiliar is explained the first time +it appears, and every instruction is walked through step by step with +the exact token movements it causes. -## Concepts +If you already know what an order book, a limit order and a taker fee +are, skip to [Accounts and PDAs](#3-accounts-and-pdas) or +[Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough). -- **Market** — one trading pair, e.g. `BASE/QUOTE`. Stored at a PDA seeded by the two mints. The market account is the signer of its three token vaults (base, quote, fee). -- **Order Book** — a PDA per market holding two `Vec`s: bids (sorted descending by price) and asks (sorted ascending). Price-time priority is implicit in the Vec order: best price is index 0, and within a price level the earliest insertion is first. -- **User Account** — one per user per market. Tracks the user's open order ids and two "unsettled" balances (base and quote) representing tokens the program owes the user but has not yet transferred back. -- **Order** — a PDA per placed order, seeded by `(market, order_id)`. Stores price, original and filled quantity, status (`Open`, `PartiallyFilled`, `Filled`, `Cancelled`) and the owner. -- **Fee vault** — a separate token account (quote mint) that accumulates taker fees. The market PDA is its authority; only `withdraw_fees` can drain it, and only the market's stored `authority` may call that. +--- -## Instructions +## Table of contents -| Name | What it does | -|-----------------------|--------------| -| `initialize_market` | Create the market, order book, base vault, quote vault, and fee vault for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. | -| `create_user_account` | Initialise the caller's per-market user account. | -| `place_order` | Lock the required funds (bids lock `price × quantity` of quote; asks lock `quantity` of base), then cross against the opposing side of the book (price-time priority). Taker proceeds land in `unsettled_base`/`unsettled_quote`; any unmatched remainder rests on the book. Callers pass resting-order PDAs and their owners' `UserAccount` PDAs as `remaining_accounts`, in pairs, in book order. | -| `cancel_order` | Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. | -| `settle_funds` | Move all unsettled base and quote from the market's vaults back to the owner's token accounts. Signs with the market PDA. | -| `withdraw_fees` | Authority-only. Drains the fee vault into the authority's quote token account. Safe to call with an empty fee vault — it no-ops rather than reverting. | +1. [What does this program do?](#1-what-does-this-program-do) +2. [Glossary](#2-glossary) +3. [Accounts and PDAs](#3-accounts-and-pdas) +4. [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) +5. [The matching engine — step by step](#5-the-matching-engine--step-by-step) +6. [Full-lifecycle worked examples](#6-full-lifecycle-worked-examples) +7. [Safety and edge cases](#7-safety-and-edge-cases) +8. [Running the tests](#8-running-the-tests) +9. [Extending the program](#9-extending-the-program) -### Matching semantics +--- -`place_order` walks the opposite side of the book in price-time priority order: +## 1. What does this program do? -- A **taker bid** walks asks lowest-first. For each ask whose price `<=` the bid's limit, a fill occurs at the ask's (maker's) price, for `min(taker_remaining, maker_remaining)` quantity. Stops when the bid is filled or the next ask's price exceeds the bid's limit. -- A **taker ask** mirrors: walk bids highest-first, fill at the bid's price while the bid's price `>=` the ask's limit. -- **Price improvement** — a bid at 1000 crossing an ask at 900 fills at 900. The taker locked `1000 × qty` of quote up front; the `100 × qty` they didn't need is refunded to their `unsettled_quote`. -- **Time priority** — two orders at the same price fill in the order they were inserted. The oldest resting order wins. +Two users want to swap tokens at prices they each picked: -### Fee model +- Alice holds some amount of SPL mint **Q** (the "quote" mint — think of + this as the pricing currency, like USD in "BTC is $60 000") and wants + to obtain some amount of SPL mint **B** (the "base" mint — the asset + being priced), but only if she can get B at 900 Q per unit or lower. +- Bob holds mint **B** and wants Q, but only if he can get at least 950 + Q per unit of B he sells. -A single `fee_basis_points` value (0–10_000) applies to the taker fee on the quote side of each fill: +They post their offers — Alice a **bid** (buy offer) at price 900, Bob +an **ask** (sell offer) at price 950 — and wait. Alice's bid sits on +the book. Bob's ask sits on the book. Neither crosses the other, so +nothing happens yet. + +Later, Carol shows up holding B and willing to sell at any price ≥ 900. +She posts an ask at 900. Now Alice's bid (900) crosses Carol's new ask +(900). The program: + +1. Pairs them up. +2. Takes Carol's B out of Carol's token account, locks it in the + program's vault. +3. Takes Alice's Q out of Alice's token account (it was already locked + there when Alice placed her bid). +4. Credits each of them with what they're owed, minus a fee for the + market operator. + +At no point does either of them transfer directly to the other — all +token flows go through two program-owned vaults, and both users later +call `settle_funds` to pull their balances out. + +### The onchain pieces, in plain terms + +- A **Market** PDA — one per base/quote pair. Stores fee rate, tick + size, minimum order size, the addresses of the four related accounts + (base vault, quote vault, fee vault, order book), and the pubkey + that can withdraw accumulated fees. +- An **OrderBook** PDA — two sorted lists (bids best-first, asks + best-first) of lightweight `OrderEntry` records, each pointing at a + full `Order` account. +- A **UserAccount** PDA — one per `(market, wallet)` pair. Tracks the + order_ids this user has open and two running tallies + (`unsettled_base`, `unsettled_quote`) of tokens owed back to this + user from fills or cancellations. +- An **Order** PDA — one per placed order. Stores price, quantity, + side (bid or ask), fill status, and the owner. +- Three token accounts held by the Market PDA: `base_vault` (all + sellers' locked base + buyers' bought base waiting to be withdrawn), + `quote_vault` (mirror for quote), and `fee_vault` (accumulated taker + fees). + +### Tradfi background, briefly + +For readers new to trading terms — two quick sentences per concept. +They're optional; everything above already describes the program +mechanically. + +- **A limit order** is an instruction to trade an amount of asset at a + specific price or better. A *bid* is a limit order to buy, an *ask* + is a limit order to sell. The "limit" part means: don't trade at a + worse price than the one I named. + +- **An order book** is just the currently-open bids and asks, usually + sorted so the best price on each side sits at the top. The "top of + book" on the bid side is the highest-priced buy offer; the top of + book on the ask side is the lowest-priced sell offer. + +- **A maker** is whoever posts an order that doesn't immediately match + — they "make" liquidity by leaving their offer on the book for + others to trade against. A **taker** is whoever walks into the book + and hits the resting orders — they "take" liquidity. + +- **A taker fee** is a small cut of each trade that the market + operator takes from the taker's leg of the trade. Expressed in basis + points (see glossary), so 50 bps = 0.5%. + +- **Price-time priority** is the universal ordering rule: at the same + price level, whoever posted first fills first. + +- **Settlement** is the step that actually moves tokens out of the + custody account and back to the user. This program splits matching + and settlement into two instructions (`place_order` + `settle_funds`) + so a taker crossing a long list of orders doesn't have to pay for a + token CPI per maker. + +### What this example is not + +- **Not deployed, not audited.** Treat as a learning example. The + OrderBook is a `Vec` with a 100-per-side cap that + deserialises in full every call — fine at small scale, unsuitable + for production. Real Solana CLOBs (Openbook v2, Phoenix) use + zero-copy slabs. +- **No explicit IOC / FOK / post-only** — every order matches what it + can and rests the rest. +- **No circuit breakers, no oracles, no price bands.** + +--- + +## 2. Glossary + +Terms used below, defined in terms of what they are mechanically. + +**Account** +: On Solana, every piece of state lives in an *account* — a 32-byte +address, some lamports keeping it rent-exempt, an owner program, and +a byte buffer. Wallets, token balances, and program config are all +accounts. + +**Lamport** +: The smallest unit of SOL. 1 SOL = 10⁹ lamports. + +**Signer** +: An account whose private key signed the transaction. A signer is the +only thing that can authorise transfers out of an account it owns. + +**SPL token** +: Solana's ERC-20 equivalent. An SPL *mint* describes a token; each +user's balance lives in a separate *token account* owned by the SPL +Token program. + +**Token account** +: An account holding a balance of one mint, with an *authority* pubkey +that can move tokens out. Authorities are usually user wallets but can +be PDAs — in this program the Market PDA is the authority of all +three vaults. + +**ATA (Associated Token Account)** +: The conventional, deterministic token account for a `(wallet, mint)` +pair. "Sending USDC to someone's wallet" really means sending to their +USDC ATA. + +**PDA (Program Derived Address)** +: A deterministic address derived from a list of byte "seeds" plus a +program id. PDAs have no private key. A program *signs* as a PDA by +re-supplying the seeds during a CPI. This program creates four kinds +of PDA: `Market`, `OrderBook`, `Order`, `UserAccount`. The vaults are +regular token accounts (not PDAs) whose authority is set to the +`Market` PDA. + +**Seeds** +: Bytes that, with the program id, derive a PDA. This program's seeds: + + Market ["market", base_mint, quote_mint] + OrderBook ["order_book", market] + Order ["order", market, order_id.to_le_bytes()] + UserAccount ["user", market, owner] + +**Bump** +: The byte offset that makes `find_program_address` produce an +off-curve address. Stored on each PDA struct so the program doesn't +recompute it every time it signs. + +**CPI (Cross-Program Invocation)** +: One program calling another inside the same transaction. This +program's CPIs go to the SPL Token program's `TransferChecked`. + +**Discriminator** +: First 8 bytes of each Anchor account — the first 8 bytes of +`sha256("account:")`. Anchor writes it at initialisation +and rejects any deserialisation where the prefix doesn't match. + +**Basis point (bps)** +: 1/100 of a percent. 10 000 bps = 100%. The program's fee rate is +expressed in bps (`fee_basis_points: u16`). + +**Base asset, quote asset** +: Two words for the two sides of a pair. In "BASE/QUOTE", the base is +the asset being priced and the quote is the pricing unit. Bids spend +quote and receive base; asks spend base and receive quote. + +**Bid, ask** +: A bid is a buy order (sits on the bid side of the book). An ask is a +sell order. On this program they're the two variants of `OrderSide`. + +**Limit price** +: The worst price at which an order is allowed to trade — for a bid, +the *highest* the taker is willing to pay; for an ask, the *lowest* +the seller is willing to accept. A bid at 900 will not fill against +an ask at 950; it rests at 900 until a seller drops their price. + +**Tick size** +: Smallest allowable price increment. A market with `tick_size = 10` +accepts prices 10, 20, 30, … but rejects 15. Stops the book filling +up with 1-unit-apart orders. + +**Minimum order size** +: Smallest allowable `quantity` for any order. Keeps dust orders from +polluting the book. + +**Maker, taker** +: A maker is whoever posted the resting order that gets hit. A taker +is whoever walked in and hit it. The same person is sometimes both in +one call to `place_order`: they fill some quantity as a taker and +rest the rest as a maker for the next person. + +**Match, fill, cross** +: Two orders *cross* when the bid's price is ≥ the ask's price. They +*match* (are paired up) and a *fill* is the result — one crossing +event, with a fill quantity and a fill price. A single `place_order` +call can produce many fills if the taker quantity eats through +several resting orders. + +**Price improvement** +: When a taker's limit is better than the best resting price on the +other side, the fill happens at the resting price (maker's price +wins). The taker got a better deal than they asked for — the +difference is *price improvement*. In this program that's reflected +by refunding the difference to the taker's `unsettled_quote`. + +**Unsettled balance** +: Two `u64` counters on each `UserAccount`: `unsettled_base` and +`unsettled_quote`. Fills, price-improvement rebates, and order +cancellations all increase these counters. The physical tokens still +sit in the market's vaults. `settle_funds` moves them to the user's +own token accounts and zeroes the counters. + +**Fee vault** +: A separate SPL token account (quote mint) owned by the Market PDA. +Every taker fee — `gross * fee_bps / 10_000` per fill — moves here in +one batched CPI at the end of `place_order`. + +**Price-time priority** +: Best price first; at the same price, earliest-posted first. Here +price priority is enforced by keeping `bids` sorted descending and +`asks` sorted ascending, and time priority falls out of insertion +order at each level (new orders at an existing price go to the end of +that price's run). + +**Remaining accounts** +: Solana lets the caller pass a tail of extra `AccountInfo`s beyond +the ones named in `#[derive(Accounts)]`. This program uses them for +maker orders: for each resting order the taker wants to cross, the +caller supplies `(maker_order_pda, maker_user_account_pda)` in the +book's price-time order. The handler walks them in pairs. + +--- + +## 3. Accounts and PDAs + +### State / data accounts + +| Account | PDA? | Seeds | Authority | Holds | +|---|---|---|---|---| +| `Market` | yes | `["market", base_mint, quote_mint]` | program | fee rate, tick size, min order size, base/quote mint pubkeys, vault pubkeys, order book pubkey, `authority` wallet (allowed to withdraw fees) | +| `OrderBook` | yes | `["order_book", market]` | program | two `Vec` (bids best-first, asks best-first), `next_order_id` | +| `Order` | yes | `["order", market, order_id.to_le_bytes()]` | program | owner, side, price, original_quantity, filled_quantity, status, timestamp | +| `UserAccount` | yes | `["user", market, owner]` | program | `unsettled_base`, `unsettled_quote`, `open_orders: Vec` (max 20) | + +### Token accounts (owned by SPL Token, authority = Market PDA) + +| Account | PDA? | Authority | Mint | Holds | +|---|---|---|---|---| +| `base_vault` | no (regular token account) | Market PDA | base | bids' locked base IS NOT STORED HERE — only asks' locked base sits here pre-match, plus base owed to bid-takers waiting for `settle_funds` | +| `quote_vault` | no | Market PDA | quote | bids' locked quote pre-match, plus quote owed to ask-takers and bid-makers waiting for settlement | +| `fee_vault` | no | Market PDA | quote | taker fees accumulated across all fills; drained by `withdraw_fees` | + +Note: the **token vaults are not PDAs**. They are regular token +accounts created with `init` in `initialize_market.rs`; their +*authority* is the Market PDA, so only the program can move funds out. +Their addresses are computed by the caller (e.g. generated Keypairs in +the tests) and then written to `market.base_vault` / `quote_vault` / +`fee_vault` for the program to validate them on later calls via +`has_one = fee_vault` etc. + +### `OrderEntry` layout on `OrderBook` + +```rust +pub struct OrderEntry { + pub order_id: u64, // links to the full Order PDA + pub price: u64, + pub owner: Pubkey, +} +``` + +Kept deliberately small so the OrderBook account stays under the 10 KB +limit with 100 bids + 100 asks. The full order state (quantity, +filled_quantity, status, timestamp) lives on the `Order` PDA; the book +just needs enough to pick what to cross next and re-derive the PDA. + +### `Order` state + +From [`state/order.rs`](programs/clob/src/state/order.rs): + +```rust +pub struct Order { + pub market: Pubkey, + pub owner: Pubkey, + pub order_id: u64, + pub side: OrderSide, // Bid | Ask + pub price: u64, + pub original_quantity: u64, + pub filled_quantity: u64, + pub status: OrderStatus, // Open | PartiallyFilled | Filled | Cancelled + pub timestamp: i64, + pub bump: u8, +} +``` + +`remaining_quantity(order) = original_quantity - filled_quantity`. Used +by `cancel_order` to decide how much to credit back to the user. + +### `UserAccount` state + +```rust +pub struct UserAccount { + pub market: Pubkey, + pub owner: Pubkey, + pub unsettled_base: u64, + pub unsettled_quote: u64, + pub open_orders: Vec, // capped at 20 via Anchor max_len + pub bump: u8, +} +``` + +The `open_orders` cap (20 per user) is mirrored by a +`MAX_OPEN_ORDERS_PER_USER` check in `place_order`. One user cannot +flood the book. + +### How vault balances evolve + +At any point in time: + +- `base_vault.balance` = sum of all resting asks' `remaining_quantity` + + every user's `unsettled_base`. +- `quote_vault.balance` = sum of all resting bids' + `price * remaining_quantity` + + every user's `unsettled_quote`. + +(Plus the bit of quote that the matching engine has already taken out +as fee and batched into `fee_vault`.) + +This is not a hard invariant the program enforces — it emerges from +the flows. The invariant worth caring about is the per-event balance: +every fill moves tokens from the loser's locked pool to the winner's +`unsettled_*`, plus the fee cut to `fee_vault`. The unit tests check +this directly (`settle_funds_after_match_pays_out_both_unsettled_balances`). + +--- + +## 4. Instruction lifecycle walkthrough + +The program has six instructions. The order a user encounters them is: + +1. `initialize_market` (market operator — once) +2. `create_user_account` (every user, once per market) +3. `place_order` (a user — as many times as they want) +4. `cancel_order` (a user — to remove a resting order) +5. `settle_funds` (a user — to collect winnings) +6. `withdraw_fees` (market authority — to collect protocol revenue) + +For each, the shape is: who signs, what accounts go in, what PDAs get +created, what token flows happen, what state mutates, what checks are +run. + +Token flow shorthand: + +``` + --[amount of ]--> +``` + +### 4.1 `initialize_market` + +**Who calls it:** the market operator. They create a new trading pair. + +**Signers:** `authority`. + +**Parameters:** + +```rust +pub fn initialize_market( + context: Context, + fee_basis_points: u16, + tick_size: u64, + min_order_size: u64, +) -> Result<()> +``` + +**Accounts in:** + +- `authority` (signer, mut — pays account rent for all five new + accounts) +- `market` (PDA, **init**, seeds `["market", base_mint, quote_mint]`) +- `order_book` (PDA, **init**, seeds `["order_book", market]`) +- `base_mint`, `quote_mint` (read-only) +- `base_vault`, `quote_vault`, `fee_vault` (all **init** as + `TokenAccount`s, authority = `market`) +- `token_program`, `system_program` + +**Checks:** + +- `tick_size > 0` → `InvalidTickSize` +- `min_order_size > 0` → `BelowMinOrderSize` +- `fee_basis_points <= 10_000` → `InvalidFeeBasisPoints` + +**Token movements:** none (the vaults are empty after init). + +**State changes:** `market` and `order_book` accounts are written with +the supplied parameters plus all the derived fields +(`market.authority`, the vault pubkeys, `is_active = true`, +`next_order_id = 1`). + +The vaults are regular SPL token accounts, *not* PDAs — their +addresses are chosen by the caller (typically fresh keypairs) and +captured on the market's state so later instructions can validate +them. + +### 4.2 `create_user_account` + +**Who calls it:** every user, exactly once per market they want to +trade on. + +**Signers:** `owner`. + +**Accounts in:** + +- `owner` (signer, mut — pays rent) +- `market` (read-only) +- `user_account` (PDA, **init**, seeds `["user", market, owner]`) +- `system_program` + +**Token movements:** none. + +**State changes:** new `UserAccount` with all counters zero and no +open orders. + +### 4.3 `place_order` + +**Who calls it:** anyone with a `UserAccount` for this market. + +**Signers:** `owner`. + +**Parameters:** + +```rust +pub fn place_order<'info>( + context: Context<'info, PlaceOrder<'info>>, + side: OrderSide, // Bid | Ask + price: u64, + quantity: u64, +) -> Result<()> +``` + +**Accounts in (named):** + +- `market` (mut, `has_one = fee_vault`) +- `order_book` (mut, PDA seeds-checked) +- `order` (PDA, **init**, seeds + `["order", market, next_order_id.to_le_bytes()]`) +- `user_account` (mut, PDA seeds-checked) +- `base_vault`, `quote_vault`, `fee_vault` (all mut, boxed) +- `user_base_account`, `user_quote_account` (mut — the caller's ATAs) +- `base_mint`, `quote_mint` (read-only) +- `owner` (signer, mut) +- `token_program`, `system_program` + +**Accounts in (remaining):** a list of `AccountInfo`s passed via the +transaction's remaining accounts, grouped in pairs. For each resting +order the caller wants the taker to cross, in the book's price-time +order: + +``` +remaining_accounts[2*i] = maker_order_pda (Order account) +remaining_accounts[2*i + 1] = maker_user_account_pda (UserAccount) +``` + +If the caller doesn't pass any pairs, the order is treated as +pure-maker: whatever part of it is allowed by the book state becomes a +resting order. + +**Checks (top of handler):** + +- `market.is_active` → `MarketPaused` +- `price > 0` → `InvalidPrice` +- `price % tick_size == 0` → `InvalidTickSize` +- `quantity >= min_order_size` → `BelowMinOrderSize` +- `open_orders.len() < 20` (mirror of the max_len on the struct) → + `TooManyOpenOrders` +- `remaining_accounts.len() % 2 == 0` → `MissingMakerAccounts` + +**Checks (per maker pair, during planning):** + +- Maker order's `order_id` exists in the relevant book side → + `MakerAccountMismatch` +- Maker order's `market == market.key()` → `MakerAccountMismatch` +- Maker pair index == book position (i.e. caller walked the book in + order) → `MakerAccountMismatch` + +**Checks (per fill, during execution):** + +- Maker order and user account have matching `owner` → + `MakerOwnerMismatch` +- Maker user account's `market == market.key()` → + `MakerAccountMismatch` + +**Checks (before resting remainder):** + +- `bids.len() + asks.len() < 2 * MAX_ORDERS_PER_SIDE` → + `OrderBookFull` +- Integer math throughout: every multiplication uses + `checked_mul`; every addition on balances uses `checked_add`; + fee division uses `u128` to avoid intermediate overflow → + `NumericalOverflow` + +**Token movements (up front):** + +For a **bid**: +``` + user_quote_account --[price * quantity of quote_mint]--> quote_vault +``` + +For an **ask**: +``` + user_base_account --[quantity of base_mint]--> base_vault +``` + +The full lock happens regardless of whether the order will fully fill +immediately. That keeps the vault invariant simple: the token account +always holds *exactly* what's needed to fulfil every open position +plus every unsettled balance. + +**Token movements (during matching, per fill):** see +[§5. The matching engine — step by step](#5-the-matching-engine--step-by-step). +Summary: + +- For a taker bid crossing a resting ask at price `p`: + ``` + quote_vault --[p * fill_qty * fee_bps / 10_000]--> fee_vault + (everything else stays in quote_vault as unsettled_quote for maker) + (base_vault provides the taker's base via unsettled_base — the base + was pre-locked when the maker placed their ask) + ``` + +- For a taker ask crossing a resting bid at price `p`: + ``` + quote_vault --[p * fill_qty * fee_bps / 10_000]--> fee_vault + ``` + +No user's ATA is touched during matching — all movements happen +between vaults or inside `UserAccount` counters. Physical payouts wait +for `settle_funds`. + +**PDAs created:** `order` (always; even fully-crossed takers get an +Order PDA, marked `Filled` immediately, for consistency with +indexers). + +**State changes:** + +On the taker's `UserAccount`: + +- `unsettled_base += sum of fill.fill_quantity` (taker bid side) +- `unsettled_quote += sum of price_improvement_rebate` + (taker bid side, per fill) +- `unsettled_quote += sum of (gross - fee)` (taker ask side) + +On each maker's `Order` (via `Account::try_from` + `exit`): + +- `filled_quantity += fill.fill_quantity` +- `status = PartiallyFilled` or `Filled` + +On each maker's `UserAccount`: + +- `unsettled_quote += gross - fee` (maker was an ask) +- `unsettled_base += fill.fill_quantity` (maker was a bid) +- `open_orders` list: maker's order removed if fully filled + +On `order_book`: + +- `next_order_id += 1` +- Fully-filled makers removed from the relevant side (bids or asks) in + reverse-index order +- Taker's remainder (if any) inserted into the correct side in price + order + +On the caller's new `order`: + +- All fields populated +- `status = Filled` if taker fully matched; otherwise + `PartiallyFilled` (if some fills) or `Open` (if no fills) + +### 4.4 `cancel_order` + +**Who calls it:** the order's owner. + +**Signers:** `owner`. + +**Accounts in:** + +- `market` +- `order_book` (mut) +- `order` (mut, PDA seeds-checked via stored bump) +- `user_account` (mut) +- `owner` (signer) + +**Checks:** + +- `order.owner == owner.key()` → `Unauthorized` +- `order.status ∈ {Open, PartiallyFilled}` → `OrderNotCancellable` +- The order's `order_id` is present in `order_book` → `OrderNotFound` + (sanity — shouldn't normally fire since fully-filled orders aren't + cancellable) + +**Token movements:** none. Cancellation is an accounting-only step. + +**State changes:** + +- For a cancelled bid: `unsettled_quote += price * remaining_quantity` + (the quote the bid had locked in the vault is now owed back to the + owner). +- For a cancelled ask: `unsettled_base += remaining_quantity`. +- Remove from `order_book.bids` or `order_book.asks`. +- Remove from `user_account.open_orders`. +- `order.status = Cancelled`. + +The actual token move happens on the next `settle_funds` call. + +### 4.5 `settle_funds` + +**Who calls it:** any user. No-op when both unsettled counters are +zero, so it is safe to call on a heartbeat/cron. + +**Signers:** `owner`. + +**Accounts in:** + +- `market` (mut) +- `user_account` (mut) +- `base_vault`, `quote_vault` (mut, boxed) +- `user_base_account`, `user_quote_account` (mut, boxed — caller's + ATAs; caller must create them before calling) +- `base_mint`, `quote_mint` (boxed, read-only) +- `owner` (signer) +- `token_program` + +**Checks:** none beyond Anchor's account-validation (ownership, +mint checks on token accounts, PDA seeds). + +**Token movements:** ``` -gross = fill_price * fill_quantity -fee = gross * fee_basis_points / 10_000 # rounded down + base_vault --[user_account.unsettled_base of base_mint]--> user_base_account + quote_vault --[user_account.unsettled_quote of quote_mint]--> user_quote_account ``` -- The fee is deducted from the gross quote flowing between the two traders, and transferred to the market's `fee_vault` via one CPI per `place_order` call (aggregated across fills to keep CU cost down). -- In this example the fee is effectively maker-funded (the maker receives `gross − fee`) rather than taker-funded (where the taker would bring extra quote to cover the fee on top of the gross). This keeps the instruction simple — no per-fill CPI from the taker's ATA — and matches how Openbook v2 and Phoenix tend to operate. If you need strictly maker-neutral fees, add a second `transfer_checked` from the taker's ATA to the `fee_vault` for each fill. -- Makers never pay an explicit maker fee in this example. +Both transfers are CPIs to the SPL Token program, signed by the +`Market` PDA using seeds `["market", base_mint, quote_mint, bump]`. + +**State changes:** + +- `user_account.unsettled_base = 0` +- `user_account.unsettled_quote = 0` + +### 4.6 `withdraw_fees` + +**Who calls it:** the market authority (whichever pubkey was set as +`market.authority` at initialisation). -### Remaining accounts +**Signers:** `authority`. -`place_order`'s matching needs to mutate each resting maker's `Order` (to bump `filled_quantity` and flip `status`) and their `UserAccount` (to credit `unsettled_*` and drop filled orders from `open_orders`). Those accounts are passed as `remaining_accounts` in pairs: +**Accounts in:** + +- `market` (mut, `has_one = fee_vault`) +- `fee_vault` (mut, boxed) +- `authority_quote_account` (mut, boxed — destination) +- `quote_mint` (boxed) +- `authority` (signer) +- `token_program` + +**Checks:** + +- `authority.key() == market.authority` → `NotMarketAuthority` +- If `fee_vault.amount == 0`, returns `Ok(())` silently (so this call + is cheap to schedule) + +**Token movements:** ``` -remaining_accounts = [ - maker_1_order, maker_1_user_account, - maker_2_order, maker_2_user_account, - ... -] + fee_vault --[fee_vault.balance of quote_mint]--> authority_quote_account ``` -Ordered the way the book will walk them: lowest-priced ask first for a taker bid, highest-priced bid first for a taker ask. The program re-verifies the pairs against the live order book (rejecting out-of-order or unknown order ids) before applying any fills. +Signed by the Market PDA. + +**State changes:** none on program state (the vault balance drops to +zero as a side effect of the transfer). + +--- + +## 5. The matching engine — step by step + +This is the heart of the program. Everything in `place_order` after +the initial fund lock is matching-engine work. Follow along with +[`place_order.rs`](programs/clob/src/instructions/place_order.rs) and +[`state/matching.rs`](programs/clob/src/state/matching.rs) — it'll +read more easily once you've gone through this section. + +### 5.1 The plan + +1. Caller passes `(side, price, quantity)` and, in remaining_accounts, + the maker pairs to cross against. +2. The handler locks the required funds into the vault (done up + front, before any matching — see §4.3). +3. **Plan the fills** (pure logic, no mutations): walk the opposite + side of the book in price order. For each entry whose price + crosses the taker's limit, record a `Fill { resting_index, + resting_order_id, fill_quantity, fill_price }`. Stop when either + the taker's quantity is exhausted or the next entry fails to + cross. +4. **Apply the fills** (mutate state): for each fill, update the + maker's `Order` (increment `filled_quantity`, flip status), update + the maker's `UserAccount` (credit `unsettled_base` or + `unsettled_quote`), and accumulate deltas for the taker. +5. **Clean the book**: remove fully-filled makers from the relevant + side of `order_book.bids`/`asks`, in reverse-index order. +6. **Pay the fee**: one batched CPI from `quote_vault` to `fee_vault` + for the sum of per-fill fees. +7. **Apply the taker deltas**: single mutation of the taker's + `UserAccount`. +8. **Rest the remainder**: if `taker_remaining > 0`, insert the + new `Order` into the book at the taker's limit price, add its + `order_id` to the taker's `open_orders`, set status to + `PartiallyFilled` (if any fills) or `Open` (if none). + +### 5.2 Why bids spend quote, asks spend base — the full accounting + +Pick a taker **bid** at price `bp` and quantity `bq`, crossing a +resting **ask** at `ap ≤ bp` with remaining quantity `aq`. Let +`fill_qty = min(bq, aq)` and `fill_price = ap` (maker's price wins). + +Per-fill quantities: + +``` +gross = fill_price * fill_qty (quote tokens) +fee = gross * fee_bps / 10_000 (quote tokens) +net_to_maker = gross - fee (quote tokens) +locked = bp * fill_qty (quote tokens the taker had locked for this fill) +rebate = locked - gross (quote the taker locked but doesn't need to spend) +``` + +Token flows: + +``` + quote_vault --[fee]---------> fee_vault (CPI signed by Market PDA, batched across all fills) + + # No physical transfer for the base and net-quote legs — they stay in the + # vaults, accounted for via unsettled_* counters: + + maker.unsettled_quote += net_to_maker (maker collects gross - fee) + taker.unsettled_base += fill_qty (taker gets the base) + taker.unsettled_quote += rebate (price improvement refund) +``` + +The *base* that the taker now owns was already in `base_vault` — +remember, the maker locked it there when placing the ask. The *quote* +that the maker now owns was already in `quote_vault` — the taker +locked `bp * bq` there at the top of this call. Nothing leaves the +vaults except the fee. Everything else gets paid out later, on +`settle_funds`. + +For the opposite direction — a taker **ask** at `ap` crossing a +resting **bid** at `bp ≥ ap`: + +``` +fill_qty = min(taker_remaining, bp_remaining) +fill_price = bp +gross = bp * fill_qty +fee = gross * fee_bps / 10_000 +net_to_taker = gross - fee + +Token flows: + quote_vault --[fee]------> fee_vault + + taker.unsettled_quote += net_to_taker + maker.unsettled_base += fill_qty +``` + +No rebate on this side: the maker's bid locked exactly `bp * +bid_original_qty` of quote up front, and of that, `bp * fill_qty` is +being spent right now at exactly that price — no leftover. + +### 5.3 Worked example — taker bid crosses two resting asks + +Start with an empty book. Fees 10 bps (0.1%). Tick size 1. + +1. Maker Dan posts an ask at price 900, quantity 5. `place_order(Ask, + 900, 5)`. Dan's token account loses 5 base; base_vault gains 5 + base. `order_book.asks = [(id=1, price=900)]`. + +2. Maker Erin posts an ask at price 950, quantity 5. Same mechanism. + `base_vault.balance = 10`. `order_book.asks = [(1, 900), (2, 950)]` + (ascending). + +3. Taker Faye places a bid at 1000 for quantity 7. She passes both + makers as remaining_accounts: `(order_1, dan_user), (order_2, + erin_user)`. + + Step A — lock. Faye's quote ATA loses `1000 * 7 = 7000` quote; + `quote_vault.balance += 7000`. + + Step B — plan: + - Fill 0: resting index 0 (Dan's ask), order_id 1, qty = min(7, + 5) = 5, price = 900. `taker_remaining = 7 - 5 = 2`. + - Fill 1: resting index 1 (Erin's ask), order_id 2, qty = min(2, + 5) = 2, price = 950. `taker_remaining = 0`. + + Step C — apply fills: + + For Fill 0 (Dan): + - gross = 900 * 5 = 4500; fee = 4500 * 10 / 10 000 = 4; + net_to_maker = 4496. + - `dan_user_account.unsettled_quote += 4496` + - `faye_user_account.unsettled_base += 5` + - Faye's rebate = 1000*5 − 4500 = 500. + `faye_user_account.unsettled_quote += 500` + - `dan_order.filled_quantity = 5`, status = Filled, + remove from `dan_user_account.open_orders`. + + For Fill 1 (Erin): + - gross = 950 * 2 = 1900; fee = 1; net_to_maker = 1899. + - `erin_user_account.unsettled_quote += 1899` + - `faye_user_account.unsettled_base += 2` + - Faye's rebate = 1000*2 − 1900 = 100. + `faye_user_account.unsettled_quote += 100` + - `erin_order.filled_quantity = 2`, status = PartiallyFilled + (original 5, filled 2), **stays** in `erin_user_account.open_orders`. + + Step D — clean book. Dan's ask was fully filled → drop index 0. + Erin's ask was only partially filled → stays. `order_book.asks = + [(2, 950)]`. But note: the `OrderEntry` in the book does not track + `filled_quantity`. The book just knows the order_id and price; + the `Order` PDA carries the live remaining quantity. The next + taker who wants to hit Erin's ask will pass `order_2` as a maker, + and `place_order` will read its current `original_quantity - + filled_quantity = 3`. + + Step E — pay the fee. `total_fee_quote = 4 + 1 = 5`. One CPI: + ``` + quote_vault --[5 quote]--> fee_vault + ``` + + Step F — apply Faye's deltas. `faye_user_account.unsettled_base = + 0 + 7 = 7`. `faye_user_account.unsettled_quote = 0 + (500 + 100) = + 600`. + + Step G — rest the remainder. `taker_remaining = 0` → Faye's new + Order is marked `Filled` immediately, not added to the book. + +4. Later, each user calls `settle_funds`: + - Dan's settle: `base_vault` loses 0 base; `quote_vault` loses + 4496 quote → Dan's quote ATA gains 4496. + - Erin's settle: 1899 quote to Erin's ATA. + - Faye's settle: 7 base to Faye's base ATA; 600 quote refund to + Faye's quote ATA (unused from her 7000 lock). + +5. At some point the market authority calls `withdraw_fees`: + `fee_vault.balance = 5` → drained to authority's quote ATA. + +**Post-settlement invariant check**: +- `base_vault.balance` should equal sum of remaining ask quantities = + 3 (Erin's remainder). ✓ +- `quote_vault.balance` should equal sum of resting bids = 0. ✓ + +### 5.4 Partial fill with a remainder + +Same scenario, but Faye bids at 920 (not 1000) and quantity 8. + +- Fill 0: index 0 (Dan, 900), qty 5, price 900. Taker remaining 3. +- Attempt Fill 1: index 1 (Erin, 950). Crossing check: incoming bid at + 920, resting ask at 950 → `920 >= 950` is **false**. Matching + stops. + +After applying Fill 0 and the fee, `taker_remaining = 3 > 0`. The +book-capacity check runs (still fine). Faye's new Order is marked +`PartiallyFilled` (filled 5 of 8) and inserted into `order_book.bids` +at price 920. Her `open_orders` list now includes the new order_id. + +Erin's ask was untouched; the book now looks like: + +``` +asks [(2, 950)] ← Erin, original 5 left +bids [(3, 920)] ← Faye, remaining 3 +``` + +### 5.5 Cancel + settle round trip + +Taker Gael places a bid at 910 for quantity 4 on an empty book (no +maker pairs passed). The bid rests. + +- Step A (lock): `910 * 4 = 3640` quote moved from Gael's ATA to + quote_vault. `order_book.bids = [(4, 910)]`. +- Step B–F: no fills, no fee, no maker mutations. +- Step G: `taker_remaining = 4 = quantity` → status `Open`, added + to the book, `gael_user_account.open_orders = [4]`. + +Gael decides to cancel. `cancel_order` on order_id 4: + +- `remaining_quantity(order) = 4 - 0 = 4`. +- `gael_user_account.unsettled_quote += 910 * 4 = 3640`. +- `order_book.bids` cleared. `gael_user_account.open_orders = []`. +- `order.status = Cancelled`. + +No tokens moved — `quote_vault.balance` still holds the 3640. + +Gael calls `settle_funds`: + +- `quote_vault --[3640 quote]--> gael_user_quote_account` +- `gael_user_account.unsettled_quote = 0`. + +Net effect: Gael's balance sheet is exactly where it started; the +program earned nothing (no fill means no fee). + +--- + +## 6. Full-lifecycle worked examples + +Three scenarios with end-to-end numbers. Both mints are 6-decimal SPL +tokens. 1 BASE = 1 000 000 base units; 1 QUOTE = 1 000 000 quote +units. Where a number in the narrative looks like "price 900", read +that as "900 quote units per 1 base unit" (so for a 1-full-BASE trade +you'd move 900 * 1 000 000 quote units). -## Build +Market configuration: +- `fee_basis_points = 50` (0.5%) +- `tick_size = 1` +- `min_order_size = 1` +- `base_vault`, `quote_vault`, `fee_vault` all start empty. -```shell +### 6.1 A clean match: taker bid consumes a resting ask + +Cast: **Maria** (market authority + Alice/Bob's broker), **Alice** +(seller), **Bob** (buyer). + +1. `initialize_market` — Maria runs it. Rent for five accounts comes + out of her wallet. Market is now `is_active`. +2. `create_user_account` — Alice and Bob each run it once. +3. Alice posts an ask: `place_order(Ask, 1000, 5)`, no + remaining_accounts (empty book). + - Lock: `alice_base_account --[5 base]--> base_vault`. + - Plan: nothing to cross. + - Rest: new Order PDA with `original_quantity = 5`, status `Open`, + added to `order_book.asks` at index 0. `alice.open_orders = [1]`. +4. Bob posts a bid: `place_order(Bid, 1000, 5)`, with Alice's Order + and UserAccount as remaining_accounts. + - Lock: `bob_quote_account --[5 * 1000 = 5000 quote]--> + quote_vault`. + - Plan: one fill at (resting_index 0, order_id 1, qty 5, price + 1000). + - Apply: + - gross = 5000, fee = 5000 * 50 / 10 000 = 25, net_to_maker = + 4975. + - `alice.unsettled_quote += 4975` + - `bob.unsettled_base += 5` + - Bob's rebate = 0 (he bid at the resting price exactly). + - Alice's Order: filled 5, status Filled. Removed from + `alice.open_orders`. + - Clean book: drop index 0. `order_book.asks = []`. + - Fee CPI: `quote_vault --[25 quote]--> fee_vault`. + - Apply Bob's deltas. + - Rest remainder: `taker_remaining = 0`, so Bob's new Order is + marked Filled immediately, not booked. + +**Balances at this point (in vault land):** +- `base_vault`: 5 base (waiting for Bob's settle). +- `quote_vault`: 4975 quote (waiting for Alice's settle). The other + 25 is now in fee_vault. +- `alice.unsettled_quote = 4975`, `alice.unsettled_base = 0`. +- `bob.unsettled_base = 5`, `bob.unsettled_quote = 0`. + +5. Alice calls `settle_funds`: + ``` + quote_vault --[4975 quote]--> alice_quote_account + ``` + `alice.unsettled_quote = 0`. + +6. Bob calls `settle_funds`: + ``` + base_vault --[5 base]--> bob_base_account + ``` + `bob.unsettled_base = 0`. + +7. Maria calls `withdraw_fees`: + ``` + fee_vault --[25 quote]--> maria_quote_account + ``` + +**Final balance sheet (deltas from start):** +- Alice: −5 base, +4975 quote. +- Bob: +5 base, −5000 quote. +- Maria: +25 quote (minus whatever lamports she spent on rent for + accounts). +- All three vaults empty. + +### 6.2 Partial fill with remainder on the book + +Cast: Alice (ask maker), Bob (bid maker, then remainder rests), Carol +(new taker). + +1. `initialize_market` by Maria (same config). +2. `create_user_account` × 3. +3. Alice posts `Ask, 1000, 3`. Locks 3 base. +4. Bob posts `Bid, 1100, 10` with Alice's pair as a maker. + - Lock: `10 * 1100 = 11_000 quote` from Bob to quote_vault. + - Plan one fill: qty = min(10, 3) = 3, price = 1000. + - gross = 3000, fee = 15, net_to_maker = 2985. + - `alice.unsettled_quote += 2985` + - `bob.unsettled_base += 3` + - Rebate: `1100*3 − 3000 = 300` → `bob.unsettled_quote += 300`. + - Alice's order fully filled. + - Clean book: drop Alice's ask. `asks = []`. + - Fee CPI: 15 quote to fee_vault. + - `taker_remaining = 10 − 3 = 7`. Capacity OK. Bob's new Order + marked PartiallyFilled (filled 3 of 10), added to + `order_book.bids` at price 1100. `bob.open_orders = [2]`. + + Book state now: `asks=[], bids=[(2, 1100)]`. `quote_vault` holds + the locked portion for Bob's remainder: + `11000 − (3000 + 300 + 2985) = 4715`? Let's double-check: 2985 is + *inside* quote_vault (alice's unsettled). 300 is *inside* + quote_vault (bob's rebate unsettled). 15 went to fee_vault. 3000 + minus fee = 2985 net_to_maker sits in quote_vault waiting for + Alice's settle. So `quote_vault.balance = 11000 − 15 = 10985`, + composed of: alice.unsettled_quote (2985) + bob.unsettled_quote + (300) + bob's remaining lock for the resting bid (1100 * 7 = + 7700). 2985 + 300 + 7700 = 10 985. ✓ + +5. Alice settles: `quote_vault --[2985]--> alice_quote_account`. + `quote_vault = 10985 − 2985 = 8000` (= 7700 Bob-lock + 300 + Bob-rebate). +6. Carol posts `Ask, 1100, 4` with Bob's Order/UserAccount as a + maker pair. + - Lock: 4 base from Carol to base_vault. + - Plan: fill at (index 0, order_id 2, qty min(4, 7) = 4, price + 1100). + - gross = 4400, fee = 22, net_to_taker = 4378. + - `carol.unsettled_quote += 4378` + - `bob.unsettled_base += 4` (he's the maker-bid; base flows to + the bid side) + - No rebate on ask-taker side. + - Bob's order: filled_quantity 3 → 7, status PartiallyFilled + (still not fully filled — original 10, filled 7). + - Clean book: Bob's book remaining = 10 − 7 = 3 > 0, so his + entry stays. `order_book.bids = [(2, 1100)]`. + - Fee CPI: 22 quote → fee_vault. + - `taker_remaining = 0` → Carol's new Order marked Filled. + + Mid-state: `base_vault = 0 + 4 = 4` (from Carol's lock; was 0 + after Bob's settle made it flow — wait, no: Bob's base never + settled yet. Let's re-check:) + + After step 4 Bob's `unsettled_base = 3` (from the 3-base fill + against Alice). `base_vault.balance = 3 + 0 = 3` (Alice's + original lock after the fill; asks had drained out with the + match). After step 6, Carol added 4 base and 4 went to Bob as + unsettled. So `base_vault.balance = 3 + 4 = 7`. `bob.unsettled_base + = 3 + 4 = 7`. + +### 6.3 Cancel round-trip + +Cast: Alice (bid maker), nobody else. + +1. `initialize_market`, `create_user_account(Alice)`. +2. Alice posts `Bid, 900, 10` — rests on an empty book. + - Lock: 9000 quote from Alice to quote_vault. + - No fills. `alice.open_orders = [1]`. `bids = [(1, 900)]`. +3. Alice reconsiders and calls `cancel_order` on her bid. + - `remaining_quantity = 10 − 0 = 10`. + - `alice.unsettled_quote += 900 * 10 = 9000`. + - `bids = []`, `alice.open_orders = []`. + - `order.status = Cancelled`. +4. Alice calls `settle_funds`: + ``` + quote_vault --[9000 quote]--> alice_quote_account + ``` + `alice.unsettled_quote = 0`. + +Net delta: Alice is exactly where she started. The vaults are empty. +The Order account is still on chain in `Cancelled` state (one could +imagine a future instruction to reclaim its rent — see §9). + +--- + +## 7. Safety and edge cases + +### 7.1 What the program refuses to do + +From [`errors.rs`](programs/clob/src/errors.rs): + +| Error | When | +|---|---| +| `InvalidPrice` | `place_order` called with `price == 0` | +| `InvalidQuantity` | Reserved (not currently triggered by the handlers) | +| `OrderNotFound` | `cancel_order` failed to locate the order in the book (sanity path) | +| `MarketPaused` | `place_order` on a market with `is_active = false` (no instruction flips this today, but the field is there) | +| `Unauthorized` | `cancel_order` by someone other than the order owner | +| `OrderBookFull` | `place_order` remainder would push the book past `200` total entries | +| `TooManyOpenOrders` | User already has 20 open orders on this market | +| `InvalidTickSize` | `tick_size == 0` at init, or `price % tick_size != 0` on place | +| `BelowMinOrderSize` | `min_order_size == 0` at init, or `quantity < min_order_size` on place | +| `OrderNotCancellable` | `cancel_order` on a Filled or Cancelled order | +| `NumericalOverflow` | Any checked arithmetic returned `None` | +| `InvalidFeeBasisPoints` | `fee_basis_points > 10_000` at init | +| `InvalidFeeVault` | `market.fee_vault` on the struct does not match the passed `fee_vault` (Anchor `has_one`) | +| `MakerAccountMismatch` | Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order | +| `MissingMakerAccounts` | `remaining_accounts.len()` not a multiple of 2 | +| `MakerOwnerMismatch` | Maker Order and UserAccount have different owners | +| `NotMarketAuthority` | `withdraw_fees` called by wrong signer | + +### 7.2 Guarded design choices worth knowing + +- **Full lock on place.** The handler always moves the full locked + amount into the vault before matching. This keeps the + vault-balance invariant simple and makes `cancel_order` / partial + fills straightforward: the vault already has everything it could + owe. + +- **Caller supplies maker pairs.** The matching engine does not + iterate the whole book looking for counterparties — the caller + tells it which resting orders to cross. This is what Openbook v2 + does and it's the only way to fit the matching work within a + transaction's account budget when the book is large. The cost is + that an off-book client needs to read the `OrderBook` account + first, pick the crossings, and pass the right accounts. The + program still enforces order (price-time priority) and ownership + on what the caller passes, so a malicious caller cannot cross a + non-top-of-book maker to hurt someone else — they can only *fail + to cross* orders they should have crossed, which only hurts + themselves. + +- **Matching applies at the maker's price, not the taker's.** The + fill price is always the resting order's price. Takers that cross + deeper into the book get price improvement, refunded to + `unsettled_quote` (for taker bids). This is the standard CLOB + rule. + +- **Fees come out of the gross.** The maker receives `gross - fee`, + not `gross`; the fee lives on for a while in `quote_vault` before + being moved to `fee_vault` in one batched CPI at the end of + `place_order`. An alternative model — the taker paying `gross + + fee` on top of the lock — is discussed in a comment in + `place_order.rs` and left as an exercise. + +- **Unsettled balances are pure accounting.** No token physically + moves to or from a user during matching or cancellation. Both + events just bump `unsettled_*` counters. The user collects by + calling `settle_funds`. This means one `place_order` call that + crosses many makers only costs one token CPI (the fee move), not + one-per-fill. Large orders stay within the CU budget. + +- **`settle_funds` no-ops on zero.** Both legs are guarded by `if + base_amount > 0` / `if quote_amount > 0`. Safe to schedule on a + cron or heartbeat. + +- **`withdraw_fees` no-ops on empty.** Likewise. + +- **Boxed InterfaceAccounts.** Several handlers use `Box< + InterfaceAccount<...>>` for mint/token accounts. That's a BPF + stack-size workaround — each `InterfaceAccount` is ~1 KB on the + stack and the Solana VM gives handlers a tight budget. Don't + unbox these without testing the compute output size. + +- **Discriminator + `has_one`.** Every state account carries an 8- + byte discriminator that Anchor checks. `Market` has + `has_one = fee_vault`, so the `place_order` handler can trust the + `fee_vault` account without re-checking its mint or authority. + +- **Book capacity check after matching.** The taker's remainder + check happens at the end. A bid that clears enough asks to free + up 3 slots can then rest its own 1-slot remainder even on a + previously-full book — matching the "liquidity-positive" spirit + of a CLOB. + +### 7.3 Things this example does *not* do + +A production CLOB would add: + +- **Zero-copy OrderBook.** 100 entries per side deserialised every + call limits both throughput and maximum book size. +- **Cancel-on-expiry / GTC vs IOC vs FOK.** All orders here are + implicitly GTC (good 'til cancelled). +- **Post-only / reject-if-cross.** No way to guarantee your order + will be a maker. +- **Self-trade protection.** Nothing stops a single user from + crossing their own resting order. +- **Rent reclamation for closed orders.** `Order` accounts persist + on chain in `Filled` or `Cancelled` state forever; a real program + would either close them in the same instruction or provide a + `close_order` to reclaim rent later. +- **Partial taker-funded fees.** The fee comes out of the maker's + gross today (see `place_order.rs` comment). If you want + maker-neutral fees, take an additional transfer from the taker's + ATA at match time. +- **Minimum-tick for quantities.** `min_order_size` is a floor, but + there's no "round lot" constraint. +- **Pause / admin / upgrade.** `is_active` exists but no instruction + flips it. +- **Oracle-aware price bands.** A taker bid 10 000× higher than the + best ask will happily sweep the book. + +--- + +## 8. Running the tests + +All tests are LiteSVM Rust integration tests under +[`programs/clob/tests/test_clob.rs`](programs/clob/tests/test_clob.rs). +They load the built `.so` via +`include_bytes!("../../../target/deploy/clob.so")`, so a build must +run first. + +### Prerequisites + +- Anchor 1.0.0 +- Solana CLI (`solana -V`) +- Rust stable (pinned at the repo root) + +### Commands + +From `defi/clob/anchor/`: + +```bash +# 1. Build the .so — target/deploy/clob.so anchor build + +# 2. Run the LiteSVM tests +cargo test --manifest-path programs/clob/Cargo.toml + +# Or equivalently (Anchor.toml scripts.test = "cargo test"): +anchor test --skip-local-validator ``` -## Test +Expected: -```shell -anchor test ``` +running 23 tests +test authority_can_withdraw_fees_after_match ... ok +test cancel_and_settle_bid_refunds_full_quote ... ok +test cancel_ask_credits_unsettled_base ... ok +test cancel_order_rejects_non_owner ... ok +test create_user_account_tracks_market_and_owner ... ok +test fee_vault_receives_exactly_bps_of_taker_gross ... ok +test initialize_market_rejects_oversized_fee ... ok +test initialize_market_rejects_zero_tick_size ... ok +test initialize_market_sets_market_and_order_book ... ok +test place_ask_locks_base_in_vault ... ok +test place_bid_locks_quote_in_vault ... ok +test place_order_rejects_below_min_order_size ... ok +test place_order_rejects_unaligned_tick ... ok +test place_order_rejects_zero_price ... ok +test resting_orders_at_same_price_fill_by_time_priority ... ok +test settle_funds_after_match_pays_out_both_unsettled_balances ... ok +test settle_funds_moves_unsettled_base_to_user ... ok +test taker_ask_fully_crosses_best_bid ... ok +test taker_bid_fully_crosses_best_ask ... ok +test taker_bid_gets_price_improvement_from_resting_ask ... ok +test taker_crosses_multiple_resting_orders_best_price_first ... ok +test taker_partially_filled_remainder_rests_on_book ... ok +test taker_partially_fills_resting_order_rest_stays_on_book ... ok +``` + +### What each test exercises + +**Setup / happy path (pre-matching):** + +| Test | Exercises | +|---|---| +| `initialize_market_sets_market_and_order_book` | PDA creation, vault setup, initial field values | +| `create_user_account_tracks_market_and_owner` | Per-user PDA derivation and zero-initialised counters | +| `place_bid_locks_quote_in_vault` | Fund lock on bid | +| `place_ask_locks_base_in_vault` | Fund lock on ask | +| `settle_funds_moves_unsettled_base_to_user` | Vault → user ATA transfer via market PDA signer | + +**Validation:** + +| Test | Exercises | +|---|---| +| `place_order_rejects_zero_price` | `price > 0` | +| `place_order_rejects_unaligned_tick` | `price % tick_size == 0` | +| `place_order_rejects_below_min_order_size` | `quantity >= min_order_size` | +| `cancel_order_rejects_non_owner` | Ownership check on cancel | +| `initialize_market_rejects_zero_tick_size` | Init constraint | +| `initialize_market_rejects_oversized_fee` | `fee_bps <= 10_000` | + +**Cancel + settle flow:** + +| Test | Exercises | +|---|---| +| `cancel_ask_credits_unsettled_base` | Ask cancel → `unsettled_base += remaining` | +| `cancel_and_settle_bid_refunds_full_quote` | Round trip of a Bob-style cancellation | + +**Matching engine:** + +| Test | Exercises | +|---|---| +| `taker_bid_fully_crosses_best_ask` | Full-fill crossing, fee routed correctly | +| `taker_ask_fully_crosses_best_bid` | Symmetric path | +| `taker_partially_fills_resting_order_rest_stays_on_book` | Resting order's `filled_quantity` updated, not removed | +| `taker_partially_filled_remainder_rests_on_book` | Taker's remainder inserted in correct price order | +| `taker_crosses_multiple_resting_orders_best_price_first` | Walks multiple makers in price priority | +| `resting_orders_at_same_price_fill_by_time_priority` | Tie-break at same price is first-in-first-out | +| `taker_bid_gets_price_improvement_from_resting_ask` | Rebate → `unsettled_quote` | +| `fee_vault_receives_exactly_bps_of_taker_gross` | Fee math in a single batched CPI | +| `authority_can_withdraw_fees_after_match` | Fee drain after fills, authority-gated | +| `settle_funds_after_match_pays_out_both_unsettled_balances` | Both legs paid in one call | + +### CI note -Tests are pure Rust, running against [LiteSVM](https://github.com/LiteSVM/litesvm). They live in `programs/clob/tests/test_clob.rs` and include the built `.so` via `include_bytes!`, so a fresh `anchor build` must run first. `anchor test` does this automatically; alternatively run `anchor build && cargo test`. +The repo's `.github/workflows/anchor.yml` runs `anchor build` before +`anchor test` for every changed anchor project. That matters here: +the integration tests include the BPF artefact via `include_bytes!`, +so a stale or missing `.so` would break the tests. CI is already +covered. -## Credit +--- -Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). Matching engine added in a subsequent pass. +## 9. Extending the program + +Ordered by difficulty. + +### Easy + +- **Close-on-terminal `Order`.** After a `place_order` fully fills a + maker, close its `Order` account in the same instruction and + refund rent to the owner. Same for `cancel_order` on an `Open` + order. Saves on-chain storage. + +- **IOC flag.** Add `post_only: bool` and `ioc: bool` parameters. + `ioc` means "match what you can and discard the remainder instead + of resting it". `post_only` means "reject the order if it would + cross". Both are one-line checks around the existing matching + logic. + +- **Self-trade guard.** Reject a fill where `maker_order.owner == + owner.key()`. Alternative: auto-cancel the maker side. + +### Moderate + +- **Taker-funded fees.** Pull the fee from the taker's ATA in a + second transfer at match time, instead of netting it out of the + maker's gross. Preserves strict "maker pays nothing" semantics. + +- **Order expiry.** Add `expires_at: i64` to `Order`. In + `place_order`, skip resting entries whose `expires_at` is past; + add a permissionless `sweep_expired` instruction. + +- **Order-book realloc.** Replace the two `Vec` with a + pair of fixed-length arrays plus a length prefix, so the book can + hold many more orders without paying to realloc. Keeps + serialisation simple; avoids the 10 KB deserialisation cost per + call. + +### Harder + +- **Zero-copy slabs.** Rewrite the order book as a red-black tree in + a zero-copy account. This is what Openbook v2 and Phoenix use in + production. + +- **Event queue.** Mirror Openbook's `EventQueue` — `place_order` + writes "fill" events, and a separate `consume_events` instruction + processes them in batches for the maker side. Makes matching O(1) + in CU cost regardless of the taker's depth. + +- **Market-makers as CPI users.** Formalise the `remaining_accounts` + protocol so a market-making program can call `place_order` on + behalf of its users, pre-computing the crossings off-chain and + rewriting the book in one transaction. + +- **Cross-market swaps.** Chain two `place_order` calls (e.g. + base→USDC then USDC→quote2) with an outer helper that routes + through `unsettled_*` balances without a settle in between. + +--- + +## Code layout + +``` +defi/clob/anchor/ +├── Anchor.toml +├── Cargo.toml +├── README.md (this file) +└── programs/clob/ + ├── Cargo.toml + ├── src/ + │ ├── errors.rs + │ ├── lib.rs #[program] entry points + │ ├── instructions/ + │ │ ├── mod.rs + │ │ ├── initialize_market.rs + │ │ ├── create_user_account.rs + │ │ ├── place_order.rs (matching engine lives here) + │ │ ├── cancel_order.rs + │ │ ├── settle_funds.rs + │ │ └── withdraw_fees.rs + │ └── state/ + │ ├── mod.rs + │ ├── market.rs + │ ├── order.rs + │ ├── order_book.rs + │ ├── user_account.rs + │ └── matching.rs (pure fill-planning logic) + └── tests/ + └── test_clob.rs LiteSVM tests +``` From 883828eb8d9c4ad78654b59fe083fd2f34598ba0 Mon Sep 17 00:00:00 2001 From: "Edward (MM bot)" Date: Tue, 21 Apr 2026 21:38:03 +0000 Subject: [PATCH 5/5] docs(clob): apply Mike's feedback to README and code comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five consistent lessons from earlier reviews, applied to the CLOB program. 1. 'Token' not 'SPL Token' — tokens are the default on Solana, no qualifier is needed unless specifically contrasting with native SOL. Replaced 'SPL Token' / 'SPL token' throughout README, state/market.rs, and tests. In tests, 'classic SPL Token vs Token-2022' becomes 'Classic Token Program vs Token Extensions Program' — more precise and drops the SPL prefix that's noise. 2. Glossary removed. The old section enumerated every Solana term (Account, Lamport, Signer, PDA, Bump, CPI, ...) which duplicates what https://solana.com/docs/terminology already covers. Replaced with a one-line pointer there, plus a short inline 'Terms' block that defines only genuinely CLOB-specific vocabulary (base/quote, tick size, unsettled balance, fee vault, price improvement, remaining accounts). 3. No Ethereum references. Old README described an SPL token as 'Solana's ERC-20 equivalent'. Removed — explain Solana on its own terms. 4. Accurate finance framing. Added a sentence at the top noting that a CLOB is the same matching mechanism every major equity / futures / FX / crypto exchange (NYSE, NASDAQ, LSE, CME, Binance, Coinbase, Openbook, Phoenix) uses. Previously the README framed the program as 'two users who want to swap tokens' — accurate but understated. A CLOB is real finance infrastructure and the README now says so. Renamed the 'Tradfi background' subsection to 'Finance background'. 5. 'Instruction handler' not 'instruction' when referring to the code. An instruction is the call data submitted in a transaction; the instruction handler is the Rust function that processes it. Updated 'the program has six instructions', 'later instructions can validate', 'no instruction flips this', 'close in the same instruction', etc. Phrasing like 'a user calls the place_order instruction' is left alone because it genuinely refers to the call. Additional cleanups bundled in: - 'on-chain' / 'off-chain' → 'onchain' / 'offchain' in README, matching the repo-wide normalisation in commit fa93ce0d. - 'SPL Token program' → 'Token program' in CPI descriptions. - 'SPL token accounts' → 'token accounts' in the vaults section. - Code comment in place_order.rs describes basis points as 'the universal rate convention on every major exchange' instead of the vague 'TradFi and CEXes'. - Code comment in market.rs: 'program instructions can drain it' → 'program instruction handlers can drain it'. - initialize_market.rs: 'Basis-points' → 'Basis points' (punctuation). Section numbers in the README renumbered (2/3→2, 4→3, 5→4, ...) after the Glossary removal; all cross-references updated. Quasar port: NOT included in this change. Quasar's account macros require fixed-layout structs (it does not support Vec fields on #[account] structs — see basics/favorites/quasar/src/state.rs for the explicit call-out). The CLOB's OrderBook is a Vec pair and UserAccount has a Vec open_orders, both of which rely on dynamic insertion and removal. On top of that, place_order iterates through remaining_accounts to deserialize and mutate multiple maker Order / UserAccount PDAs per call — a pattern none of the existing Quasar examples (escrow, token-swap, counter) demonstrate. Porting would need a fresh fixed-capacity OrderBook design and a new cross-account mutation pattern. Left as a follow-up so the terminology and finance framing fixes ship first; a separate PR can tackle the Quasar port once the architectural approach is agreed. Tests: all 23 existing LiteSVM tests in programs/clob/tests/test_clob.rs still pass locally after the sweep. --- defi/clob/anchor/README.md | 392 +++++++----------- .../src/instructions/initialize_market.rs | 2 +- .../clob/src/instructions/place_order.rs | 3 +- .../anchor/programs/clob/src/state/market.rs | 4 +- .../anchor/programs/clob/tests/test_clob.rs | 6 +- 5 files changed, 164 insertions(+), 243 deletions(-) diff --git a/defi/clob/anchor/README.md b/defi/clob/anchor/README.md index 9316c9af5..91ec11006 100644 --- a/defi/clob/anchor/README.md +++ b/defi/clob/anchor/README.md @@ -1,33 +1,42 @@ # CLOB — Central Limit Order Book -An Anchor program that runs a simple **onchain order book** for a single -SPL-token trading pair. Users post buy or sell offers at the prices they -want, the program matches crossing offers, and settles the resulting -token movements. - -This README is a teaching document. If you have never written a Solana -program before and have no background in trading, you are the target -reader. Every term that could be unfamiliar is explained the first time -it appears, and every instruction is walked through step by step with -the exact token movements it causes. - -If you already know what an order book, a limit order and a taker fee -are, skip to [Accounts and PDAs](#3-accounts-and-pdas) or -[Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough). +An Anchor program that runs an **onchain central limit order book** +(CLOB) for a single pair of token mints. Users post buy or sell offers +at the prices they want, the program matches crossing offers in +price-time priority, and settles the resulting token movements. + +A CLOB is the standard piece of financial-market infrastructure: NYSE, +NASDAQ, LSE, and CME all run on one, and every major crypto venue +(Binance, Coinbase, Openbook, Phoenix) implements the same idea. This +program is a simplified, onchain version of that mechanism. Two simple +Solana CLOBs you can read alongside it: +[Openbook v2](https://github.com/openbook-dex/openbook-v2) and +[Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1). Both use +zero-copy slabs for scale; this example uses plain `Vec`s so the +matching-engine logic stays readable. + +This README is a teaching document. Every term specific to the program +— order book, bid/ask, maker/taker, tick size, unsettled balance — is +defined inline when it first appears. Solana-level terminology +(account, PDA, CPI, bump, discriminator) is defined at +. + +If you already know what an order book, a limit order, and a taker fee +are, skip to [Accounts and PDAs](#2-accounts-and-pdas) or +[Instruction lifecycle walkthrough](#3-instruction-lifecycle-walkthrough). --- ## Table of contents 1. [What does this program do?](#1-what-does-this-program-do) -2. [Glossary](#2-glossary) -3. [Accounts and PDAs](#3-accounts-and-pdas) -4. [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) -5. [The matching engine — step by step](#5-the-matching-engine--step-by-step) -6. [Full-lifecycle worked examples](#6-full-lifecycle-worked-examples) -7. [Safety and edge cases](#7-safety-and-edge-cases) -8. [Running the tests](#8-running-the-tests) -9. [Extending the program](#9-extending-the-program) +2. [Accounts and PDAs](#2-accounts-and-pdas) +3. [Instruction lifecycle walkthrough](#3-instruction-lifecycle-walkthrough) +4. [The matching engine — step by step](#4-the-matching-engine--step-by-step) +5. [Full-lifecycle worked examples](#5-full-lifecycle-worked-examples) +6. [Safety and edge cases](#6-safety-and-edge-cases) +7. [Running the tests](#7-running-the-tests) +8. [Extending the program](#8-extending-the-program) --- @@ -35,21 +44,21 @@ are, skip to [Accounts and PDAs](#3-accounts-and-pdas) or Two users want to swap tokens at prices they each picked: -- Alice holds some amount of SPL mint **Q** (the "quote" mint — think of - this as the pricing currency, like USD in "BTC is $60 000") and wants - to obtain some amount of SPL mint **B** (the "base" mint — the asset - being priced), but only if she can get B at 900 Q per unit or lower. -- Bob holds mint **B** and wants Q, but only if he can get at least 950 - Q per unit of B he sells. +- Alice holds some amount of mint **Q** (the *quote* mint — the pricing + unit, like USD in "BTC is $60 000") and wants to obtain some amount + of mint **B** (the *base* mint — the asset being priced), but only + if she can get B at 900 Q per unit or lower. +- Bob holds mint **B** and wants Q, but only if he can get at least + 950 Q per unit of B he sells. -They post their offers — Alice a **bid** (buy offer) at price 900, Bob -an **ask** (sell offer) at price 950 — and wait. Alice's bid sits on -the book. Bob's ask sits on the book. Neither crosses the other, so -nothing happens yet. +They post their offers — Alice a *bid* (a buy offer at a limit price), +Bob an *ask* (a sell offer at a limit price) — and wait. Alice's bid +sits on the book. Bob's ask sits on the book. Neither crosses the +other, so nothing happens yet. Later, Carol shows up holding B and willing to sell at any price ≥ 900. -She posts an ask at 900. Now Alice's bid (900) crosses Carol's new ask -(900). The program: +She posts an ask at 900. Now Alice's bid (900) *crosses* Carol's new +ask (900) — the bid is ≥ the ask. The program: 1. Pairs them up. 2. Takes Carol's B out of Carol's token account, locks it in the @@ -83,39 +92,40 @@ call `settle_funds` to pull their balances out. `quote_vault` (mirror for quote), and `fee_vault` (accumulated taker fees). -### Tradfi background, briefly +### Finance background, briefly -For readers new to trading terms — two quick sentences per concept. -They're optional; everything above already describes the program -mechanically. +For readers new to trading terms — these are the same concepts every +equity, futures, and crypto exchange uses. They're optional; +everything above describes the program mechanically. -- **A limit order** is an instruction to trade an amount of asset at a - specific price or better. A *bid* is a limit order to buy, an *ask* - is a limit order to sell. The "limit" part means: don't trade at a - worse price than the one I named. +- **A limit order** is an order to trade an amount of an asset at a + specific price *or better*. A *bid* is a limit order to buy, an + *ask* is a limit order to sell. The "limit" part means: don't trade + at a worse price than the one I named. -- **An order book** is just the currently-open bids and asks, usually +- **An order book** is the set of currently-open bids and asks, sorted so the best price on each side sits at the top. The "top of book" on the bid side is the highest-priced buy offer; the top of book on the ask side is the lowest-priced sell offer. -- **A maker** is whoever posts an order that doesn't immediately match - — they "make" liquidity by leaving their offer on the book for - others to trade against. A **taker** is whoever walks into the book - and hits the resting orders — they "take" liquidity. +- **A maker** is whoever posts an order that doesn't immediately + match — they "make" liquidity by leaving their offer on the book + for others to trade against. A **taker** is whoever walks into the + book and hits the resting orders — they "take" liquidity. -- **A taker fee** is a small cut of each trade that the market - operator takes from the taker's leg of the trade. Expressed in basis - points (see glossary), so 50 bps = 0.5%. +- **A taker fee** is a cut of each trade taken by the venue from the + taker's leg of the trade, expressed in *basis points* (bps). One + bps is 0.01%; 10 000 bps is 100%. A 50 bps fee is 0.5%. -- **Price-time priority** is the universal ordering rule: at the same - price level, whoever posted first fills first. +- **Price-time priority** is the universal matching rule on every + limit order book: best price first, and at the same price level, + whoever posted first fills first. - **Settlement** is the step that actually moves tokens out of the - custody account and back to the user. This program splits matching - and settlement into two instructions (`place_order` + `settle_funds`) - so a taker crossing a long list of orders doesn't have to pay for a - token CPI per maker. + venue's custody account and back to the user. This program splits + matching and settlement into two instruction handlers (`place_order` + and `settle_funds`) so a taker crossing a long list of makers + doesn't have to pay for a token CPI per maker. ### What this example is not @@ -128,148 +138,57 @@ mechanically. can and rests the rest. - **No circuit breakers, no oracles, no price bands.** ---- - -## 2. Glossary - -Terms used below, defined in terms of what they are mechanically. - -**Account** -: On Solana, every piece of state lives in an *account* — a 32-byte -address, some lamports keeping it rent-exempt, an owner program, and -a byte buffer. Wallets, token balances, and program config are all -accounts. - -**Lamport** -: The smallest unit of SOL. 1 SOL = 10⁹ lamports. - -**Signer** -: An account whose private key signed the transaction. A signer is the -only thing that can authorise transfers out of an account it owns. - -**SPL token** -: Solana's ERC-20 equivalent. An SPL *mint* describes a token; each -user's balance lives in a separate *token account* owned by the SPL -Token program. - -**Token account** -: An account holding a balance of one mint, with an *authority* pubkey -that can move tokens out. Authorities are usually user wallets but can -be PDAs — in this program the Market PDA is the authority of all -three vaults. - -**ATA (Associated Token Account)** -: The conventional, deterministic token account for a `(wallet, mint)` -pair. "Sending USDC to someone's wallet" really means sending to their -USDC ATA. - -**PDA (Program Derived Address)** -: A deterministic address derived from a list of byte "seeds" plus a -program id. PDAs have no private key. A program *signs* as a PDA by -re-supplying the seeds during a CPI. This program creates four kinds -of PDA: `Market`, `OrderBook`, `Order`, `UserAccount`. The vaults are -regular token accounts (not PDAs) whose authority is set to the -`Market` PDA. - -**Seeds** -: Bytes that, with the program id, derive a PDA. This program's seeds: - - Market ["market", base_mint, quote_mint] - OrderBook ["order_book", market] - Order ["order", market, order_id.to_le_bytes()] - UserAccount ["user", market, owner] - -**Bump** -: The byte offset that makes `find_program_address` produce an -off-curve address. Stored on each PDA struct so the program doesn't -recompute it every time it signs. - -**CPI (Cross-Program Invocation)** -: One program calling another inside the same transaction. This -program's CPIs go to the SPL Token program's `TransferChecked`. - -**Discriminator** -: First 8 bytes of each Anchor account — the first 8 bytes of -`sha256("account:")`. Anchor writes it at initialisation -and rejects any deserialisation where the prefix doesn't match. - -**Basis point (bps)** -: 1/100 of a percent. 10 000 bps = 100%. The program's fee rate is -expressed in bps (`fee_basis_points: u16`). - -**Base asset, quote asset** -: Two words for the two sides of a pair. In "BASE/QUOTE", the base is -the asset being priced and the quote is the pricing unit. Bids spend -quote and receive base; asks spend base and receive quote. - -**Bid, ask** -: A bid is a buy order (sits on the bid side of the book). An ask is a -sell order. On this program they're the two variants of `OrderSide`. - -**Limit price** -: The worst price at which an order is allowed to trade — for a bid, -the *highest* the taker is willing to pay; for an ask, the *lowest* -the seller is willing to accept. A bid at 900 will not fill against -an ask at 950; it rests at 900 until a seller drops their price. - -**Tick size** -: Smallest allowable price increment. A market with `tick_size = 10` -accepts prices 10, 20, 30, … but rejects 15. Stops the book filling -up with 1-unit-apart orders. - -**Minimum order size** -: Smallest allowable `quantity` for any order. Keeps dust orders from -polluting the book. - -**Maker, taker** -: A maker is whoever posted the resting order that gets hit. A taker -is whoever walked in and hit it. The same person is sometimes both in -one call to `place_order`: they fill some quantity as a taker and -rest the rest as a maker for the next person. - -**Match, fill, cross** -: Two orders *cross* when the bid's price is ≥ the ask's price. They -*match* (are paired up) and a *fill* is the result — one crossing -event, with a fill quantity and a fill price. A single `place_order` -call can produce many fills if the taker quantity eats through -several resting orders. - -**Price improvement** -: When a taker's limit is better than the best resting price on the -other side, the fill happens at the resting price (maker's price -wins). The taker got a better deal than they asked for — the -difference is *price improvement*. In this program that's reflected -by refunding the difference to the taker's `unsettled_quote`. - -**Unsettled balance** -: Two `u64` counters on each `UserAccount`: `unsettled_base` and -`unsettled_quote`. Fills, price-improvement rebates, and order -cancellations all increase these counters. The physical tokens still -sit in the market's vaults. `settle_funds` moves them to the user's -own token accounts and zeroes the counters. - -**Fee vault** -: A separate SPL token account (quote mint) owned by the Market PDA. -Every taker fee — `gross * fee_bps / 10_000` per fill — moves here in -one batched CPI at the end of `place_order`. - -**Price-time priority** -: Best price first; at the same price, earliest-posted first. Here -price priority is enforced by keeping `bids` sorted descending and -`asks` sorted ascending, and time priority falls out of insertion -order at each level (new orders at an existing price go to the end of -that price's run). - -**Remaining accounts** -: Solana lets the caller pass a tail of extra `AccountInfo`s beyond -the ones named in `#[derive(Accounts)]`. This program uses them for -maker orders: for each resting order the taker wants to cross, the -caller supplies `(maker_order_pda, maker_user_account_pda)` in the -book's price-time order. The handler walks them in pairs. +Solana terminology (account, PDA, CPI, bump, discriminator, signer, +lamport, ATA) is defined at . +Terms specific to this program are explained inline when they first +appear; a few extra definitions appear below where useful. + +**Base asset, quote asset.** In "BASE/QUOTE", the base is the asset +being priced and the quote is the pricing unit. Bids spend quote and +receive base; asks spend base and receive quote. + +**Limit price.** The worst price at which an order is allowed to +trade — for a bid, the *highest* the buyer will pay; for an ask, the +*lowest* the seller will accept. A bid at 900 won't fill against an +ask at 950. + +**Tick size.** Smallest allowable price increment. A market with +`tick_size = 10` accepts prices 10, 20, 30, …, but rejects 15. Stops +the book filling up with 1-unit-apart orders. + +**Minimum order size.** Smallest allowable `quantity` on any order. +Keeps dust orders from polluting the book. + +**Match / fill / cross.** Two orders *cross* when the bid's price is +≥ the ask's price; they *match* (are paired up) and a *fill* is the +result — one crossing event with a fill quantity and a fill price. +One call to `place_order` can produce many fills. + +**Price improvement.** When a taker's limit is better than the best +resting price on the opposite side, the fill happens at the resting +(maker's) price. The taker gets a better deal than they named; the +difference is refunded to the taker's `unsettled_quote`. + +**Unsettled balance.** Two `u64` counters on each `UserAccount`: +`unsettled_base` and `unsettled_quote`. Fills, price-improvement +rebates, and cancellations all increase these counters. The physical +tokens still sit in the market's vaults. `settle_funds` moves them +to the user's own token accounts and zeroes the counters. + +**Fee vault.** A separate token account (quote mint) owned by the +Market PDA. Every taker fee — `gross * fee_bps / 10_000` per fill — +moves here in one batched CPI at the end of `place_order`. + +**Remaining accounts.** Solana lets the caller pass a tail of extra +`AccountInfo`s beyond the ones named in `#[derive(Accounts)]`. The +`place_order` handler uses them for the resting orders the taker +wants to cross: for each one, the caller supplies +`(maker_order_pda, maker_user_account_pda)` in the book's price-time +order. --- -## 3. Accounts and PDAs +## 2. Accounts and PDAs ### State / data accounts @@ -280,7 +199,7 @@ book's price-time order. The handler walks them in pairs. | `Order` | yes | `["order", market, order_id.to_le_bytes()]` | program | owner, side, price, original_quantity, filled_quantity, status, timestamp | | `UserAccount` | yes | `["user", market, owner]` | program | `unsettled_base`, `unsettled_quote`, `open_orders: Vec` (max 20) | -### Token accounts (owned by SPL Token, authority = Market PDA) +### Token accounts (owned by the Token Program, authority = Market PDA) | Account | PDA? | Authority | Mint | Holds | |---|---|---|---|---| @@ -371,9 +290,10 @@ this directly (`settle_funds_after_match_pays_out_both_unsettled_balances`). --- -## 4. Instruction lifecycle walkthrough +## 3. Instruction lifecycle walkthrough -The program has six instructions. The order a user encounters them is: +The program has six instruction handlers. The order a user encounters +them is: 1. `initialize_market` (market operator — once) 2. `create_user_account` (every user, once per market) @@ -392,7 +312,7 @@ Token flow shorthand: --[amount of ]--> ``` -### 4.1 `initialize_market` +### 3.1 `initialize_market` **Who calls it:** the market operator. They create a new trading pair. @@ -433,12 +353,12 @@ the supplied parameters plus all the derived fields (`market.authority`, the vault pubkeys, `is_active = true`, `next_order_id = 1`). -The vaults are regular SPL token accounts, *not* PDAs — their +The vaults are regular token accounts, *not* PDAs — their addresses are chosen by the caller (typically fresh keypairs) and -captured on the market's state so later instructions can validate -them. +captured on the market's state so later instruction handlers can +validate them. -### 4.2 `create_user_account` +### 3.2 `create_user_account` **Who calls it:** every user, exactly once per market they want to trade on. @@ -457,7 +377,7 @@ trade on. **State changes:** new `UserAccount` with all counters zero and no open orders. -### 4.3 `place_order` +### 3.3 `place_order` **Who calls it:** anyone with a `UserAccount` for this market. @@ -553,7 +473,7 @@ always holds *exactly* what's needed to fulfil every open position plus every unsettled balance. **Token movements (during matching, per fill):** see -[§5. The matching engine — step by step](#5-the-matching-engine--step-by-step). +[§4. The matching engine — step by step](#4-the-matching-engine--step-by-step). Summary: - For a taker bid crossing a resting ask at price `p`: @@ -611,7 +531,7 @@ On the caller's new `order`: - `status = Filled` if taker fully matched; otherwise `PartiallyFilled` (if some fills) or `Open` (if no fills) -### 4.4 `cancel_order` +### 3.4 `cancel_order` **Who calls it:** the order's owner. @@ -647,7 +567,7 @@ On the caller's new `order`: The actual token move happens on the next `settle_funds` call. -### 4.5 `settle_funds` +### 3.5 `settle_funds` **Who calls it:** any user. No-op when both unsettled counters are zero, so it is safe to call on a heartbeat/cron. @@ -675,7 +595,7 @@ mint checks on token accounts, PDA seeds). quote_vault --[user_account.unsettled_quote of quote_mint]--> user_quote_account ``` -Both transfers are CPIs to the SPL Token program, signed by the +Both transfers are CPIs to the Token program, signed by the `Market` PDA using seeds `["market", base_mint, quote_mint, bump]`. **State changes:** @@ -683,7 +603,7 @@ Both transfers are CPIs to the SPL Token program, signed by the - `user_account.unsettled_base = 0` - `user_account.unsettled_quote = 0` -### 4.6 `withdraw_fees` +### 3.6 `withdraw_fees` **Who calls it:** the market authority (whichever pubkey was set as `market.authority` at initialisation). @@ -718,7 +638,7 @@ zero as a side effect of the transfer). --- -## 5. The matching engine — step by step +## 4. The matching engine — step by step This is the heart of the program. Everything in `place_order` after the initial fund lock is matching-engine work. Follow along with @@ -726,12 +646,12 @@ the initial fund lock is matching-engine work. Follow along with [`state/matching.rs`](programs/clob/src/state/matching.rs) — it'll read more easily once you've gone through this section. -### 5.1 The plan +### 4.1 The plan 1. Caller passes `(side, price, quantity)` and, in remaining_accounts, the maker pairs to cross against. 2. The handler locks the required funds into the vault (done up - front, before any matching — see §4.3). + front, before any matching — see §3.3). 3. **Plan the fills** (pure logic, no mutations): walk the opposite side of the book in price order. For each entry whose price crosses the taker's limit, record a `Fill { resting_index, @@ -753,7 +673,7 @@ read more easily once you've gone through this section. `order_id` to the taker's `open_orders`, set status to `PartiallyFilled` (if any fills) or `Open` (if none). -### 5.2 Why bids spend quote, asks spend base — the full accounting +### 4.2 Why bids spend quote, asks spend base — the full accounting Pick a taker **bid** at price `bp` and quantity `bq`, crossing a resting **ask** at `ap ≤ bp` with remaining quantity `aq`. Let @@ -810,7 +730,7 @@ No rebate on this side: the maker's bid locked exactly `bp * bid_original_qty` of quote up front, and of that, `bp * fill_qty` is being spent right now at exactly that price — no leftover. -### 5.3 Worked example — taker bid crosses two resting asks +### 4.3 Worked example — taker bid crosses two resting asks Start with an empty book. Fees 10 bps (0.1%). Tick size 1. @@ -892,7 +812,7 @@ Start with an empty book. Fees 10 bps (0.1%). Tick size 1. 3 (Erin's remainder). ✓ - `quote_vault.balance` should equal sum of resting bids = 0. ✓ -### 5.4 Partial fill with a remainder +### 4.4 Partial fill with a remainder Same scenario, but Faye bids at 920 (not 1000) and quantity 8. @@ -913,7 +833,7 @@ asks [(2, 950)] ← Erin, original 5 left bids [(3, 920)] ← Faye, remaining 3 ``` -### 5.5 Cancel + settle round trip +### 4.5 Cancel + settle round trip Taker Gael places a bid at 910 for quantity 4 on an empty book (no maker pairs passed). The bid rests. @@ -943,7 +863,7 @@ program earned nothing (no fill means no fee). --- -## 6. Full-lifecycle worked examples +## 5. Full-lifecycle worked examples Three scenarios with end-to-end numbers. Both mints are 6-decimal SPL tokens. 1 BASE = 1 000 000 base units; 1 QUOTE = 1 000 000 quote @@ -957,7 +877,7 @@ Market configuration: - `min_order_size = 1` - `base_vault`, `quote_vault`, `fee_vault` all start empty. -### 6.1 A clean match: taker bid consumes a resting ask +### 5.1 A clean match: taker bid consumes a resting ask Cast: **Maria** (market authority + Alice/Bob's broker), **Alice** (seller), **Bob** (buyer). @@ -1022,7 +942,7 @@ Cast: **Maria** (market authority + Alice/Bob's broker), **Alice** accounts). - All three vaults empty. -### 6.2 Partial fill with remainder on the book +### 5.2 Partial fill with remainder on the book Cast: Alice (ask maker), Bob (bid maker, then remainder rests), Carol (new taker). @@ -1086,7 +1006,7 @@ Cast: Alice (ask maker), Bob (bid maker, then remainder rests), Carol unsettled. So `base_vault.balance = 3 + 4 = 7`. `bob.unsettled_base = 3 + 4 = 7`. -### 6.3 Cancel round-trip +### 5.3 Cancel round-trip Cast: Alice (bid maker), nobody else. @@ -1106,14 +1026,14 @@ Cast: Alice (bid maker), nobody else. `alice.unsettled_quote = 0`. Net delta: Alice is exactly where she started. The vaults are empty. -The Order account is still on chain in `Cancelled` state (one could -imagine a future instruction to reclaim its rent — see §9). +The Order account is still onchain in `Cancelled` state (one could +imagine a future instruction handler to reclaim its rent — see §8). --- -## 7. Safety and edge cases +## 6. Safety and edge cases -### 7.1 What the program refuses to do +### 6.1 What the program refuses to do From [`errors.rs`](programs/clob/src/errors.rs): @@ -1122,7 +1042,7 @@ From [`errors.rs`](programs/clob/src/errors.rs): | `InvalidPrice` | `place_order` called with `price == 0` | | `InvalidQuantity` | Reserved (not currently triggered by the handlers) | | `OrderNotFound` | `cancel_order` failed to locate the order in the book (sanity path) | -| `MarketPaused` | `place_order` on a market with `is_active = false` (no instruction flips this today, but the field is there) | +| `MarketPaused` | `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there) | | `Unauthorized` | `cancel_order` by someone other than the order owner | | `OrderBookFull` | `place_order` remainder would push the book past `200` total entries | | `TooManyOpenOrders` | User already has 20 open orders on this market | @@ -1137,7 +1057,7 @@ From [`errors.rs`](programs/clob/src/errors.rs): | `MakerOwnerMismatch` | Maker Order and UserAccount have different owners | | `NotMarketAuthority` | `withdraw_fees` called by wrong signer | -### 7.2 Guarded design choices worth knowing +### 6.2 Guarded design choices worth knowing - **Full lock on place.** The handler always moves the full locked amount into the vault before matching. This keeps the @@ -1201,7 +1121,7 @@ From [`errors.rs`](programs/clob/src/errors.rs): previously-full book — matching the "liquidity-positive" spirit of a CLOB. -### 7.3 Things this example does *not* do +### 6.3 Things this example does *not* do A production CLOB would add: @@ -1214,8 +1134,8 @@ A production CLOB would add: - **Self-trade protection.** Nothing stops a single user from crossing their own resting order. - **Rent reclamation for closed orders.** `Order` accounts persist - on chain in `Filled` or `Cancelled` state forever; a real program - would either close them in the same instruction or provide a + onchain in `Filled` or `Cancelled` state forever; a real program + would either close them in the same handler or provide a `close_order` to reclaim rent later. - **Partial taker-funded fees.** The fee comes out of the maker's gross today (see `place_order.rs` comment). If you want @@ -1223,14 +1143,14 @@ A production CLOB would add: ATA at match time. - **Minimum-tick for quantities.** `min_order_size` is a floor, but there's no "round lot" constraint. -- **Pause / admin / upgrade.** `is_active` exists but no instruction +- **Pause / admin / upgrade.** `is_active` exists but no handler flips it. - **Oracle-aware price bands.** A taker bid 10 000× higher than the best ask will happily sweep the book. --- -## 8. Running the tests +## 7. Running the tests All tests are LiteSVM Rust integration tests under [`programs/clob/tests/test_clob.rs`](programs/clob/tests/test_clob.rs). @@ -1343,16 +1263,16 @@ covered. --- -## 9. Extending the program +## 8. Extending the program Ordered by difficulty. ### Easy - **Close-on-terminal `Order`.** After a `place_order` fully fills a - maker, close its `Order` account in the same instruction and - refund rent to the owner. Same for `cancel_order` on an `Open` - order. Saves on-chain storage. + maker, close its `Order` account in the same handler and refund + rent to the owner. Same for `cancel_order` on an `Open` order. + Saves onchain storage. - **IOC flag.** Add `post_only: bool` and `ioc: bool` parameters. `ioc` means "match what you can and discard the remainder instead @@ -1392,7 +1312,7 @@ Ordered by difficulty. - **Market-makers as CPI users.** Formalise the `remaining_accounts` protocol so a market-making program can call `place_order` on - behalf of its users, pre-computing the crossings off-chain and + behalf of its users, pre-computing the crossings offchain and rewriting the book in one transaction. - **Cross-market swaps.** Chain two `place_order` calls (e.g. diff --git a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs index f4cfaf9f2..e50f7bbea 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs @@ -4,7 +4,7 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::errors::ErrorCode; use crate::state::{Market, OrderBook, MARKET_SEED, ORDER_BOOK_SEED}; -// Basis-points are hundredths of a percent; 10000 bps == 100%. Fees above 100% +// Basis points are hundredths of a percent; 10000 bps == 100%. Fees above 100% // would be nonsensical, so we cap here. const MAX_FEE_BASIS_POINTS: u16 = 10_000; diff --git a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs index 950d1286a..08e9660e6 100644 --- a/defi/clob/anchor/programs/clob/src/instructions/place_order.rs +++ b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs @@ -14,7 +14,8 @@ use crate::state::{ // PlaceOrder check reads clearly and the limit is documented in one place. const MAX_OPEN_ORDERS_PER_USER: usize = 20; -// Basis-points denominator. 10_000 bps == 100% — standard in TradFi and CEXes. +// Basis-points denominator. 10_000 bps == 100% — the universal rate convention +// on every major exchange (NYSE, CME, Binance, Coinbase, ...). const BASIS_POINTS_DENOMINATOR: u128 = 10_000; // Remaining accounts are passed in groups of 2 per resting order we intend diff --git a/defi/clob/anchor/programs/clob/src/state/market.rs b/defi/clob/anchor/programs/clob/src/state/market.rs index eb9576ba1..42a815049 100644 --- a/defi/clob/anchor/programs/clob/src/state/market.rs +++ b/defi/clob/anchor/programs/clob/src/state/market.rs @@ -21,8 +21,8 @@ pub struct Market { // Dedicated token account (quote mint) that accumulates taker fees. // Kept separate from `quote_vault` so user-owed balances and // market-earned fees cannot be confused. The market PDA signs transfers - // out of it, so only program instructions (notably `withdraw_fees`) can - // drain it. + // out of it, so only program instruction handlers (notably `withdraw_fees`) + // can drain it. pub fee_vault: Pubkey, pub order_book: Pubkey, diff --git a/defi/clob/anchor/programs/clob/tests/test_clob.rs b/defi/clob/anchor/programs/clob/tests/test_clob.rs index b91cfdafa..ed106a120 100644 --- a/defi/clob/anchor/programs/clob/tests/test_clob.rs +++ b/defi/clob/anchor/programs/clob/tests/test_clob.rs @@ -60,9 +60,9 @@ const ASK_PRICE: u64 = 100; const ASK_QUANTITY: u64 = 5; fn token_program_id() -> Pubkey { - // The program accepts either SPL Token or Token-2022 via `TokenInterface`; - // we use classic SPL Token for tests because solana-kite's helpers create - // classic-token mints. + // The program accepts either the Classic Token Program or the Token + // Extensions Program via `TokenInterface`; we use the Classic Token + // Program for tests because solana-kite's helpers create classic mints. "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() .unwrap()