|
| 1 | +# Order Book — Central Limit Order Book (CLOB), Quasar port |
| 2 | + |
| 3 | +A [central limit order book (CLOB)](https://www.investopedia.com/terms/l/limitorderbook.asp) — the market |
| 4 | +structure NYSE, NASDAQ, CME, and onchain venues like Phoenix and OpenBook run on. Users post buy or sell |
| 5 | +offers at prices they pick; the program matches crossing offers in **price-time priority** and settles the |
| 6 | +resulting token movements. |
| 7 | + |
| 8 | +This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor example in |
| 9 | +[`../anchor`](../anchor). Quasar is a zero-copy, `no_std`, zero-allocation Solana framework with Anchor-like |
| 10 | +syntax (`#[program]`, `#[derive(Accounts)]`, `#[account]`) that compiles to a much smaller binary. Both builds |
| 11 | +use the **same program ID** (`C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx`), so offchain tooling and PDA |
| 12 | +derivations work against either unchanged. The Anchor README carries the full conceptual walkthrough; this one |
| 13 | +focuses on how the program works and what the Quasar port does differently. |
| 14 | + |
| 15 | +## What the program does |
| 16 | + |
| 17 | +Two users want to swap tokens at prices they each chose. **Alice** holds USDC (the *quote* mint — the pricing |
| 18 | +unit) and wants to buy NVDAx (the *base* mint — the asset being priced), but only at 900 USDC/share or lower. |
| 19 | +**Bob** holds NVDAx and will sell at 900 or higher. They post a **bid** and an **ask**; when the prices cross, |
| 20 | +the program fills them. |
| 21 | + |
| 22 | +- The party whose order **rests** on the book first is the **maker**. |
| 23 | +- The party whose order arrives and **crosses** the resting order is the **taker**. |
| 24 | + |
| 25 | +A fill always clears at the **maker's** posted price (standard CLOB rule): the taker gets price improvement |
| 26 | +versus their own limit. The taker pays a fee (in basis points) routed to a fee vault; the maker's proceeds are |
| 27 | +credited net of that fee. |
| 28 | + |
| 29 | +Funds are held in **program-owned vaults** (the market PDA is their token authority) from the moment an order |
| 30 | +locks them until settlement. There is no admin escape hatch — the market authority can only withdraw |
| 31 | +accumulated **fees**, never user balances. The deployed program bytecode is the only thing that can move vault |
| 32 | +funds, and it moves them only along the place / cancel / settle paths below. |
| 33 | + |
| 34 | +## Accounts and PDAs |
| 35 | + |
| 36 | +| Account | Kind | Seeds | Role | |
| 37 | +| --- | --- | --- | --- | |
| 38 | +| `Market` | PDA | `["market", base_mint, quote_mint]` | One trading pair. Stores config + vault addresses. Its PDA is the vaults' token authority. | |
| 39 | +| `OrderBook` | keypair account | — (not a PDA) | Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. | |
| 40 | +| `MarketUser` | PDA | `["market_user", market, owner]` | Per-user, per-market. Tracks open order ids and `unsettled_*` balances owed back to the user. | |
| 41 | +| `Order` | PDA | `["order", market, order_id]` | One order. `order_id` is the book's monotonic counter at placement time. | |
| 42 | +| `base_vault` / `quote_vault` | token accounts | — | Hold locked funds while orders are open. Market PDA is the authority. | |
| 43 | +| `fee_vault` | token account | — | Accumulates taker fees (quote mint). Kept separate so user balances and fees can't be confused. | |
| 44 | + |
| 45 | +The order book is **not** a PDA. Solana caps inner-CPI account allocations at 10 KB, so a ~180 KB account can't |
| 46 | +be created with an `init` constraint — the client calls `system_program::create_account` directly (sizing it to |
| 47 | +`ORDER_BOOK_ACCOUNT_SIZE`, program-owned, zeroed) and passes it to `initialize_market`, which verifies and |
| 48 | +initializes it in place. |
| 49 | + |
| 50 | +## The two-lot pricing model |
| 51 | + |
| 52 | +Both sides of the book are denominated in **lots**, mirroring Serum/OpenBook, so `price` and `quantity` read |
| 53 | +as human numbers regardless of each mint's decimals: |
| 54 | + |
| 55 | +``` |
| 56 | +raw_base = quantity × base_lot_size |
| 57 | +raw_quote = quantity × price × quote_lot_size |
| 58 | +``` |
| 59 | + |
| 60 | +Choose `base_lot_size = 10^max(d_base − d_quote, 0)` and `quote_lot_size = 10^max(d_quote − d_base, 0)`. For |
| 61 | +NVDAx (9 decimals) / USDC (6 decimals): `base_lot_size = 1000`, `quote_lot_size = 1`, so `price = 100` means |
| 62 | +100 USDC-units per base lot and `tick_size = 1` is one atomic increment. |
| 63 | + |
| 64 | +## Instruction lifecycle |
| 65 | + |
| 66 | +| Handler | What it does | |
| 67 | +| --- | --- | |
| 68 | +| `initialize_market` | Create the `Market` PDA, the two vaults, and the fee vault; initialize the pre-created order-book account. | |
| 69 | +| `create_market_user` | Create a caller's `MarketUser` for a market. | |
| 70 | +| `place_order` | Lock funds, cross the opposing side in price-time priority, credit fills to maker/taker `unsettled_*`, route the taker fee, and rest any remainder. | |
| 71 | +| `cancel_order` | Credit an open order's locked remainder back to the owner's `unsettled_*` and remove it from the book. | |
| 72 | +| `settle_funds` | Move a user's `unsettled_*` balances out of the vaults into their token accounts. | |
| 73 | +| `withdraw_fees` | Authority-only: drain the fee vault to the authority's token account. | |
| 74 | + |
| 75 | +`place_order` takes `side` (`0` = bid, `1` = ask), `price`, `quantity`, and `order_id`. The caller passes the |
| 76 | +resting maker orders to cross as **remaining accounts**, in pairs of `(maker_order, maker_market_user)`, in the |
| 77 | +book's price-time priority. `order_id` must equal the book's current `next_order_id` (the program verifies it), |
| 78 | +so the client derives the `Order` PDA deterministically. |
| 79 | + |
| 80 | +Fills never transfer tokens directly to the counterparty — they credit `unsettled_*` balances that each user |
| 81 | +drains later via `settle_funds`. This keeps the per-fill account footprint small (no maker ATAs in the fill |
| 82 | +path), as in OpenBook v2. |
| 83 | + |
| 84 | +## The matching engine |
| 85 | + |
| 86 | +Each side of the book is a **critbit tree** (radix trie) ported from |
| 87 | +[openbook-v2](https://github.com/openbook-dex/openbook-v2) (MIT; see `src/state/slab/LICENSE-OPENBOOK`). Leaves |
| 88 | +carry the resting order's price, remaining quantity, owner, and id; a 128-bit key packs `[price][seq_num]` so an |
| 89 | +in-order walk yields best-price-first, and within a price level the sequence number preserves time priority. |
| 90 | +Insert, remove, and best-price lookup are all sub-linear, so matching stays cheap as depth grows. |
| 91 | + |
| 92 | +## What the Quasar port does differently |
| 93 | + |
| 94 | +The trading logic, fee model, lot math, and matching engine are identical to the Anchor build. The differences |
| 95 | +are all consequences of Quasar being zero-copy, `no_std`, and zero-allocation: |
| 96 | + |
| 97 | +- **The order book is accessed by casting raw account bytes.** Anchor uses `AccountLoader<OrderBook>`; Quasar |
| 98 | + has no `AccountLoader`, so `initialize_market` / `place_order` / `cancel_order` cast the account's byte region |
| 99 | + to `&mut OrderBook` with `bytemuck` (an 8-byte discriminator guards against a wrong account). The slab code is |
| 100 | + otherwise a near-verbatim port. |
| 101 | +- **`MarketUser.open_orders` is a fixed `[u8; 160]` (20 packed u64s) plus a length**, not a borsh `Vec<u64>`. A |
| 102 | + resizable field would make the account dynamic, which is awkward to mutate on the maker side where the account |
| 103 | + arrives as a remaining account. The fixed buffer keeps every mutation an in-place write. |
| 104 | +- **The slab is heap-free.** Its transient traversal stack is a fixed array (a critbit tree over 128-bit keys is |
| 105 | + at most 128 nodes deep), and two write-only stacks the upstream carried were dropped. A single `place_order` |
| 106 | + crosses at most `MAX_FILLS` (16) resting orders; any remainder rests on the book for a later order to cross. |
| 107 | +- **`side` and order status/side enums are stored as `u8`** (zero-copy accounts hold POD scalars). The `u8` |
| 108 | + wire encoding of `side` matches the Anchor build's borsh enum discriminant. |
| 109 | + |
| 110 | +## Safety and custody |
| 111 | + |
| 112 | +- Every vault transfer out is signed by the **market PDA** via `invoke_signed`; only the deployed program can |
| 113 | + move locked funds. |
| 114 | +- `settle_funds` zeroes a user's `unsettled_*` **before** transferring (checks-effects-interactions), so no |
| 115 | + token-hook re-entry could double-withdraw. |
| 116 | +- `place_order` binds every market-owned account (`base_vault`, `quote_vault`, `fee_vault`, both mints, the |
| 117 | + order book) to the addresses stored on the `Market` PDA with `has_one`, so a caller can't substitute the fee |
| 118 | + vault for a user vault and drain fees. |
| 119 | +- Taker fees use **ceiling** division, rounding in the protocol's favor so many tiny fills can't leak a minor |
| 120 | + unit to the maker. |
| 121 | + |
| 122 | +## Building and testing |
| 123 | + |
| 124 | +Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) (for `cargo build-sbf`) and the |
| 125 | +[Quasar CLI](https://github.com/blueshift-gg/quasar): |
| 126 | + |
| 127 | +```sh |
| 128 | +cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked |
| 129 | +quasar build # compiles the program to target/deploy/quasar_order_book.so |
| 130 | +cargo test # QuasarSVM integration tests (they load the compiled .so) |
| 131 | +``` |
| 132 | + |
| 133 | +`quasar build` must run before `cargo test`: the tests load the compiled `.so` into |
| 134 | +[QuasarSVM](https://github.com/blueshift-gg/quasar-svm), an in-process SVM, via `include`/`fs::read`. The suite |
| 135 | +in `src/tests.rs` drives the full lifecycle — initialize a market, create users, rest an ask, cross it with a |
| 136 | +bid, settle both sides, and withdraw the fee — asserting on-chain state, token balances, and fee accounting at |
| 137 | +each step, plus an authorization rejection. |
| 138 | + |
| 139 | +## Extending |
| 140 | + |
| 141 | +- **Self-trade prevention:** reject or cancel-back when a taker would cross their own resting order. |
| 142 | +- **Post-only / IOC / FOK** order types by gating the rest-vs-cancel behaviour on a flag. |
| 143 | +- **Multiple fee tiers** keyed on the taker's `MarketUser`. |
| 144 | +- **Market pause/resume** by flipping `Market.is_active` from an authority-gated handler. |
0 commit comments