diff --git a/README.md b/README.md index 9bbb1c7c..31e3979c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Constant product AMM (xΒ·y=k) β€” create liquidity pools, deposit and withdraw l [βš“ Anchor](./tokens/token-swap/anchor) [πŸ’« Quasar](./tokens/token-swap/quasar) +### 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/order-book/anchor) + ### Escrow Peer-to-peer OTC trade β€” one user deposits token A and specifies how much token B they want. A counterparty fulfills the offer and both sides receive their tokens atomically. diff --git a/defi/order-book/anchor/.gitignore b/defi/order-book/anchor/.gitignore new file mode 100644 index 00000000..2e0446b0 --- /dev/null +++ b/defi/order-book/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/defi/order-book/anchor/Anchor.toml b/defi/order-book/anchor/Anchor.toml new file mode 100644 index 00000000..3a88b09d --- /dev/null +++ b/defi/order-book/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] +order_book = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# LiteSVM Rust tests live under `programs/order-book/tests/` and include the built +# `.so` via `include_bytes!`, so a fresh `anchor build` must run first. +test = "cargo test" diff --git a/defi/order-book/anchor/Cargo.toml b/defi/order-book/anchor/Cargo.toml new file mode 100644 index 00000000..68da9ddd --- /dev/null +++ b/defi/order-book/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/order-book/anchor/README.md b/defi/order-book/anchor/README.md new file mode 100644 index 00000000..b428aa49 --- /dev/null +++ b/defi/order-book/anchor/README.md @@ -0,0 +1,1438 @@ +# Order Book β€” Central Limit Order Book (CLOB) + +This is an **order book** β€” specifically, a **central limit order +book (CLOB)**, the standard piece of market infrastructure used by +NYSE, NASDAQ, LSE, CME, and every major crypto venue. An Anchor +program that runs an onchain order book 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. + +This program is a simplified, onchain version of that mechanism. Two +production Solana CLOBs to 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 **MarketUser** 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 `MarketUser`: +`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 | +| `MarketUser` | 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/order-book/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. + +### `MarketUser` state + +```rust +pub struct MarketUser { + 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. + +**Why per-(user, market) and not per-user?** A `MarketUser` is keyed +by both the human `owner` and the `market`, not by `owner` alone. +Three reasons: + +1. **Unsettled balances are per-market by definition.** Different + markets use different `base_mint` / `quote_mint` pairs, so the + scalar `unsettled_base` / `unsettled_quote` fields can't be + shared across markets β€” they'd refer to different tokens. + +2. **Open-order indexing is local to one book.** `open_orders` + holds `order_id`s that index into a specific market's + `OrderBook`. Mixing ids from different books would force a + per-entry market discriminator and a wider lookup path. + +3. **Lock-contention isolation.** A user trading on multiple + markets in parallel would otherwise serialise every + `place_order` / `settle_funds` on a single shared account. + Per-(user, market) lets independent markets run independently. + +This matches the standard pattern: Openbook v2 calls it +`OpenOrdersAccount`, Phoenix calls it `Trader`, Serum called it +`OpenOrders`. We named ours `MarketUser` to be explicit about what +it actually scopes to. + +### 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_market_user` (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_market_user` + +**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) +- `market_user` (PDA, **init**, seeds `["user", market, owner]`) +- `system_program` + +**Token movements:** none. + +**State changes:** new `MarketUser` with all counters zero and no +open orders. + +### 3.3 `place_order` + +**Who calls it:** anyone with a `MarketUser` 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()]`) +- `market_user` (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 (MarketUser) +``` + +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 == the maker's slot position on the book + (i.e. caller walked the book sorted by price) β†’ `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`; + every product of two `u64` money values is computed in `u128` + to avoid intermediate overflow and then narrowed back to `u64` + with `try_into` β†’ `NumericalOverflow`. After each per-fill fee + calculation an invariant check enforces `fee_quote <= gross_quote`. + +**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 trading +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 `MarketUser` 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 `MarketUser`: + +- `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 `MarketUser`: + +- `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) +- `market_user` (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 `market_user.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) +- `market_user` (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 --[market_user.unsettled_base of base_mint]--> user_base_account + quote_vault --[market_user.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]`. + +Order of operations is checks-effects-interactions: the +`unsettled_*` counters are zeroed *before* the transfer CPIs, then +the transfers run. Solana CPIs aren't reentrant in the EVM sense, +but zeroing state first means no future token-program extension or +transfer hook can observe stale unsettled balances mid-CPI and +double-withdraw. + +**State changes:** + +- `market_user.unsettled_base = 0` +- `market_user.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/order-book/src/instructions/place_order.rs) and +[`state/matching.rs`](programs/order-book/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 sorted by price (best price first). 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 `MarketUser` (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 + `MarketUser`. +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_market_user.unsettled_quote += 4496` + - `faye_market_user.unsettled_base += 5` + - Faye's rebate = 1000*5 βˆ’ 4500 = 500. + `faye_market_user.unsettled_quote += 500` + - `dan_order.filled_quantity = 5`, status = Filled, + remove from `dan_market_user.open_orders`. + + For Fill 1 (Erin): + - gross = 950 * 2 = 1900; fee = 1; net_to_maker = 1899. + - `erin_market_user.unsettled_quote += 1899` + - `faye_market_user.unsettled_base += 2` + - Faye's rebate = 1000*2 βˆ’ 1900 = 100. + `faye_market_user.unsettled_quote += 100` + - `erin_order.filled_quantity = 2`, status = PartiallyFilled + (original 5, filled 2), **stays** in `erin_market_user.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_market_user.unsettled_base = + 0 + 7 = 7`. `faye_market_user.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_market_user.open_orders = [4]`. + +Gael decides to cancel. `cancel_order` on order_id 4: + +- `remaining_quantity(order) = 4 - 0 = 4`. +- `gael_market_user.unsettled_quote += 910 * 4 = 3640`. +- `order_book.bids` cleared. `gael_market_user.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_market_user.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_market_user` β€” 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 MarketUser 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_market_user` Γ— 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/MarketUser 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_market_user(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/order-book/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 MarketUser 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 + order-book 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 an order book. + +### 6.3 Things this example does *not* do + +A production order book 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/order-book/tests/test_order_book.rs`](programs/order-book/tests/test_order_book.rs). +They load the built `.so` via +`include_bytes!("../../../target/deploy/order_book.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/order-book/anchor/`: + +```bash +# 1. Build the .so β€” target/deploy/order_book.so +anchor build + +# 2. Run the LiteSVM tests +cargo test --manifest-path programs/order-book/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_market_user_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_market_user_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. + +### Why a balanced tree (critbit)? + +The current example stores each side of the book as a `Vec` +sorted by price. That's fine for a teaching example with a few dozen +resting orders. For a production order book it's wrong, and the reason is +worth understanding before the "Zero-copy slabs" bullet below. + +**Tree balancing must be guaranteed, not assumed.** A plain binary +search tree only keeps a roughly-balanced shape when its inputs arrive +in random order. In an order book an attacker chooses the inputs β€” the prices +of their orders β€” so nothing they choose can be allowed to determine +the tree's shape. A *balanced-by-construction* tree (red-black, +critbit / binary radix trie, AVL, …) enforces a bounded shape via +invariants maintained on every insert and delete, regardless of input +order. + +**Concrete attack on a plain BST.** An attacker posts orders at +monotonically increasing prices ($100, $101, $102, $103, …; +"monotonically increasing" just means "always going up"). Each new +price is greater than every previous one, so each new node attaches as +the right child of the previous one. After N such orders the tree has +degenerated into a linked list of length N β€” same worst-case shape as +the unbounded `Vec` we'd be moving away from. Lookups, inserts, and +matches all walk O(N) instead of O(log N). + +**Why this matters on Solana specifically.** Solana transactions have +a ~1.4M compute-unit budget. If `place_order` walks an unbalanced book +and exceeds CU mid-match, the transaction aborts and the placer pays +fees for nothing. Worse, *legitimate users' orders fail to place +because an adversary skewed the tree shape*. A balanced-by-construction +tree bounds every operation at O(log N) regardless of input, so the +attack is structurally impossible. + +**Why critbit specifically.** Critbit (a binary radix trie keyed on +the price bits) is balanced-by-construction in a different way from a +red-black tree: tree depth is bounded by the *bit width of the sort +key* (128 bits here β€” price in the high 64, sequence number in the +low 64), not by insertion order. Inserts and deletes don't need +rotations or recolouring; the trie shape is a deterministic function +of which keys are present. Openbook v2's slab is a critbit, which is +why the upstream port lands on critbit rather than red-black. + +For *this* example the `Vec`-based book is kept on purpose: it makes +the matching logic readable in one sitting. The takeaway is the +property, not the data structure β€” the order book has to stay +balanced no matter what order traders submit prices in, and that +requirement is what makes the matching engine fast and DoS-resistant +in production. + +### Harder + +- **Zero-copy slabs.** Rewrite the order book as a critbit tree in + a zero-copy account, ported from Openbook v2's slab. This is what + Openbook v2 and Phoenix use in production; see the + "Why a balanced tree (critbit)?" subsection above for the + motivation. + +- **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/order-book/anchor/ +β”œβ”€β”€ Anchor.toml +β”œβ”€β”€ Cargo.toml +β”œβ”€β”€ README.md (this file) +└── programs/order-book/ + β”œβ”€β”€ Cargo.toml + β”œβ”€β”€ src/ + β”‚ β”œβ”€β”€ errors.rs + β”‚ β”œβ”€β”€ lib.rs #[program] entry points + β”‚ β”œβ”€β”€ instructions/ + β”‚ β”‚ β”œβ”€β”€ mod.rs + β”‚ β”‚ β”œβ”€β”€ initialize_market.rs + β”‚ β”‚ β”œβ”€β”€ create_market_user.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 + β”‚ β”œβ”€β”€ market_user.rs + β”‚ └── matching.rs (pure fill-planning logic) + └── tests/ + └── test_order_book.rs LiteSVM tests +``` diff --git a/defi/order-book/anchor/TERMINOLOGY.md b/defi/order-book/anchor/TERMINOLOGY.md new file mode 100644 index 00000000..7efce716 --- /dev/null +++ b/defi/order-book/anchor/TERMINOLOGY.md @@ -0,0 +1,79 @@ +# Terminology Rules β€” Order Book + +Project-wide rules for the README and code comments in this crate. Applies +throughout, not just one section. Audit for these before opening / merging +any PR that touches docs or comments. + +## General rule + +> If a word has two meanings within ~1 paragraph of context, use the +> explicit phrase even if it's a few chars longer. + +A wrong or ambiguous statement is worse than no statement. + +## Overloaded terms + +### "balance" β€” most important + +`balance` is ambiguous in an order-book context: it can mean token balance, +account balance, **or** the tree-balancing property (the critbit +slab is balanced-by-construction). Pick one of: + +- βœ… "tree balancing" +- βœ… "balanced tree shape" +- βœ… "keeps the tree shape balanced" +- ❌ "the critbit tree maintains balance" + +For money, use **"token balance"** or **"account balance"** explicitly +whenever it's near a tree discussion. + +### "book" + +Usually fine in context ("order book"). Be careful in mixed sentences β€” +qualify when ambiguity is possible. + +### "order" vs "ordering" + +`order` = trading order. `ordering` = sort order. When a sentence touches +both: + +- βœ… "price-sorted", "sorted by price" +- ❌ "in price order" + +### "settle" + +Be specific: + +- βœ… `settle_funds` instruction +- βœ… "transaction settlement" +- ❌ bare "settle" / "settlement" when the meaning isn't obvious + +### "key" + +Never bare `key`. Use: + +- "address" (a Solana public key used as an address) +- "public key" (a cryptographic key) +- "sort key" (the value the tree is sorted on) + +### "node" + +Qualify: + +- "validator node" (Solana cluster) +- "tree node" / "slab node" (data structure) + +### "position" + +Qualify: + +- "trading position" (financial) +- "slot position" / "array index" (slab / array) + +## Reviewer checklist + +Before approving a doc/comment PR: + +- [ ] No bare "balance" when the critbit tree is in scope. +- [ ] No bare "key", "node", "position", "settle" in ambiguous contexts. +- [ ] "Order" / "ordering" disambiguated when both senses appear nearby. diff --git a/defi/order-book/anchor/programs/order-book/Cargo.toml b/defi/order-book/anchor/programs/order-book/Cargo.toml new file mode 100644 index 00000000..befea625 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "order_book" +version = "0.1.0" +description = "Order book example (CLOB) on Solana" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "order_book" + +[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" +# Used by the ported Openbook slab β€” `bytemuck::Pod` / `Zeroable` on every node +# variant + `min_const_generics` so `[AnyNode; 1024]` can derive Pod without +# hitting bytemuck's default-32 array cap. `static_assertions` keeps the slab +# layout asserts (node size, alignment) compile-time, matching upstream. +bytemuck = { version = "1.18", features = ["derive", "min_const_generics"] } +static_assertions = "1.1" + +[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/order-book/anchor/programs/order-book/src/errors.rs b/defi/order-book/anchor/programs/order-book/src/errors.rs new file mode 100644 index 00000000..acb5c58d --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/errors.rs @@ -0,0 +1,70 @@ +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("MarketUser 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("Base vault does not match the market's base vault")] + InvalidBaseVault, + + #[msg("Quote vault does not match the market's quote vault")] + InvalidQuoteVault, + + #[msg("Base mint does not match the market's base mint")] + InvalidBaseMint, + + #[msg("Quote mint does not match the market's quote mint")] + InvalidQuoteMint, + + #[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 MarketUser owner mismatch")] + MakerOwnerMismatch, + + #[msg("Only the market authority can withdraw fees")] + NotMarketAuthority, + + #[msg("Order book account does not match the market's order book")] + InvalidOrderBook, +} diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs new file mode 100644 index 00000000..b27316a0 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::{ + remaining_quantity, remove_open_order, Market, Order, OrderBook, OrderSide, OrderStatus, + MarketUser, ORDER_SEED, MARKET_USER_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 market_user = &mut context.accounts.market_user; + match order.side { + OrderSide::Bid => { + // u128 intermediate: the lock was originally taken on a + // u64 quote balance, so price * remaining must fit u64 + // β€” but the multiplication itself can transiently exceed + // u64. Mirror the same pattern as place_order: widen, + // multiply, narrow. + let quote_amount: u64 = (order.price as u128) + .checked_mul(remaining as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; + market_user.unsettled_quote = market_user + .unsettled_quote + .checked_add(quote_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + OrderSide::Ask => { + market_user.unsettled_base = market_user + .unsettled_base + .checked_add(remaining) + .ok_or(ErrorCode::NumericalOverflow)?; + } + } + } + + // Remove the leaf from the slab. The current cancel API doesn't tell us + // which side the order is on without reading the Order PDA β€” which we + // already have, so use it. + let mut order_book = context.accounts.order_book.load_mut()?; + let removed = order_book.remove_from(order.side, order.order_id).is_some(); + require!(removed, ErrorCode::OrderNotFound); + drop(order_book); + + let market_user = &mut context.accounts.market_user; + remove_open_order(market_user, order.order_id); + + order.status = OrderStatus::Cancelled; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CancelOrder<'info> { + #[account(has_one = order_book @ ErrorCode::InvalidOrderBook)] + pub market: Account<'info, Market>, + + // Not a PDA (see initialize_market.rs); bound to `market` via has_one. + #[account(mut)] + pub order_book: AccountLoader<'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 = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = market_user.bump + )] + pub market_user: Account<'info, MarketUser>, + + pub owner: Signer<'info>, +} diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs b/defi/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs new file mode 100644 index 00000000..25d28665 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/create_market_user.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +use crate::state::{Market, MarketUser, MARKET_USER_SEED}; + +pub fn handle_create_market_user(context: Context) -> Result<()> { + let market_user = &mut context.accounts.market_user; + market_user.market = context.accounts.market.key(); + market_user.owner = context.accounts.owner.key(); + market_user.unsettled_base = 0; + market_user.unsettled_quote = 0; + market_user.open_orders = Vec::new(); + market_user.bump = context.bumps.market_user; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CreateMarketUser<'info> { + #[account( + init, + payer = owner, + space = MarketUser::DISCRIMINATOR.len() + MarketUser::INIT_SPACE, + seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()], + bump + )] + pub market_user: Account<'info, MarketUser>, + + pub market: Account<'info, Market>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs new file mode 100644 index 00000000..7b83ae9b --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -0,0 +1,117 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::errors::ErrorCode; +use crate::state::{Market, OrderBook, MARKET_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; + + // Zero-copy account: initialize the slab in place. `load_init` is the + // first-write path β€” every subsequent handler uses `load` / `load_mut`. + // The order book is not a PDA (see the comment on the `order_book` + // account below), so `bump` is unused and stored as 0. + let mut order_book = context.accounts.order_book.load_init()?; + order_book.initialize(context.accounts.market.key(), 0); + + 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>, + + // The order book is a zero-copy account (~180 KB: two 1024-slot critbit + // slabs back to back). Solana's BPF runtime caps inner-CPI account + // allocations at 10 KB, so we can't use Anchor's `init` here β€” the + // client must call system_program::create_account directly before this + // instruction, sizing the account to ORDER_BOOK_ACCOUNT_SIZE, owned by + // this program, and zero-initialized. + // + // `#[account(zero)]` verifies the account is owned by this program + // and has its discriminator unset, which is exactly what a freshly + // create_account-d account looks like. The handler then stamps the + // discriminator + struct via `load_init()`. + // + // The account is passed in as a regular Signer (created by the client), + // not a PDA. The README documents the (program_id, MARKET_SEED + market) + // PDA derivation that clients should still use for the account address + // β€” we just have to allocate it ourselves. + #[account(zero)] + pub order_book: AccountLoader<'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/order-book/anchor/programs/order-book/src/instructions/mod.rs b/defi/order-book/anchor/programs/order-book/src/instructions/mod.rs new file mode 100644 index 00000000..0b80b8b3 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/mod.rs @@ -0,0 +1,13 @@ +pub mod cancel_order; +pub mod create_market_user; +pub mod initialize_market; +pub mod place_order; +pub mod settle_funds; +pub mod withdraw_fees; + +pub use cancel_order::*; +pub use create_market_user::*; +pub use initialize_market::*; +pub use place_order::*; +pub use settle_funds::*; +pub use withdraw_fees::*; diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs new file mode 100644 index 00000000..a4e4e029 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs @@ -0,0 +1,486 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::ErrorCode; +use crate::state::{ + add_open_order, plan_fills, remove_open_order, Market, Order, OrderBook, OrderSide, + OrderStatus, MarketUser, MARKET_SEED, ORDER_SEED, MARKET_USER_SEED, +}; + +// Mirror of MarketUser.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_market_user]. 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.market_user.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. + // + // The bid lock multiplies two u64s. A plain `u64::checked_mul` would + // refuse anything that overflows u64 (~1.8e19) β€” which is a perfectly + // legal lock once you scale by token decimals (e.g. 18-decimal quote + // mint * mid-cap price * mid-cap quantity). Promote to u128 for the + // multiplication, then narrow back to u64 with try_into so the failure + // mode is "can't fit the on-chain transfer" not "silently rejected at + // the math step". + 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 as u128) + .checked_mul(quantity as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(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 plan + // --------------------------------------------------------------- + // + // Caller passes pairs of `(maker_order, maker_market_user)` in the + // transaction's remaining_accounts, in the same price-time-priority + // order the book would walk. We plan fills against the resting tree, + // then verify the caller's account list matches the plan, then apply. + let maker_accounts = &context.remaining_accounts; + require!( + maker_accounts.len() % ACCOUNTS_PER_MAKER == 0, + ErrorCode::MissingMakerAccounts + ); + + let order_book_loader = &context.accounts.order_book; + + // Plan in an immutable load scope, copy the fills out, then drop the + // borrow before we re-borrow for mutations. AccountLoader::load() is a + // RefCell-based runtime borrow, so the loaded ref must not outlive the + // plan we copy out of it. + let (fills, taker_remaining) = { + let order_book = order_book_loader.load()?; + plan_fills(&order_book, side, price, quantity) + }; + + require!( + maker_accounts.len() >= fills.len() * ACCOUNTS_PER_MAKER, + ErrorCode::MissingMakerAccounts + ); + + // Sanity-check the caller-supplied maker account list against the plan. + // Each fill's maker_order_id must match the order_id stored on the + // corresponding `Order` PDA, and that PDA's `market` must match this + // market. + for (fill_index, fill) in fills.iter().enumerate() { + let maker_order_info = &maker_accounts[fill_index * ACCOUNTS_PER_MAKER]; + let maker_order = Account::::try_from(maker_order_info)?; + require!( + maker_order.order_id == fill.maker_order_id, + ErrorCode::MakerAccountMismatch + ); + require!( + maker_order.market == market.key(), + ErrorCode::MakerAccountMismatch + ); + } + + // --------------------------------------------------------------- + // Apply fills + // --------------------------------------------------------------- + + let taker_market_user = &mut context.accounts.market_user; + let mut taker_base_received: u64 = 0; + let mut taker_quote_rebate: u64 = 0; + let mut taker_quote_received: u64 = 0; + // Aggregate the per-fill fee into a single transfer at the end β€” + // halves CU cost vs one CPI per fill. + let mut total_fee_quote: u64 = 0; + + for (fill_index, fill) in fills.iter().enumerate() { + let maker_order_info = &maker_accounts[fill_index * ACCOUNTS_PER_MAKER]; + let maker_user_info = &maker_accounts[fill_index * ACCOUNTS_PER_MAKER + 1]; + + let mut maker_order = Account::::try_from(maker_order_info)?; + let mut maker_market_user = Account::::try_from(maker_user_info)?; + + require!( + maker_order.owner == maker_market_user.owner, + ErrorCode::MakerOwnerMismatch + ); + require!( + maker_market_user.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. + // fill_price * fill_quantity in u128 to avoid u64-overflow on big + // fills (high-decimal mints scale this product fast). Narrow back + // to u64 with try_into so the transfer/balance step gets a real + // u64 and the failure mode is explicit. + let gross_quote: u64 = (fill.fill_price as u128) + .checked_mul(fill.fill_quantity as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(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))?; + + // Defensive invariant: fees are a fraction of gross, never more. + // `fee_basis_points <= 10_000` is enforced at market init, so this + // should be unreachable β€” but a stale assumption here would let a + // misconfigured market overdraw the maker's net payout. Cheap check. + require!(fee_quote <= gross_quote, ErrorCode::NumericalOverflow); + + match side { + // Taker Bid, resting Ask. Taker pays quote, gets base. + OrderSide::Bid => { + let net_quote_to_maker = gross_quote + .checked_sub(fee_quote) + .ok_or(ErrorCode::NumericalOverflow)?; + maker_market_user.unsettled_quote = maker_market_user + .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. + // u128 intermediate for the same reason as the bid lock + // and gross_quote above β€” the original lock is already + // bounded to u64, so this product narrows back cleanly. + let locked_for_this_fill: u64 = (price as u128) + .checked_mul(fill.fill_quantity as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(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_market_user.unsettled_base = maker_market_user + .unsettled_base + .checked_add(fill.fill_quantity) + .ok_or(ErrorCode::NumericalOverflow)?; + + 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 maker_fully_filled { + remove_open_order(&mut maker_market_user, maker_order.order_id); + } + + maker_order.exit(context.program_id)?; + maker_market_user.exit(context.program_id)?; + } + + // --------------------------------------------------------------- + // Apply book-side updates from the planned fills (decrement remaining + // qty / remove fully-filled leaves), then insert any taker remainder. + // --------------------------------------------------------------- + let maker_side = match side { + OrderSide::Bid => OrderSide::Ask, + OrderSide::Ask => OrderSide::Bid, + }; + + { + let mut order_book = order_book_loader.load_mut()?; + for fill in &fills { + order_book.apply_fill_to_maker( + maker_side, + fill.maker_order_id, + fill.fill_price, + fill.fill_quantity, + )?; + } + } + + // 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_market_user.unsettled_base = taker_market_user + .unsettled_base + .checked_add(taker_base_received) + .ok_or(ErrorCode::NumericalOverflow)?; + taker_market_user.unsettled_quote = taker_market_user + .unsettled_quote + .checked_add(taker_quote_rebate) + .ok_or(ErrorCode::NumericalOverflow)? + .checked_add(taker_quote_received) + .ok_or(ErrorCode::NumericalOverflow)?; + + // --------------------------------------------------------------- + // Stamp the taker's Order PDA. Either Filled (no remainder) or rested. + // + // The seq_num we baked into the order_id (via `next_order_id` at the + // top of the planning section) is what the slab uses as the time- + // priority tie-break. We allocate it inside the load_mut scope below + // so the counter and the leaf insert happen against the same view of + // the book. + // --------------------------------------------------------------- + let timestamp = Clock::get()?.unix_timestamp; + let order_id = { + let mut order_book = order_book_loader.load_mut()?; + let id = order_book.allocate_order_id()?; + if taker_remaining > 0 { + require!( + !order_book.is_side_full(side), + ErrorCode::OrderBookFull + ); + order_book.place_resting( + side, + price, + taker_remaining, + context.accounts.owner.key(), + id, + timestamp, + )?; + } + id + }; + + 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; + // checked_sub, not saturating_sub: a silent clamp on filled_quantity + // would be a real correctness bug (the matching engine should never + // hand us a remainder larger than the original). saturating_* belongs + // on cosmetic/UX paths, not on a field the matching engine relies on. + order.filled_quantity = quantity + .checked_sub(taker_remaining) + .ok_or(ErrorCode::NumericalOverflow)?; + order.timestamp = timestamp; + order.bump = context.bumps.order; + + if taker_remaining == 0 { + order.status = OrderStatus::Filled; + } else { + order.status = if taker_remaining < quantity { + OrderStatus::PartiallyFilled + } else { + OrderStatus::Open + }; + add_open_order(taker_market_user, order_id); + } + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(side: OrderSide, price: u64, quantity: u64)] +pub struct PlaceOrder<'info> { + // `has_one` ties every market-owned account on this struct to the + // addresses recorded on the Market PDA. Crucially, without + // has_one on base_vault / quote_vault / base_mint / quote_mint a caller + // could swap fee_vault in for quote_vault (same mint, same authority) + // and steer the per-fill fee transfer to drain real fees instead of + // routing them in. + #[account( + mut, + has_one = fee_vault @ ErrorCode::InvalidFeeVault, + has_one = base_vault @ ErrorCode::InvalidBaseVault, + has_one = quote_vault @ ErrorCode::InvalidQuoteVault, + has_one = base_mint @ ErrorCode::InvalidBaseMint, + has_one = quote_mint @ ErrorCode::InvalidQuoteMint, + has_one = order_book @ ErrorCode::InvalidOrderBook, + )] + pub market: Account<'info, Market>, + + // Zero-copy: AccountLoader streams the slab in/out without paying + // borsh (de)serialization on every instruction. See order_book.rs for + // the layout. Not a PDA β€” the client created it directly via + // system_program::create_account (see initialize_market.rs for why); + // `has_one = order_book` on `market` is what ties this specific account + // to this specific market. + #[account(mut)] + pub order_book: AccountLoader<'info, OrderBook>, + + // The order PDA seed uses the book's `next_order_id` *before* this + // instruction increments it β€” i.e. the id this new order will receive. + // Read via `load()` so Anchor can derive the PDA at verification time. + #[account( + init, + payer = owner, + space = Order::DISCRIMINATOR.len() + Order::INIT_SPACE, + seeds = [ + ORDER_SEED, + market.key().as_ref(), + order_book.load()?.next_order_id.to_le_bytes().as_ref() + ], + bump + )] + pub order: Account<'info, Order>, + + #[account( + mut, + seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = market_user.bump + )] + pub market_user: Account<'info, MarketUser>, + + // 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/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs b/defi/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs new file mode 100644 index 00000000..c8fe64da --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/instructions/settle_funds.rs @@ -0,0 +1,118 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::errors::ErrorCode; +use crate::state::{Market, MarketUser, MARKET_SEED, MARKET_USER_SEED}; + +pub fn handle_settle_funds(context: Context) -> Result<()> { + let market_user = &mut context.accounts.market_user; + let market = &context.accounts.market; + + // Snapshot the amounts the user is owed, then zero the counters + // BEFORE the token transfers. Checks-effects-interactions: even though + // Solana CPIs don't reenter in the EVM sense, if either transfer ever + // gained a path that called back into this program (custom token + // hooks, transfer-fee extensions with side effects, ...), having stale + // unsettled_* values readable mid-transfer would let a re-entry double- + // withdraw. Updating state first makes that class of bug impossible. + let base_amount = market_user.unsettled_base; + let quote_amount = market_user.unsettled_quote; + market_user.unsettled_base = 0; + market_user.unsettled_quote = 0; + + // 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, + )?; + } + + 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, + )?; + } + + Ok(()) +} + +#[derive(Accounts)] +pub struct SettleFunds<'info> { + // `has_one` constraints bind these vaults/mints to the addresses stored + // on the Market PDA at initialise_market time. Without them a caller + // could substitute the fee_vault (same mint + same authority as + // quote_vault) for `quote_vault` and drain accumulated taker fees, + // since transfer_checked only verifies mint + authority on the source + // account, not its identity. + #[account( + mut, + has_one = base_vault @ ErrorCode::InvalidBaseVault, + has_one = quote_vault @ ErrorCode::InvalidQuoteVault, + has_one = base_mint @ ErrorCode::InvalidBaseMint, + has_one = quote_mint @ ErrorCode::InvalidQuoteMint, + )] + pub market: Account<'info, Market>, + + #[account( + mut, + seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()], + bump = market_user.bump + )] + pub market_user: Account<'info, MarketUser>, + + // 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/order-book/anchor/programs/order-book/src/instructions/withdraw_fees.rs b/defi/order-book/anchor/programs/order-book/src/instructions/withdraw_fees.rs new file mode 100644 index 00000000..2b44463f --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/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/order-book/anchor/programs/order-book/src/lib.rs b/defi/order-book/anchor/programs/order-book/src/lib.rs new file mode 100644 index 00000000..1e06df24 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/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 order_book { + 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_market_user(context: Context) -> Result<()> { + instructions::create_market_user::handle_create_market_user(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/order-book/anchor/programs/order-book/src/state/market.rs b/defi/order-book/anchor/programs/order-book/src/state/market.rs new file mode 100644 index 00000000..42a81504 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/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/order-book/anchor/programs/order-book/src/state/market_user.rs b/defi/order-book/anchor/programs/order-book/src/state/market_user.rs new file mode 100644 index 00000000..14d80ac9 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/market_user.rs @@ -0,0 +1,38 @@ +use anchor_lang::prelude::*; + +pub const MARKET_USER_SEED: &[u8] = b"market_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 MarketUser { + 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 MarketUser, order_id: u64) { + if !account.open_orders.contains(&order_id) { + account.open_orders.push(order_id); + } +} + +pub fn remove_open_order(account: &mut MarketUser, 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/order-book/anchor/programs/order-book/src/state/matching.rs b/defi/order-book/anchor/programs/order-book/src/state/matching.rs new file mode 100644 index 00000000..86b2c85a --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/matching.rs @@ -0,0 +1,95 @@ +//! Matching engine helpers. Pure logic (no CPIs) that walks the resting side +//! of the book in price-time priority and produces a list of fills the +//! caller should apply. +//! +//! Walking the tree returns leaves in best-price-first order (asks +//! ascending, bids descending), and within a single price level the leaf +//! key's seq_num half preserves time priority, so a straight `next()` walk +//! is the right traversal for matching. Stop as soon as either the taker is +//! exhausted or the next leaf no longer crosses the taker's limit price. + +use crate::state::slab::OrderTreeIter; +use crate::state::{OrderBook, OrderSide}; + +/// One matched fill between the incoming taker order and a resting maker +/// order. `place_order` turns these into token movements and account +/// mutations. +/// +/// Deliberately does NOT carry the slab handle of the maker leaf: removing +/// any leaf rebalances the tree, which invalidates other handles in the +/// same plan. We look the leaf up by its tree key (built from price + +/// order_id) at apply time instead. +pub struct Fill { + /// order_id of the resting order being filled. Used both to sanity- + /// check the maker `Order` PDA the caller passed in remaining_accounts, + /// and to reconstruct the tree key for the apply-time lookup. + pub maker_order_id: u64, + + /// Quantity filled (in base tokens). + pub fill_quantity: u64, + + /// Price at which the fill clears. Always the resting (maker) order's + /// price β€” standard order-book rule: maker's posted price wins; the taker + /// gets price improvement vs their limit on bids, and a higher payout + /// vs their limit on asks. Also the high 64 bits of the tree key when + /// we look the leaf up again at apply time. + 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. +/// +/// Returns `(fills, taker_remaining)` β€” `taker_remaining` is what's left +/// over after crossing, to be rested on the book at the taker's limit price. +pub fn plan_fills( + order_book: &OrderBook, + incoming_side: OrderSide, + incoming_price: u64, + incoming_quantity: u64, +) -> (Vec, u64) { + // Resting side is the opposite of the taker's side. + let (root, nodes) = match incoming_side { + OrderSide::Bid => (&order_book.asks_root, &order_book.asks), + OrderSide::Ask => (&order_book.bids_root, &order_book.bids), + }; + + let mut fills: Vec = Vec::new(); + let mut taker_remaining = incoming_quantity; + + for (_handle, leaf) in OrderTreeIter::new(nodes, root) { + if taker_remaining == 0 { + break; + } + + // Crossing condition: bid takes when its limit is >= the resting + // ask's price; ask takes when its limit is <= the resting bid's + // price. The tree walk is in best-price-first order on the resting + // side, so the first leaf that fails to cross means every + // subsequent leaf also fails β€” break, don't continue. + let resting_price = leaf.price(); + let crosses = match incoming_side { + OrderSide::Bid => incoming_price >= resting_price, + OrderSide::Ask => incoming_price <= resting_price, + }; + if !crosses { + break; + } + + if leaf.quantity == 0 { + // Defensive: fully-filled leaves are removed, but if one ever + // slips through with zero quantity, skip it rather than emit a + // zero-size fill. + continue; + } + + let fill_quantity = taker_remaining.min(leaf.quantity); + fills.push(Fill { + maker_order_id: leaf.order_id, + fill_quantity, + fill_price: resting_price, + }); + taker_remaining = taker_remaining.saturating_sub(fill_quantity); + } + + (fills, taker_remaining) +} diff --git a/defi/order-book/anchor/programs/order-book/src/state/mod.rs b/defi/order-book/anchor/programs/order-book/src/state/mod.rs new file mode 100644 index 00000000..b1c058ee --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/mod.rs @@ -0,0 +1,12 @@ +pub mod market; +pub mod matching; +pub mod order; +pub mod order_book; +pub mod slab; +pub mod market_user; + +pub use market::*; +pub use matching::*; +pub use order::*; +pub use order_book::*; +pub use market_user::*; diff --git a/defi/order-book/anchor/programs/order-book/src/state/order.rs b/defi/order-book/anchor/programs/order-book/src/state/order.rs new file mode 100644 index 00000000..24e4d264 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/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/order-book/anchor/programs/order-book/src/state/order_book.rs b/defi/order-book/anchor/programs/order-book/src/state/order_book.rs new file mode 100644 index 00000000..18954082 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/order_book.rs @@ -0,0 +1,260 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::slab::{ + new_node_key, AnyNode, LeafNode, OrderTreeIter, OrderTreeNodes, OrderTreeRoot, OrderTreeType, + MAX_TREE_NODES, NODE_SIZE, +}; +use crate::state::OrderSide; + +pub const ORDER_BOOK_SEED: &[u8] = b"order_book"; + +/// Per-side capacity. 1024 leaves is enough for any realistic depth a single +/// market quotes; at 88 bytes per node that's ~90 KB per side, so the whole +/// OrderBook account fits in ~180 KB β€” well under Solana's per-account ceiling +/// and well within the rent budget a market authority is happy to fund once. +pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; + +/// Combined order book: two critbit trees plus a shared monotonic seq_num +/// counter that gives every order a unique tie-break and acts as the public +/// `order_id`. +/// +/// Stored as one `AccountLoader` (zero-copy). The account is far +/// larger than Anchor's borsh `Account` would happily deserialize on every +/// instruction β€” zero-copy gives us per-field memory access without paying +/// the (de)serialization cost. +#[account(zero_copy(unsafe))] +#[repr(C)] +pub struct OrderBook { + /// Market PDA this book belongs to. Constrained on-chain via the + /// `market` `has_one = order_book` (and vice-versa) bindings. + pub market: Pubkey, + + /// Tree roots for the two sides. Kept on this struct (rather than inside + /// the OrderTreeNodes blobs) so each side's `leaf_count` is cheap to read + /// without iterating. + pub bids_root: OrderTreeRoot, + pub asks_root: OrderTreeRoot, + + /// Monotonic order id. Incremented on every successful place_order, used + /// both as the public `order_id` and as the seq_num tie-break baked into + /// each leaf's tree key. + pub next_order_id: u64, + + pub bump: u8, + pub _padding: [u8; 7], + + /// The two slabs. Layout-wise they're back-to-back so the whole struct + /// is one contiguous zero-copy region. + pub bids: OrderTreeNodes, + pub asks: OrderTreeNodes, +} + +// Sanity check: catch any accidental layout drift if someone adds a field +// without recomputing space. Account discriminator + struct itself. +pub const ORDER_BOOK_ACCOUNT_SIZE: usize = 8 + std::mem::size_of::(); + +// Compile-time check: the struct shouldn't quietly balloon past its expected +// ~180 KB if anyone adds fields. 32 (market) + 8 (bids_root) + 8 (asks_root) +// + 8 (next_order_id) + 1 (bump) + 7 (pad) + 2 * (12 + 88*1024) +// = 64 + 2 * 90124 = 180312 bytes for the struct itself. +const _: () = { + assert!(std::mem::size_of::() == 32 + 8 + 8 + 8 + 1 + 7 + 2 * (1 + 3 + 4 + 4 + 4 + NODE_SIZE * MAX_TREE_NODES)); +}; + +/// A compact view of one resting order for the matching engine. Returned +/// from the tree iterator so callers don't have to hand-roll slab access. +pub struct RestingOrderView { + pub order_id: u64, + pub price: u64, + pub quantity: u64, + pub owner: Pubkey, +} + +impl OrderBook { + /// First-time initialization. Sets the market binding, the order-id + /// counter, and stamps each slab with its side tag so the iterator knows + /// which way to walk. + pub fn initialize(&mut self, market: Pubkey, bump: u8) { + self.market = market; + self.bids_root = OrderTreeRoot::default(); + self.asks_root = OrderTreeRoot::default(); + // Order ids start at 1 so 0 can stand for "no order" in clients. + self.next_order_id = 1; + self.bump = bump; + self._padding = [0; 7]; + + // Slab regions arrive zeroed (Anchor zero_copy guarantees that on + // first init). We only need to write the side tag β€” every other + // field already reads as "empty" (bump_index=0, free_list_len=0, + // free_list_head=0, all node slots Uninitialized). + self.bids.order_tree_type = OrderTreeType::Bids as u8; + self.asks.order_tree_type = OrderTreeType::Asks as u8; + } + + /// Allocate the next order id and roll the counter. Returns the id the + /// caller should stamp into the new Order PDA. + pub fn allocate_order_id(&mut self) -> Result { + let id = self.next_order_id; + self.next_order_id = self + .next_order_id + .checked_add(1) + .ok_or_else(|| error!(ErrorCode::NumericalOverflow))?; + Ok(id) + } + + /// Insert a resting order at `price` on `side`. The seq_num baked into + /// the leaf's tree key gives price-time priority: at any single price, + /// earlier orders sort before later ones when the iterator walks the + /// tree. + pub fn place_resting( + &mut self, + side: OrderSide, + price: u64, + quantity: u64, + owner: Pubkey, + order_id: u64, + timestamp: i64, + ) -> Result<()> { + let key = new_node_key(side, price, order_id); + let leaf = LeafNode::new(key, owner, quantity, order_id, timestamp); + let (root, nodes) = match side { + OrderSide::Bid => (&mut self.bids_root, &mut self.bids), + OrderSide::Ask => (&mut self.asks_root, &mut self.asks), + }; + nodes.insert_leaf(root, &leaf)?; + Ok(()) + } + + /// Remove a resting order by `order_id`. Returns `true` if it was found + /// and removed on either side. + pub fn remove(&mut self, order_id: u64) -> bool { + // We don't know which side the order is on without scanning, so try + // both. Tree lookup is O(log N) β€” much cheaper than the linear Vec + // scan the previous implementation did. + if self.remove_from(OrderSide::Bid, order_id).is_some() { + return true; + } + if self.remove_from(OrderSide::Ask, order_id).is_some() { + return true; + } + false + } + + /// Remove a resting order from a specific side. Returns the removed leaf + /// if found, so callers can read its quantity/owner without re-fetching + /// the Order account. + pub fn remove_from(&mut self, side: OrderSide, order_id: u64) -> Option { + let key = new_node_key(side, /*price*/ 0, order_id); + // The seq_num half of the key is order_id (or its bitwise NOT on + // bids), so we can reconstruct it without knowing the price. But we + // do still need the price for the high bits; tree search keys on + // exact match so we have to walk the leaves to find it. + let _ = key; + let (root, nodes) = match side { + OrderSide::Bid => (&mut self.bids_root, &mut self.bids), + OrderSide::Ask => (&mut self.asks_root, &mut self.asks), + }; + + // Linear scan to find the full key (price + seq_num) for this + // order_id. Cheap relative to a CPI β€” and only happens at + // cancellation, not in the hot matching path. + let mut found_key: Option = None; + for (_, leaf) in OrderTreeIter::new(nodes, root) { + if leaf.order_id == order_id { + found_key = Some(leaf.key); + break; + } + } + let key = found_key?; + nodes.remove_by_key(root, key) + } + + /// Resting-order view of the best (best-priced) leaf on `side`, if any. + pub fn best(&self, side: OrderSide) -> Option { + let (root, nodes) = match side { + OrderSide::Bid => (&self.bids_root, &self.bids), + OrderSide::Ask => (&self.asks_root, &self.asks), + }; + let (_handle, leaf) = nodes.best_leaf(root)?; + Some(RestingOrderView { + order_id: leaf.order_id, + price: leaf.price(), + quantity: leaf.quantity, + owner: leaf.owner, + }) + } + + /// Number of resting orders on a side. O(1). + pub fn count(&self, side: OrderSide) -> u32 { + match side { + OrderSide::Bid => self.bids_root.leaf_count, + OrderSide::Ask => self.asks_root.leaf_count, + } + } + + /// Decrease the remaining quantity on a resting leaf, or remove it if the + /// fill consumes everything. Used by the matching engine to apply fills + /// against the maker side of the book without reinserting leaves. + /// + /// Leaves are looked up by (price, order_id) instead of a cached slab + /// handle because removing any leaf rebalances the tree β€” every other + /// handle in the same plan would be stale after the first removal. + /// (price, order_id) reconstructs the exact tree key the leaf was + /// inserted with, so the tree walk lands on the right slot every time. + pub fn apply_fill_to_maker( + &mut self, + maker_side: OrderSide, + maker_order_id: u64, + fill_price: u64, + fill_quantity: u64, + ) -> Result<()> { + let key = new_node_key(maker_side, fill_price, maker_order_id); + let (root, nodes) = match maker_side { + OrderSide::Bid => (&mut self.bids_root, &mut self.bids), + OrderSide::Ask => (&mut self.asks_root, &mut self.asks), + }; + + // Look up the leaf to read its remaining quantity. We need to know + // whether to mutate-in-place (partial fill) or remove (full fill). + let (handle, remaining_after) = { + let (handle, leaf) = nodes + .find_by_key(root, key) + .ok_or_else(|| error!(ErrorCode::OrderNotFound))?; + let remaining = leaf + .quantity + .checked_sub(fill_quantity) + .ok_or_else(|| error!(ErrorCode::NumericalOverflow))?; + (handle, remaining) + }; + if remaining_after == 0 { + nodes.remove_by_key(root, key); + } else { + // Partial: mutate the leaf's quantity in place. The slab slot + // index does not change here, so no tree rebalancing occurs and + // the other leaves' slot positions in the slab stay valid. + let leaf = nodes + .node_mut(handle) + .and_then(AnyNode::as_leaf_mut) + .ok_or_else(|| error!(ErrorCode::OrderNotFound))?; + leaf.quantity = remaining_after; + } + Ok(()) + } + + pub fn is_side_full(&self, side: OrderSide) -> bool { + match side { + OrderSide::Bid => self.bids.is_full(), + OrderSide::Ask => self.asks.is_full(), + } + } +} + +impl Default for OrderTreeRoot { + fn default() -> Self { + Self { + maybe_node: 0, + leaf_count: 0, + } + } +} diff --git a/defi/order-book/anchor/programs/order-book/src/state/slab/LICENSE-OPENBOOK b/defi/order-book/anchor/programs/order-book/src/state/slab/LICENSE-OPENBOOK new file mode 100644 index 00000000..c1b4c881 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/LICENSE-OPENBOOK @@ -0,0 +1,26 @@ +The slab and order-tree code in this directory is adapted from +openbook-dex/openbook-v2 (https://github.com/openbook-dex/openbook-v2), +specifically the files under programs/openbook-v2/src/state/orderbook/. +Those upstream files are licensed under the MIT License reproduced below. + +MIT License + +Copyright (c) 2023 GluonicLedger Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/defi/order-book/anchor/programs/order-book/src/state/slab/iterator.rs b/defi/order-book/anchor/programs/order-book/src/state/slab/iterator.rs new file mode 100644 index 00000000..8fdbbefd --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/iterator.rs @@ -0,0 +1,89 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// Trimmed: this iterator yields handles + leaf refs only (no oracle peg +// validity filtering). That's all the matching engine needs β€” fills cross in +// pure price-time priority, and there are no time-in-force or peg checks to +// apply. + +use super::nodes::{InnerNode, LeafNode, NodeHandle, NodeRef}; +use super::ordertree::{OrderTreeNodes, OrderTreeRoot, OrderTreeType}; + +/// In-order walk of one side of the book. +/// +/// Visits leaves in best-price-first order: +/// - asks: ascending (lowest price first) +/// - bids: descending (highest price first) +/// +/// Within a single price level, earlier orders come first (price-time +/// priority) β€” that ordering is encoded in the leaf key, so it falls out of +/// the in-order walk automatically. +pub struct OrderTreeIter<'a> { + nodes: &'a OrderTreeNodes, + + /// Inner nodes whose "right" branch (in walk direction) we still owe a + /// visit to. + stack: Vec<&'a InnerNode>, + + /// Cached next leaf so `peek` and `next` can share work. + next_leaf: Option<(NodeHandle, &'a LeafNode)>, + + /// Child indexes to walk: (first, second). For asks we go (0, 1) β€” i.e. + /// down the left child first, then right; for bids we go (1, 0). + first: usize, + second: usize, +} + +impl<'a> OrderTreeIter<'a> { + pub fn new(nodes: &'a OrderTreeNodes, root: &OrderTreeRoot) -> Self { + let (first, second) = if nodes.order_tree_type() == OrderTreeType::Bids { + (1, 0) + } else { + (0, 1) + }; + let mut iter = Self { + nodes, + stack: vec![], + next_leaf: None, + first, + second, + }; + if let Some(handle) = root.node() { + iter.next_leaf = iter.walk_to_first_leaf(handle); + } + iter + } + + pub fn peek(&self) -> Option<(NodeHandle, &'a LeafNode)> { + self.next_leaf + } + + fn walk_to_first_leaf(&mut self, start: NodeHandle) -> Option<(NodeHandle, &'a LeafNode)> { + let mut current = start; + loop { + match self.nodes.node(current)?.case()? { + NodeRef::Inner(inner) => { + self.stack.push(inner); + current = inner.children[self.first]; + } + NodeRef::Leaf(leaf) => return Some((current, leaf)), + } + } + } +} + +impl<'a> Iterator for OrderTreeIter<'a> { + type Item = (NodeHandle, &'a LeafNode); + + fn next(&mut self) -> Option { + let current = self.next_leaf?; + self.next_leaf = match self.stack.pop() { + None => None, + Some(inner) => { + let start = inner.children[self.second]; + self.walk_to_first_leaf(start) + } + }; + Some(current) + } +} diff --git a/defi/order-book/anchor/programs/order-book/src/state/slab/mod.rs b/defi/order-book/anchor/programs/order-book/src/state/slab/mod.rs new file mode 100644 index 00000000..285ab292 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/mod.rs @@ -0,0 +1,18 @@ +// Slab + order-tree port of openbook-dex/openbook-v2 (commit +// f3e17421e675b083b584867594bf3cf4f675d156), MIT-licensed. See +// LICENSE-OPENBOOK in this directory. +// +// Public surface is intentionally small: the OrderBook wrapper in +// `state/order_book.rs` calls insert/best/remove/iter; nothing else in the +// program needs to know the tree exists. + +pub mod iterator; +pub mod nodes; +pub mod ordertree; + +pub use iterator::OrderTreeIter; +pub use nodes::{ + new_node_key, price_from_key, AnyNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag, + NODE_SIZE, +}; +pub use ordertree::{OrderTreeNodes, OrderTreeRoot, OrderTreeType, MAX_TREE_NODES}; diff --git a/defi/order-book/anchor/programs/order-book/src/state/slab/nodes.rs b/defi/order-book/anchor/programs/order-book/src/state/slab/nodes.rs new file mode 100644 index 00000000..2cf359ee --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/nodes.rs @@ -0,0 +1,340 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// Trimmed from the upstream nodes.rs: +// - dropped oracle-pegged price helpers (this order book is fixed-price only) +// - dropped time-in-force / expiry tracking (no IOC/post-only here) +// - dropped client_order_id and owner_slot (the on-chain `Order` PDA +// already owns that bookkeeping) +// The slab/tree mechanics (NodeTag, InnerNode/LeafNode/FreeNode, AnyNode, +// walk_down, new_node_key) are preserved as-is so future contributors can +// diff against upstream cleanly. + +use std::mem::{align_of, size_of}; + +use anchor_lang::prelude::*; +use bytemuck::{cast_mut, cast_ref}; +use static_assertions::const_assert_eq; + +use crate::state::OrderSide; + +/// Index into `OrderTreeNodes::nodes`. u32 is plenty for 1024 slots; the type +/// is kept wide to match upstream so the layout stays compatible. +pub type NodeHandle = u32; + +/// Every node β€” Inner, Leaf, Free β€” is padded to the same 88 bytes so the +/// underlying `[AnyNode; N]` array is a true slab: we can swap a Leaf for an +/// Inner in place without reallocating. Matches the upstream Openbook layout. +pub const NODE_SIZE: usize = 88; + +/// Tag stored in the first byte of every slab slot. +/// +/// `Uninitialized` (the zero value) is what fresh account memory looks like +/// before any insert, so a freshly-zeroed slab is automatically empty. +#[repr(u8)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum NodeTag { + Uninitialized = 0, + InnerNode = 1, + LeafNode = 2, + FreeNode = 3, + LastFreeNode = 4, +} + +impl NodeTag { + pub fn from_u8(tag: u8) -> Option { + match tag { + 0 => Some(NodeTag::Uninitialized), + 1 => Some(NodeTag::InnerNode), + 2 => Some(NodeTag::LeafNode), + 3 => Some(NodeTag::FreeNode), + 4 => Some(NodeTag::LastFreeNode), + _ => None, + } + } +} + +/// Build the 128-bit tree key for a new leaf. +/// +/// Layout: `[price_data : u64][seq_num_bits : u64]`. +/// +/// For asks the seq_num is stored as-is, so earlier (smaller) seq_num sorts +/// before a later one at the same price β†’ time priority is preserved when +/// walking the tree ascending. +/// +/// For bids we invert the seq_num so that *earlier* still sorts first when +/// the tree is walked descending. (Reading bids in descending order means +/// largest key first; inverting seq_num makes the earlier order's seq_num +/// the larger one at any given price.) +pub fn new_node_key(side: OrderSide, price_data: u64, seq_num: u64) -> u128 { + let seq_num = if side == OrderSide::Bid { !seq_num } else { seq_num }; + ((price_data as u128) << 64) | (seq_num as u128) +} + +/// Extract the price half of a tree key. Same shape on both sides because the +/// price is stored in the high 64 bits unmodified. +#[inline(always)] +pub fn price_from_key(key: u128) -> u64 { + (key >> 64) as u64 +} + +/// One internal tree node. Two children (referenced by slab handle), a key +/// holding the shared prefix bits, and `prefix_len` telling consumers how many +/// of the high bits of `key` are meaningful. +/// +/// `tag` is the first byte at offset 0 β€” same offset as on `LeafNode` and +/// `FreeNode` β€” so `AnyNode::tag` reads the variant tag from a fixed offset +/// regardless of which variant is in the slot. +/// +/// `repr(C, packed(8))` caps field alignment at 8 bytes. Without this, u128 +/// (which has 16-byte alignment on x86_64) would force the struct to align +/// to 16, mismatching `AnyNode`'s 8-byte alignment and tripping both the +/// `align_of` const-asserts and bytemuck's `Pod` derive. Capping at 8 is +/// safe: every field is still naturally 8-aligned within the struct, so +/// references and reads work normally. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C, packed(8))] +pub struct InnerNode { + /// `NodeTag::InnerNode` + pub tag: u8, + pub padding: [u8; 3], + + /// Number of high `key` bits that all descendants share. + pub prefix_len: u32, + + /// Only the top `prefix_len` bits of `key` are meaningful β€” the rest is + /// whichever leaf happened to be inserted first below this node. + pub key: u128, + + /// Slab handles for the left (`children[0]`) and right (`children[1]`) + /// subtree. Left = 0 critbit, right = 1 critbit. + pub children: [NodeHandle; 2], + + /// Pads to NODE_SIZE so InnerNode / LeafNode / FreeNode are + /// interchangeable in the slab. 88 - 1 - 3 - 4 - 16 - 8 = 56. + pub reserved: [u8; 56], +} +const_assert_eq!(size_of::(), NODE_SIZE); +const_assert_eq!(size_of::() % 8, 0); + +impl InnerNode { + pub fn new(prefix_len: u32, key: u128) -> Self { + Self { + tag: NodeTag::InnerNode as u8, + padding: [0; 3], + prefix_len, + key, + children: [0; 2], + reserved: [0; 56], + } + } + + /// Given a search key, return the child the key would descend into and + /// the critbit (0 or 1) that decision was based on. + /// + /// The critbit is the bit immediately *after* the shared prefix + /// (i.e. the first bit at which the search key can disagree with this + /// node's stored prefix). + #[inline(always)] + pub fn walk_down(&self, search_key: u128) -> (NodeHandle, bool) { + let crit_bit_mask = 1u128 << (127 - self.prefix_len); + let crit_bit = (search_key & crit_bit_mask) != 0; + (self.children[crit_bit as usize], crit_bit) + } +} + +/// One resting order in the slab. +/// +/// All the per-order metadata callers care about lives on the corresponding +/// `Order` PDA β€” the slab leaf only stores what the matching engine needs: +/// the tree key (price + tie-break), the remaining quantity, the owner, and +/// the order_id (which the handler uses to verify the matching `Order` +/// account the caller passed in). +/// +/// `tag` is at offset 0 to match `InnerNode` (see comment there). +/// `repr(C, packed(8))` for the same reason as `InnerNode`. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C, packed(8))] +pub struct LeafNode { + /// `NodeTag::LeafNode` + pub tag: u8, + pub padding: [u8; 7], + + /// The 128-bit tree key. See `new_node_key`. + pub key: u128, + + /// Owner of this resting order. Same as the corresponding `Order` + /// account's `owner`; cached here so the matching loop doesn't have to + /// deserialize the maker account just to read the owner. + pub owner: Pubkey, + + /// Quantity remaining (in base tokens). Decremented as fills consume the + /// order; the leaf is removed when it hits 0. + pub quantity: u64, + + /// Public order identifier. The handler uses this both to look up the + /// owner's `Order` PDA and to sanity-check the maker account list passed + /// as `remaining_accounts`. + pub order_id: u64, + + /// Unix timestamp at which the order rested. Not used by matching (the + /// seq_num inside `key` is the tie-break) β€” kept so off-chain tooling + /// can show an "age" without re-deriving it from a different account. + pub timestamp: i64, + + /// Pads to NODE_SIZE. 88 - 1 - 7 - 16 - 32 - 8 - 8 - 8 = 8. + pub reserved: [u8; 8], +} +const_assert_eq!(size_of::(), NODE_SIZE); +const_assert_eq!(size_of::() % 8, 0); + +impl LeafNode { + pub fn new( + key: u128, + owner: Pubkey, + quantity: u64, + order_id: u64, + timestamp: i64, + ) -> Self { + Self { + tag: NodeTag::LeafNode as u8, + padding: [0; 7], + key, + owner, + quantity, + order_id, + timestamp, + reserved: [0; 8], + } + } + + /// Price half of the tree key β€” convenience for callers. + #[inline(always)] + pub fn price(&self) -> u64 { + price_from_key(self.key) + } +} + +/// Free-list link in the slab. When a leaf is removed its slot is replaced by +/// a FreeNode that points to the previous free slot, so subsequent inserts +/// reuse the slot in O(1). +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub(crate) struct FreeNode { + pub(crate) tag: u8, + pub(crate) padding: [u8; 3], + /// Next free slot in the chain, or unused when this is the last. + pub(crate) next: NodeHandle, + pub(crate) reserved: [u8; NODE_SIZE - 16], + /// Forces 8-byte alignment so all node variants share the same alignment. + pub(crate) force_align: u64, +} +const_assert_eq!(size_of::(), NODE_SIZE); +const_assert_eq!(size_of::() % 8, 0); + +/// Type-erased node. The slab is an array of these; the leading `tag` byte +/// tells callers which variant the slot actually holds. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct AnyNode { + pub tag: u8, + pub data: [u8; 79], + /// See FreeNode::force_align. + pub force_align: u64, +} +const_assert_eq!(size_of::(), NODE_SIZE); +const_assert_eq!(size_of::() % 8, 0); +const_assert_eq!(align_of::(), 8); +const_assert_eq!(size_of::(), size_of::()); +const_assert_eq!(align_of::(), align_of::()); +const_assert_eq!(size_of::(), size_of::()); +const_assert_eq!(align_of::(), align_of::()); +const_assert_eq!(size_of::(), size_of::()); +const_assert_eq!(align_of::(), align_of::()); + +pub enum NodeRef<'a> { + Inner(&'a InnerNode), + Leaf(&'a LeafNode), +} + +pub enum NodeRefMut<'a> { + Inner(&'a mut InnerNode), + Leaf(&'a mut LeafNode), +} + +impl AnyNode { + pub fn case(&self) -> Option { + match NodeTag::from_u8(self.tag)? { + NodeTag::InnerNode => Some(NodeRef::Inner(cast_ref(self))), + NodeTag::LeafNode => Some(NodeRef::Leaf(cast_ref(self))), + _ => None, + } + } + + pub fn case_mut(&mut self) -> Option { + match NodeTag::from_u8(self.tag)? { + NodeTag::InnerNode => Some(NodeRefMut::Inner(cast_mut(self))), + NodeTag::LeafNode => Some(NodeRefMut::Leaf(cast_mut(self))), + _ => None, + } + } + + pub fn key(&self) -> Option { + Some(match self.case()? { + NodeRef::Inner(inner) => inner.key, + NodeRef::Leaf(leaf) => leaf.key, + }) + } + + pub fn children(&self) -> Option<[NodeHandle; 2]> { + match self.case()? { + NodeRef::Inner(inner) => Some(inner.children), + NodeRef::Leaf(_) => None, + } + } + + pub fn as_leaf(&self) -> Option<&LeafNode> { + match self.case() { + Some(NodeRef::Leaf(leaf)) => Some(leaf), + _ => None, + } + } + + pub fn as_leaf_mut(&mut self) -> Option<&mut LeafNode> { + match self.case_mut() { + Some(NodeRefMut::Leaf(leaf)) => Some(leaf), + _ => None, + } + } + + pub fn as_inner_mut(&mut self) -> Option<&mut InnerNode> { + match self.case_mut() { + Some(NodeRefMut::Inner(inner)) => Some(inner), + _ => None, + } + } +} + +impl AsRef for InnerNode { + fn as_ref(&self) -> &AnyNode { + cast_ref(self) + } +} + +impl AsRef for LeafNode { + fn as_ref(&self) -> &AnyNode { + cast_ref(self) + } +} + +impl FreeNode { + pub(crate) fn new(tag: NodeTag, next: NodeHandle) -> Self { + Self { + tag: tag as u8, + padding: [0; 3], + next, + reserved: [0; NODE_SIZE - 16], + force_align: 0, + } + } +} diff --git a/defi/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs b/defi/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs new file mode 100644 index 00000000..50b13aa5 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs @@ -0,0 +1,338 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// Trimmed from the upstream ordertree.rs: +// - dropped expiry tracking (no time-in-force orders here) +// - dropped `remove_one_expired` / `find_earliest_expiry` +// - error type rebased to this crate's `ErrorCode` +// The tree mechanics (critbit / radix-trie insert + remove, min/max walks) +// are preserved so future contributors can diff against upstream cleanly. + +use anchor_lang::prelude::*; +use bytemuck::{cast, cast_ref}; +use static_assertions::const_assert_eq; + +use super::nodes::{AnyNode, FreeNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag}; +use crate::errors::ErrorCode; + +/// Per-side slab capacity. 1024 leaves easily covers any realistic depth at +/// the prices a single market quotes; the 88-byte node size keeps each side +/// at ~90 KB, well under Solana's 10 MB per-account ceiling. +pub const MAX_TREE_NODES: usize = 1024; + +/// Root pointer + leaf count for one side of the book. +/// +/// `maybe_node` is only meaningful when `leaf_count > 0` β€” a freshly-zeroed +/// root represents an empty tree. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct OrderTreeRoot { + pub maybe_node: NodeHandle, + pub leaf_count: u32, +} +const_assert_eq!(std::mem::size_of::(), 8); + +impl OrderTreeRoot { + pub fn node(&self) -> Option { + if self.leaf_count == 0 { + None + } else { + Some(self.maybe_node) + } + } +} + +/// Which side of the book this tree represents. +/// +/// Iteration order is determined by this: bids walk highest-key-first (best +/// bid first), asks walk lowest-key-first (best ask first). Since price is +/// stored in the high bits of the key, that's also best-price-first on both +/// sides. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum OrderTreeType { + Bids = 0, + Asks = 1, +} + +/// The slab itself. A fixed-size array of nodes plus the bookkeeping needed +/// to allocate and free slots in O(1): +/// - `bump_index` is the next never-used slot (used when the free list is +/// empty) +/// - `free_list_head` + `free_list_len` chain reclaimed slots, so cancels +/// reuse memory without compaction. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct OrderTreeNodes { + /// `OrderTreeType`, as a u8 so the struct stays Pod. + pub order_tree_type: u8, + pub padding: [u8; 3], + pub bump_index: u32, + pub free_list_len: u32, + pub free_list_head: NodeHandle, + pub nodes: [AnyNode; MAX_TREE_NODES], +} +const_assert_eq!( + std::mem::size_of::(), + 1 + 3 + 4 + 4 + 4 + 88 * MAX_TREE_NODES +); + +impl OrderTreeNodes { + pub fn order_tree_type(&self) -> OrderTreeType { + match self.order_tree_type { + 0 => OrderTreeType::Bids, + 1 => OrderTreeType::Asks, + _ => panic!("invalid order_tree_type"), + } + } + + pub fn node(&self, handle: NodeHandle) -> Option<&AnyNode> { + let node = &self.nodes[handle as usize]; + match NodeTag::from_u8(node.tag) { + Some(NodeTag::InnerNode) | Some(NodeTag::LeafNode) => Some(node), + _ => None, + } + } + + pub fn node_mut(&mut self, handle: NodeHandle) -> Option<&mut AnyNode> { + let node = &mut self.nodes[handle as usize]; + match NodeTag::from_u8(node.tag) { + Some(NodeTag::InnerNode) | Some(NodeTag::LeafNode) => Some(node), + _ => None, + } + } + + /// Best-priced leaf for this tree. + /// + /// For asks ("min" means lowest price) this is the leftmost leaf; for + /// bids ("max" means highest price) this is the rightmost leaf. + pub fn best_leaf(&self, root: &OrderTreeRoot) -> Option<(NodeHandle, &LeafNode)> { + let find_max = self.order_tree_type() == OrderTreeType::Bids; + self.leaf_min_max(find_max, root) + } + + fn leaf_min_max( + &self, + find_max: bool, + root: &OrderTreeRoot, + ) -> Option<(NodeHandle, &LeafNode)> { + let mut node_handle: NodeHandle = root.node()?; + let critbit = usize::from(find_max); + loop { + match self.node(node_handle)?.case()? { + NodeRef::Inner(inner) => node_handle = inner.children[critbit], + NodeRef::Leaf(leaf) => return Some((node_handle, leaf)), + } + } + } + + /// Look up a leaf by its full 128-bit key. + pub fn find_by_key(&self, root: &OrderTreeRoot, search_key: u128) -> Option<(NodeHandle, &LeafNode)> { + let mut handle = root.node()?; + loop { + let node = self.node(handle)?; + match node.case()? { + NodeRef::Inner(inner) => { + let (next, _) = inner.walk_down(search_key); + handle = next; + } + NodeRef::Leaf(leaf) => { + if leaf.key == search_key { + return Some((handle, leaf)); + } + return None; + } + } + } + } + + pub fn remove_by_key( + &mut self, + root: &mut OrderTreeRoot, + search_key: u128, + ) -> Option { + // Stack of (handle, critbit) pairs so we can walk back up to the + // root after splicing the leaf out β€” same trick the upstream uses. + let mut stack: Vec<(NodeHandle, bool)> = vec![]; + + let mut parent_h = root.node()?; + let (mut child_h, mut crit_bit) = match self.node(parent_h)?.case()? { + NodeRef::Leaf(&leaf) if leaf.key == search_key => { + // Special case: the root is the matching leaf. Clear root, + // free the slot. + assert_eq!(root.leaf_count, 1); + root.maybe_node = 0; + root.leaf_count = 0; + let _ = self.free(parent_h)?; + return Some(leaf); + } + NodeRef::Leaf(_) => return None, + NodeRef::Inner(inner) => inner.walk_down(search_key), + }; + stack.push((parent_h, crit_bit)); + + loop { + match self.node(child_h)?.case()? { + NodeRef::Inner(inner) => { + parent_h = child_h; + let (next, bit) = inner.walk_down(search_key); + child_h = next; + crit_bit = bit; + stack.push((parent_h, crit_bit)); + } + NodeRef::Leaf(leaf) => { + if leaf.key != search_key { + return None; + } + break; + } + } + } + + // Replace the parent inner-node with its remaining child. We free + // both the old parent slot's content and the child slot. + let other_child_h = self.node(parent_h)?.children()?[!crit_bit as usize]; + let other_child = self.free(other_child_h)?; + *self.nodes.get_mut(parent_h as usize)? = other_child; + + root.leaf_count -= 1; + let removed: LeafNode = cast(self.free(child_h)?); + Some(removed) + } + + /// Move the slot at `handle` onto the free list, return whatever was + /// there. + fn free(&mut self, handle: NodeHandle) -> Option { + let val = *self.node(handle)?; + let tag = if self.free_list_len == 0 { + NodeTag::LastFreeNode + } else { + NodeTag::FreeNode + }; + self.nodes[handle as usize] = cast(FreeNode::new(tag, self.free_list_head)); + self.free_list_len += 1; + self.free_list_head = handle; + Some(val) + } + + /// Allocate a slot and write `val` into it. Reuses a free-list slot when + /// one's available, otherwise consumes the next bump slot. + fn alloc(&mut self, val: &AnyNode) -> Result { + match NodeTag::from_u8(val.tag) { + Some(NodeTag::InnerNode) | Some(NodeTag::LeafNode) => (), + _ => return err!(ErrorCode::OrderBookFull), + }; + + if self.free_list_len == 0 { + require!( + (self.bump_index as usize) < self.nodes.len() && self.bump_index < u32::MAX, + ErrorCode::OrderBookFull + ); + self.nodes[self.bump_index as usize] = *val; + let handle = self.bump_index; + self.bump_index += 1; + return Ok(handle); + } + + let handle = self.free_list_head; + let next = cast_ref::(&self.nodes[handle as usize]).next; + self.free_list_head = next; + self.free_list_len -= 1; + self.nodes[handle as usize] = *val; + Ok(handle) + } + + /// Insert `new_leaf` into the tree rooted at `root`. + /// + /// Returns the handle of the new leaf and, when a duplicate key collided, + /// the leaf that got overwritten. (Callers in this order book embed a + /// monotonically increasing seq_num in every key, so collisions cannot + /// actually happen β€” the case is kept just to match the upstream API.) + pub fn insert_leaf( + &mut self, + root: &mut OrderTreeRoot, + new_leaf: &LeafNode, + ) -> Result<(NodeHandle, Option)> { + // Empty tree: leaf becomes the root. + let mut parent_handle: NodeHandle = match root.node() { + Some(h) => h, + None => { + let handle = self.alloc(new_leaf.as_ref())?; + root.maybe_node = handle; + root.leaf_count = 1; + return Ok((handle, None)); + } + }; + + let mut stack: Vec<(NodeHandle, bool)> = vec![]; + + loop { + let parent_contents = *self + .node(parent_handle) + .ok_or_else(|| error!(ErrorCode::OrderBookFull))?; + let parent_key = parent_contents.key().unwrap(); + + // Exact-key collision: only possible if the existing slot is a + // leaf (inner nodes' `key` is just a shared prefix). Overwrite + // and bail. + if parent_key == new_leaf.key { + if let Some(NodeRef::Leaf(&old_leaf)) = parent_contents.case() { + *self.node_mut(parent_handle).unwrap() = *new_leaf.as_ref(); + return Ok((parent_handle, Some(old_leaf))); + } + } + + let shared_prefix_len: u32 = (parent_key ^ new_leaf.key).leading_zeros(); + + if let Some(NodeRef::Inner(inner)) = parent_contents.case() { + if shared_prefix_len >= inner.prefix_len { + // The new key shares at least this node's prefix β€” + // descend. + let (child, crit_bit) = inner.walk_down(new_leaf.key); + stack.push((parent_handle, crit_bit)); + parent_handle = child; + continue; + } + } + + // Split: parent (leaf or inner with shorter shared prefix) and + // new leaf disagree at bit `shared_prefix_len`. Replace parent + // in place with a new InnerNode that has them both as children. + let crit_bit_mask: u128 = 1u128 << (127 - shared_prefix_len); + let new_leaf_crit_bit = (crit_bit_mask & new_leaf.key) != 0; + let old_parent_crit_bit = !new_leaf_crit_bit; + + let new_leaf_handle = self.alloc(new_leaf.as_ref())?; + let moved_parent_handle = match self.alloc(&parent_contents) { + Ok(h) => h, + Err(error) => { + // alloc rolled back from the failed half-insert. + let _ = self.free(new_leaf_handle); + return Err(error); + } + }; + + // The slot at `parent_handle` currently holds a LeafNode (or an + // InnerNode whose prefix is too long for the new key). We're + // replacing it with a freshly-built InnerNode that has the new + // leaf and the moved-aside old node as children. We can't go via + // `node_mut().as_inner_mut()` here because that would refuse the + // slot when its tag is still LeafNode β€” instead, write a complete + // new InnerNode bit-pattern into the slot via AnyNode. + let mut new_inner = InnerNode::new(shared_prefix_len, new_leaf.key); + new_inner.children[new_leaf_crit_bit as usize] = new_leaf_handle; + new_inner.children[old_parent_crit_bit as usize] = moved_parent_handle; + self.nodes[parent_handle as usize] = *new_inner.as_ref(); + + // `stack` not needed past this point; suppress unused-mut. + let _ = stack; + + root.leaf_count += 1; + return Ok((new_leaf_handle, None)); + } + } + + pub fn is_full(&self) -> bool { + self.free_list_len <= 1 && (self.bump_index as usize) >= self.nodes.len() - 1 + } +} diff --git a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs new file mode 100644 index 00000000..89c1d0cf --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -0,0 +1,1985 @@ +//! LiteSVM tests for the order-book 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_instruction, system_program, + }, + Discriminator, 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/order_book/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_SEED: &[u8] = b"order"; +const MARKET_USER_SEED: &[u8] = b"market_user"; + +// Size of the zero-copy OrderBook account, including Anchor's 8-byte +// discriminator. Mirrors `order_book::state::ORDER_BOOK_ACCOUNT_SIZE` β€” duplicated +// here so tests are self-contained and stay closer to what an SDK does. +// Two 1024-leaf critbit slabs at 88 bytes per node, plus header. If you +// change this, bump the constant in `state/order_book.rs` too β€” the +// `#[account(zero)]` check fails if the account size is wrong. +const ORDER_BOOK_ACCOUNT_SIZE: u64 = order_book::state::ORDER_BOOK_ACCOUNT_SIZE as u64; + +// 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_pda(program_id: &Pubkey, base_mint: &Pubkey, quote_mint: &Pubkey) -> Pubkey { + let (market, _) = Pubkey::find_program_address( + &[MARKET_SEED, base_mint.as_ref(), quote_mint.as_ref()], + program_id, + ); + market +} + +fn market_user_pda(program_id: &Pubkey, market: &Pubkey, owner: &Pubkey) -> Pubkey { + let (market_user, _) = Pubkey::find_program_address( + &[MARKET_USER_SEED, market.as_ref(), owner.as_ref()], + program_id, + ); + market_user +} + +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, + // The order book is a ~180 KB zero-copy account owned by the program. + // It's NOT a PDA β€” the BPF runtime caps inner-CPI allocations at 10 KB, + // so the client must allocate it directly via system_program::CreateAccount + // and pass it in as a signer. See `build_initialize_market_tx` for the + // full setup. + order_book: Keypair, + buyer_base_ata: Pubkey, + buyer_quote_ata: Pubkey, + seller_base_ata: Pubkey, + seller_quote_ata: Pubkey, + buyer_market_user: Pubkey, + seller_market_user: Pubkey, +} + +fn full_setup() -> Scenario { + let program_id = order_book::id(); + let mut svm = LiteSVM::new(); + let program_bytes = include_bytes!("../../../target/deploy/order_book.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 order-book 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 = market_pda(&program_id, &base_mint, "e_mint); + let buyer_market_user = market_user_pda(&program_id, &market, &buyer.pubkey()); + let seller_market_user = market_user_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(); + let order_book = 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_market_user, + seller_market_user, + } +} + +// --------------------------------------------------------------------------- +// Instruction builders β€” one per program entry point. +// --------------------------------------------------------------------------- + +/// Build the `system_program::CreateAccount` instruction the client must run +/// to allocate the ~180 KB OrderBook account before calling +/// `initialize_market`. The new account is owned by the order-book program and +/// zero-initialized; the program then runs `load_init` against it in the +/// next instruction. +/// +/// Rent is whatever LiteSVM's bank quotes for that size at the current +/// rent rate; we use `minimum_balance` so the account is rent-exempt. +fn build_create_order_book_account_ix( + sc: &Scenario, + payer: &Pubkey, +) -> Instruction { + // LiteSVM uses the default rent schedule; minimum_balance() on the + // 180 KB account is around 1.25 SOL β€” well within the 100 SOL we fund + // the test payer with in `full_setup`. + let rent_lamports = sc + .svm + .minimum_balance_for_rent_exemption(ORDER_BOOK_ACCOUNT_SIZE as usize); + system_instruction::create_account( + payer, + &sc.order_book.pubkey(), + rent_lamports, + ORDER_BOOK_ACCOUNT_SIZE, + &sc.program_id, + ) +} + +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, + &order_book::instruction::InitializeMarket { + fee_basis_points, + tick_size, + min_order_size, + } + .data(), + order_book::accounts::InitializeMarket { + market: sc.market, + order_book: sc.order_book.pubkey(), + 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_market_user_ix(sc: &Scenario, owner: &Pubkey) -> Instruction { + let market_user = market_user_pda(&sc.program_id, &sc.market, owner); + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::CreateMarketUser {}.data(), + order_book::accounts::CreateMarketUser { + market_user, + 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, + market_user: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, + side: order_book::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, + &order_book::instruction::PlaceOrder { + side, + price, + quantity, + } + .data(), + order_book::accounts::PlaceOrder { + market: sc.market, + order_book: sc.order_book.pubkey(), + order, + market_user, + 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, market_user) PDA +/// pairs appended as remaining accounts. The order-book program 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 MarketUser +/// (unsettled_* and open_orders). +#[allow(clippy::too_many_arguments)] +fn build_place_order_with_makers_ix( + sc: &Scenario, + owner: &Keypair, + market_user: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, + side: order_book::state::OrderSide, + order_id: u64, + price: u64, + quantity: u64, + maker_pairs: &[(u64, Pubkey)], +) -> Instruction { + let mut ix = build_place_order_ix( + sc, + owner, + market_user, + user_base_account, + user_quote_account, + side, + order_id, + price, + quantity, + ); + + for (maker_order_id, maker_market_user) 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_market_user, false)); + } + + ix +} + +fn build_withdraw_fees_ix( + sc: &Scenario, + authority_quote_account: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::WithdrawFees {}.data(), + order_book::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, + market_user: Pubkey, + order_id: u64, +) -> Instruction { + let order = order_pda(&sc.program_id, &sc.market, order_id); + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::CancelOrder {}.data(), + order_book::accounts::CancelOrder { + market: sc.market, + order_book: sc.order_book.pubkey(), + order, + market_user, + owner: *owner, + } + .to_account_metas(None), + ) +} + +fn build_settle_funds_ix( + sc: &Scenario, + owner: &Pubkey, + market_user: Pubkey, + user_base_account: Pubkey, + user_quote_account: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::SettleFunds {}.data(), + order_book::accounts::SettleFunds { + market: sc.market, + market_user, + 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) { + // Allocate the OrderBook account first β€” it has to exist (owned by the + // program, zero-initialized) before initialize_market's `#[account(zero)]` + // check passes. + let create_ix = build_create_order_book_account_ix(sc, &sc.authority.pubkey()); + let init_ix = build_initialize_market_ix(sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, init_ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ) + .unwrap(); + + let buyer_ix = build_create_market_user_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_market_user_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 create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, ix], + &[ + &sc.authority, + &sc.order_book, + &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.pubkey()) + .expect("order book account missing"); + assert_eq!(order_book_account.owner, sc.program_id); + // The 8-byte discriminator should now be set (init handler ran). + assert_eq!( + &order_book_account.data[..8], + order_book::state::OrderBook::DISCRIMINATOR + ); + + // 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_market_user_tracks_market_and_owner() { + let mut sc = full_setup(); + + let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + let init_ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, init_ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_market_user_ix(&sc, &sc.buyer.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + let market_user = sc + .svm + .get_account(&sc.buyer_market_user) + .expect("user account PDA missing"); + assert_eq!(market_user.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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::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 create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + 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![create_ix, init_ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_market_user_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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::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 create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + 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![create_ix, init_ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ) + .unwrap(); + + let create_ix = build_create_market_user_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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + 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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::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_market_user, + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::state::OrderSide::Ask, + ask_order_id, + ASK_PRICE, + ASK_QUANTITY, + ); + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + 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_market_user, + 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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + bid_order_id, + BID_PRICE, + BID_QUANTITY, + ); + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_market_user, + bid_order_id, + ); + let settle_ix = build_settle_funds_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_market_user, + 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 + ); +} + +// Regression test for the fee-drain attack on settle_funds. Pre-fix, +// `SettleFunds` did not bind `quote_vault` to `market.quote_vault` via +// `has_one`, so a caller could pass `market.fee_vault` (same mint and +// same authority) where `quote_vault` was expected and drain accumulated +// taker fees while spending their own unsettled_quote credit. The +// has_one constraint now bound on the `market` field must surface this +// as `ConstraintHasOne` (anchor error 2001) before any transfer runs. +#[test] +fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // Earn the buyer some unsettled_quote: place a bid (locks quote in + // the real quote_vault) and immediately cancel it (credits the + // buyer's unsettled_quote). settle_funds would normally drain the + // quote_vault to the buyer's ATA. + let bid_order_id = 1u64; + let place_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + bid_order_id, + BID_PRICE, + BID_QUANTITY, + ); + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.buyer.pubkey(), + sc.buyer_market_user, + bid_order_id, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![place_ix, cancel_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .unwrap(); + + // Build a settle_funds ix but swap fee_vault in for quote_vault. + // Everything else (base_vault, mints, user accounts, owner) stays + // correct, so the only thing that should reject this is the new + // has_one constraint on the market PDA. + let attack_ix = Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::SettleFunds {}.data(), + order_book::accounts::SettleFunds { + market: sc.market, + market_user: sc.buyer_market_user, + base_vault: sc.base_vault.pubkey(), + // Attack: route the quote-side transfer at the fee_vault. + quote_vault: sc.fee_vault.pubkey(), + user_base_account: sc.buyer_base_ata, + user_quote_account: sc.buyer_quote_ata, + base_mint: sc.base_mint, + quote_mint: sc.quote_mint, + owner: sc.buyer.pubkey(), + token_program: token_program_id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![attack_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ); + assert!( + result.is_err(), + "settle_funds must reject fee_vault substituted for quote_vault" + ); +} + +#[test] +fn initialize_market_rejects_zero_tick_size() { + let mut sc = full_setup(); + + let zero_tick_size: u64 = 0; + let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + 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![create_ix, ix], + &[ + &sc.authority, + &sc.order_book, + &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 create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + 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![create_ix, ix], + &[ + &sc.authority, + &sc.order_book, + &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. +// --------------------------------------------------------------------------- + +// MarketUser field offsets after the 8-byte Anchor discriminator. Layout +// (see programs/order_book/src/state/market_user.rs): +// market: Pubkey (32) +// owner: Pubkey (32) +// unsettled_base: u64 (8) +// unsettled_quote: u64 (8) +// ... +// Borsh-decoding manually (rather than pulling MarketUser 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, market_user: &Pubkey) -> (u64, u64) { + let data = svm + .get_account(market_user) + .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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user); + 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_market_user); + 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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::state::OrderSide::Ask, + TAKER_ASK_ID, + PRICE, + QUANTITY, + &[(MAKER_BID_ID, sc.buyer_market_user)], + ); + 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_market_user); + 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_market_user); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + TAKER_BID_QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + TAKER_BID_QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + TAKER_BID_PRICE, + TAKER_BID_QUANTITY, + &[ + (BEST_ASK_ID, sc.seller_market_user), + (SECOND_ASK_ID, sc.seller_market_user), + ], + ); + 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_market_user); + 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_market_user); + 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_market_user = market_user_pda(&sc.program_id, &sc.market, &second_seller.pubkey()); + let __ix1 = build_create_market_user_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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + second_seller_base_ata, + second_seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + ASK_PRICE_SHARED, + ASK_QUANTITY_EACH, + &[(FIRST_ASK_ID, sc.seller_market_user)], + ); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + TAKER_BID_PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user); + 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_market_user); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::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_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + 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_market_user, + 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_market_user, + 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 + ); +} +