diff --git a/README.md b/README.md index e19d4b286..064ad7b55 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 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) + ### 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..2e0446b07 --- /dev/null +++ b/defi/clob/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/defi/clob/anchor/Anchor.toml b/defi/clob/anchor/Anchor.toml new file mode 100644 index 000000000..aed6d136e --- /dev/null +++ b/defi/clob/anchor/Anchor.toml @@ -0,0 +1,20 @@ +[toolchain] +# 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" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +clob = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# 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/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..91ec11006 --- /dev/null +++ b/defi/clob/anchor/README.md @@ -0,0 +1,1353 @@ +# CLOB — Central Limit Order Book + +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. [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) + +--- + +## 1. What does this program do? + +Two users want to swap tokens at prices they each picked: + +- 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* (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 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 + 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). + +### Finance background, briefly + +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 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 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 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 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 + 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 + +- **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.** + +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. + +--- + +## 2. 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 the Token Program, 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`). + +--- + +## 3. Instruction lifecycle walkthrough + +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) +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 ]--> +``` + +### 3.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 token accounts, *not* PDAs — their +addresses are chosen by the caller (typically fresh keypairs) and +captured on the market's state so later instruction handlers can +validate them. + +### 3.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. + +### 3.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 +[§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`: + ``` + 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) + +### 3.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. + +### 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. + +**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:** + +``` + base_vault --[user_account.unsettled_base of base_mint]--> user_base_account + quote_vault --[user_account.unsettled_quote of quote_mint]--> user_quote_account +``` + +Both transfers are CPIs to the 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` + +### 3.6 `withdraw_fees` + +**Who calls it:** the market authority (whichever pubkey was set as +`market.authority` at initialisation). + +**Signers:** `authority`. + +**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:** + +``` + fee_vault --[fee_vault.balance of quote_mint]--> authority_quote_account +``` + +Signed by the Market PDA. + +**State changes:** none on program state (the vault balance drops to +zero as a side effect of the transfer). + +--- + +## 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 +[`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. + +### 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 §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, + 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). + +### 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 +`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. + +### 4.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. ✓ + +### 4.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 +``` + +### 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. + +- 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). + +--- + +## 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 +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). + +Market configuration: +- `fee_basis_points = 50` (0.5%) +- `tick_size = 1` +- `min_order_size = 1` +- `base_vault`, `quote_vault`, `fee_vault` all start empty. + +### 5.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. + +### 5.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`. + +### 5.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 onchain in `Cancelled` state (one could +imagine a future instruction handler to reclaim its rent — see §8). + +--- + +## 6. Safety and edge cases + +### 6.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 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 | +| `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 | + +### 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 + 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. + +### 6.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 + 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 + 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 handler + flips it. +- **Oracle-aware price bands.** A taker bid 10 000× higher than the + best ask will happily sweep the book. + +--- + +## 7. 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 +``` + +Expected: + +``` +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 + +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. + +--- + +## 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 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 + 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 offchain 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 +``` diff --git a/defi/clob/anchor/programs/clob/Cargo.toml b/defi/clob/anchor/programs/clob/Cargo.toml new file mode 100644 index 000000000..0d1d236fb --- /dev/null +++ b/defi/clob/anchor/programs/clob/Cargo.toml @@ -0,0 +1,36 @@ +[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" + +[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/errors.rs b/defi/clob/anchor/programs/clob/src/errors.rs new file mode 100644 index 000000000..f7bb9cd6d --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/errors.rs @@ -0,0 +1,55 @@ +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, + + #[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/cancel_order.rs b/defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs new file mode 100644 index 000000000..039b5d7b1 --- /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 handle_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 CancelOrder<'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..a46242ced --- /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 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(); + 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 CreateUserAccount<'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..e50f7bbea --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs @@ -0,0 +1,108 @@ +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 handle_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.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; + 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 InitializeMarket<'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>, + + // 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>, + + 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..19fb61b5a --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/mod.rs @@ -0,0 +1,13 @@ +pub mod cancel_order; +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 new file mode 100644 index 000000000..08e9660e6 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/instructions/place_order.rs @@ -0,0 +1,487 @@ +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, 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; + +// 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 +// 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, +) -> 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 + ); + + require!( + context.accounts.user_account.open_orders.len() < MAX_OPEN_ORDERS_PER_USER, + ErrorCode::TooManyOpenOrders + ); + + // Lock up the funds the order would need if filled. Bids lock quote + // (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 => ( + 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, + )?; + + // --------------------------------------------------------------- + // 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 + ); + } + + 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(()) +} + +#[derive(Accounts)] +#[instruction(side: OrderSide, price: u64, quantity: u64)] +pub struct PlaceOrder<'info> { + #[account( + mut, + has_one = fee_vault @ ErrorCode::InvalidFeeVault, + )] + 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 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>, + + #[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>, + + #[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..da271f198 --- /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 handle_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 SettleFunds<'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 PlaceOrder — + // 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/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 new file mode 100644 index 000000000..8749c1d9d --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/lib.rs @@ -0,0 +1,76 @@ +use anchor_lang::prelude::*; + +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, + fee_basis_points: u16, + tick_size: u64, + min_order_size: u64, + ) -> Result<()> { + instructions::initialize_market::handle_initialize_market( + context, + fee_basis_points, + tick_size, + min_order_size, + ) + } + + /// 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, 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, + ) -> Result<()> { + instructions::place_order::handle_place_order(context, side, price, quantity) + } + + /// 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) + } + + /// 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) + } + + /// 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 new file mode 100644 index 000000000..42a815049 --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/market.rs @@ -0,0 +1,39 @@ +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, + + // 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 instruction handlers (notably `withdraw_fees`) + // can drain it. + pub fee_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/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 new file mode 100644 index 000000000..c7a50b46d --- /dev/null +++ b/defi/clob/anchor/programs/clob/src/state/mod.rs @@ -0,0 +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/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/programs/clob/tests/test_clob.rs b/defi/clob/anchor/programs/clob/tests/test_clob.rs new file mode 100644 index 000000000..ed106a120 --- /dev/null +++ b/defi/clob/anchor/programs/clob/tests/test_clob.rs @@ -0,0 +1,1813 @@ +//! 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), 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::{AccountMeta, 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 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() +} + +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, + // 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, + 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(); + let fee_vault = Keypair::new(); + + Scenario { + svm, + program_id, + payer, + authority, + buyer, + seller, + base_mint, + quote_mint, + base_vault, + quote_vault, + fee_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(), + fee_vault: sc.fee_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(), + fee_vault: sc.fee_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), + ) +} + +/// 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, + 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.fee_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.fee_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.fee_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.fee_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.fee_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.fee_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.fee_vault], + &sc.authority.pubkey(), + ); + assert!( + result.is_err(), + "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 + ); +} +