diff --git a/.gitignore b/.gitignore index d137957d..357e1aad 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ node_modules/ /target deploy -.claude +.claude/* +# Exception: skills are shared team resources +!.claude/skills/ +!.claude/skills/** 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..2265ae72 --- /dev/null +++ b/defi/order-book/anchor/README.md @@ -0,0 +1,1639 @@ +# Order Book โ€” Central Limit Order Book (CLOB) + +This is an **[order book](https://www.investopedia.com/terms/o/order-book.asp)** โ€” specifically, a **[central limit order +book (CLOB)](https://www.investopedia.com/terms/o/order-book.asp)**, 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. + +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) +- [A real-world walkthrough: NVDAx/USDC](#a-real-world-walkthrough-nvdaxusdc) +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) +- [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance) +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 **USDC** (the *[quote](https://www.investopedia.com/terms/q/quotecurrency.asp)* mint โ€” the pricing unit, the way USD + is the pricing unit in "NVDAx is $950") and wants to buy **NVDAx** + (the *[base](https://www.investopedia.com/terms/b/basecurrency.asp)* mint โ€” the asset being priced), but only if she can + get NVDAx at 900 USDC per share or lower. +- Bob holds **NVDAx** and wants USDC, but only if he can get at least + 950 USDC per NVDAx share 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 NVDAx and willing to sell at any price โ‰ฅ 900 +USDC. She posts an ask at 900. Now Alice's bid (900 USDC) *crosses* +Carol's new ask (900 USDC) โ€” the bid is โ‰ฅ the ask. The program: + +1. Pairs them up. +2. Locks Carol's NVDAx in the program's base vault (Carol signed this + transaction, so only her funds move). +3. Allocates Alice's USDC โ€” already sitting in the quote vault since + Alice placed her bid โ€” to Carol. +4. Credits each party's unsettled balance with what they're owed, minus + a fee for the market operator. Tokens don't leave the vaults yet; + Alice and Carol each call `settle_funds` later to pull them out. + +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** account โ€” two stores: bids sorted highest-first, + asks sorted lowest-first, each holding up to 1024 entries. Rather + than a plain list of orders, each side uses a balanced tree for fast + lookup โ€” see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). + Each entry stores enough to drive matching (price, quantity, + `order_id`); the full `Order` PDA holds the authoritative state. +- 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](https://www.investopedia.com/terms/l/limitorder.asp)** is an order to trade an amount of an asset at a + specific price *or better*. A *[bid](https://www.investopedia.com/terms/b/bid.asp)* is a limit order to buy, an + *[ask](https://www.investopedia.com/terms/a/ask.asp)* 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](https://www.investopedia.com/terms/m/marketmaker.asp)** is whoever posts an order that doesn't immediately + match โ€” they "make" [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) by leaving their offer on the book + for others to trade against. A **[taker](https://www.investopedia.com/terms/m/maker_taker.asp)** is whoever walks into the + book and hits the resting orders โ€” they "take" liquidity. + +- **A [taker fee](https://www.investopedia.com/terms/m/maker_taker.asp)** is a cut of each trade taken by the venue from the + taker's leg of the trade, expressed in *[basis points](https://www.investopedia.com/terms/b/basispoint.asp)* (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](https://www.investopedia.com/terms/s/settlement.asp)** 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, not + production-ready code. +- **No [immediate-or-cancel](https://www.investopedia.com/terms/i/immediateorcancel.asp) (IOC), [fill-or-kill](https://www.investopedia.com/terms/f/fill-or-kill.asp) (FOK), or post-only orders** โ€” every + order matches what it can at the limit price and rests any remainder + on the book. IOC would discard the remainder instead of resting it; + FOK would reject the whole order unless it fills entirely; post-only + would reject the order if it would cross immediately. +- **No circuit breakers, no oracles, no price bands.** + +Solana terminology (account, PDA, CPI, bump, discriminator, signer, +lamport, ATA) is defined at . + +**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](https://www.investopedia.com/terms/p/priceimprovement.asp).** 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. + +--- + +## A real-world walkthrough: NVDAx/USDC + +This section walks through a complete sequence of trades using four real participants and two example tokens. Every instruction handler is shown with the exact accounts it creates or mutates. Read this before the technical reference sections if you are new to order books or to Solana. + +### The tokens + +| Token | What it is | Role on this market | +|---|---|---| +| **NVDAx** | A [tokenised](https://www.investopedia.com/terms/t/tokenization.asp) on-chain representation of one NVIDIA share. Its price tracks the underlying stock. | **Base asset** โ€” the thing being bought and sold | +| **USDC** | A stablecoin redeemable 1:1 for US dollars | **Quote asset** โ€” the currency used for pricing and payment | + +A price of **960** means "960 USDC per NVDAx". The same program logic โ€” identical instruction handlers and account structure โ€” works for any other pair, such as **TSLAx/USDC** (tokenised Tesla shares). + +### The participants + +| Name | Role | Motivation | +|---|---|---| +| **Maria** | Market authority | Earns 0.25 % ([25 basis points](https://www.investopedia.com/terms/b/basispoint.asp)) on every fill. Her revenue scales with market volume, so she wants a liquid, trusted venue. | +| **Alice** | Retail investor โ€” buyer | Bullish thesis: she expects NVDAx to rise from ~960 USDC to ~1 100 as demand for NVIDIA's AI chips grows. She wants to accumulate NVDAx at a good price before that move. | +| **Bob** | [Market maker](https://www.investopedia.com/terms/m/marketmaker.asp) | No directional view on NVDAx. Profits from the [bid-ask spread](https://www.investopedia.com/terms/b/bid-askspread.asp): he simultaneously quotes a buy price (bid) below fair value and a sell price (ask) above it. If both sides fill, the difference is his gross revenue. He provides [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) to the market in exchange for that spread. | +| **Carol** | Retail investor โ€” seller | Bought NVDAx at 800 USDC six months ago. It is now trading around 960. She wants to sell some to [realise her profit](https://www.investopedia.com/terms/r/realizedprofit.asp) in USDC. | + +--- + +### Step 1 โ€” Maria creates the market + +**Instruction: `initialize_market(fee_basis_points=25, tick_size=1, min_order_size=1)`** +**Key accounts: `base_mint = NVDAx`, `quote_mint = USDC`** + +Maria's wallet signs. Five accounts are created: + +| Account | Type | Seeds / notes | State after | +|---|---|---|---| +| `Market` PDA | Program data | `["market", NVDAx_mint, USDC_mint]` | `fee_bps=25`, `tick_size=1`, `is_active=true`; vault addresses recorded | +| `OrderBook` | Zero-copy slab (~180 KB) | Client-allocated (not a PDA) | Both critbit trees empty | +| `base_vault` | Token account (NVDAx) | Authority = Market PDA | 0 NVDAx | +| `quote_vault` | Token account (USDC) | Authority = Market PDA | 0 USDC | +| `fee_vault` | Token account (USDC) | Authority = Market PDA | 0 USDC | + +**No tokens move.** Maria pays the SOL rent for all five accounts. + +--- + +### Step 2 โ€” Alice, Bob, and Carol register as traders + +**Instruction: `create_market_user`** (called once by each trader) + +Each call creates one `MarketUser` PDA โ€” a per-(trader, market) account that tracks their open orders and any tokens owed to them: + +| Account | Seeds | State after | +|---|---|---| +| Alice's `MarketUser` PDA | `["market_user", market, alice_pubkey]` | `unsettled_base=0`, `unsettled_quote=0`, `open_orders=[]` | +| Bob's `MarketUser` PDA | `["market_user", market, bob_pubkey]` | same | +| Carol's `MarketUser` PDA | `["market_user", market, carol_pubkey]` | same | + +--- + +### Step 3 โ€” Bob posts a sell offer (ask) at 965 USDC + +Bob estimates NVDAx fair value at 960 USDC. He quotes a 10-USDC spread โ€” ask at 965, bid at 955. He starts by posting the ask. + +**Instruction: `place_order(side=Ask, price=965, quantity=10)`** (no `remaining_accounts` โ€” book is empty) + +**Token flow:** +``` +bob_nvdax_ata --[10 NVDAx]--> base_vault +``` + +**Accounts changed:** + +| Account | Change | +|---|---| +| `base_vault` | +10 NVDAx | +| New `Order` PDA (id=1) | `side=Ask, price=965, qty=10, status=Open` | +| `OrderBook.asks` | Leaf inserted at price 965 | +| Bob's `MarketUser.open_orders` | `[1]` | + +**Book state:** +``` +asks [(id=1, price=965, qty=10)] โ† Bob +bids [] +``` + +--- + +### Step 4 โ€” Alice places a buy offer (bid) at 950 USDC + +Alice places a [limit order](https://www.investopedia.com/terms/l/limitorder.asp): she will buy 5 NVDAx but pay no more than 950 USDC each. Her bid (950) does not cross Bob's ask (965), so nothing fills and her bid rests on the book. + +**Instruction: `place_order(side=Bid, price=950, quantity=5)`** (no `remaining_accounts`) + +**Token flow:** +``` +alice_usdc_ata --[950 ร— 5 = 4 750 USDC]--> quote_vault +``` + +**Accounts changed:** + +| Account | Change | +|---|---| +| `quote_vault` | +4 750 USDC | +| New `Order` PDA (id=2) | `side=Bid, price=950, qty=5, status=Open` | +| `OrderBook.bids` | Leaf inserted at price 950 | +| Alice's `MarketUser.open_orders` | `[2]` | + +**Book state:** +``` +asks [(id=1, price=965, qty=10)] โ† Bob +bids [(id=2, price=950, qty=5)] โ† Alice +``` + +The [bid-ask spread](https://www.investopedia.com/terms/b/bid-askspread.asp) is 965 โˆ’ 950 = 15 USDC. No trade yet. + +--- + +### Step 5 โ€” Carol sells into Alice's bid + +Carol wants to sell 3 NVDAx. Alice is bidding 950 USDC โ€” above Carol's floor of 945. Carol sends an [ask](https://www.investopedia.com/terms/a/ask.asp) at 945 and passes Alice's resting order as a maker. + +**Instruction: `place_order(side=Ask, price=945, quantity=3, remaining_accounts=[alice_order_pda, alice_market_user_pda])`** + +**Crossing check:** Carol's ask (945) โ‰ค Alice's bid (950) โœ“ โ€” the orders cross. Fill price = 950 (Alice's price โ€” the resting [maker](https://www.investopedia.com/terms/m/marketmaker.asp) always sets the execution price). Carol named 945 but receives 950 โ€” that is [price improvement](https://www.investopedia.com/terms/p/priceimprovement.asp). + +**Token flow (Carol's NVDAx locked up front):** +``` +carol_nvdax_ata --[3 NVDAx]--> base_vault +``` + +**Fill accounting (fill price = 950, fill qty = 3):** + +| Line item | Calculation | Result | +|---|---|---| +| Gross quote exchanged | 950 ร— 3 | 2 850 USDC | +| Taker fee (25 bps) | 2 850 ร— 25 / 10 000 | 7 USDC | +| Carol's net proceeds | 2 850 โˆ’ 7 | 2 843 USDC โ†’ `carol.MarketUser.unsettled_quote` | +| Alice's base received | 3 NVDAx | โ†’ `alice.MarketUser.unsettled_base` | + +**Accounts changed:** + +| Account | Change | +|---|---| +| `base_vault` | +3 NVDAx (Carol's lock) | +| `fee_vault` | +7 USDC (fee CPI from quote_vault) | +| Alice's `Order` PDA (id=2) | `filled_quantity=3`, `status=PartiallyFilled` | +| Alice's `MarketUser.unsettled_base` | +3 NVDAx | +| Alice's `MarketUser.open_orders` | `[2]` (still open โ€” 2 of 5 NVDAx remain) | +| Carol's `MarketUser.unsettled_quote` | +2 843 USDC | +| New Carol's `Order` PDA (id=3) | `side=Ask, price=945, qty=3, status=Filled` | +| `OrderBook.bids` | Alice's leaf quantity: 5 โ†’ 2 | + +**Book state:** +``` +asks [(id=1, price=965, qty=10)] โ† Bob (untouched) +bids [(id=2, price=950, qty=2)] โ† Alice (3 filled, 2 still resting) +``` + +Alice has 3 NVDAx credited to her (tracked in `unsettled_base`). Carol has 2 993 USDC credited (tracked in `unsettled_quote`). Neither amount has left the vaults yet โ€” that happens on `settle_funds`. + +--- + +### Step 6 โ€” Settlement: tokens move to wallets + +[Settlement](https://www.investopedia.com/terms/s/settlement.asp) is when the program pays out what it owes. + +**Alice calls `settle_funds`:** +``` +base_vault --[3 NVDAx]--> alice_nvdax_ata +``` +`alice.MarketUser.unsettled_base = 0` + +**Carol calls `settle_funds`:** +``` +quote_vault --[2 843 USDC]--> carol_usdc_ata +``` +`carol.MarketUser.unsettled_quote = 0` + +--- + +### Step 7 โ€” Maria sweeps fees + +**Maria calls `withdraw_fees`:** +``` +fee_vault --[7 USDC]--> maria_usdc_ata +``` +`fee_vault.balance = 0` + +--- + +### Final position + +| Participant | Paid / locked | Received | Outcome | +|---|---|---|---| +| **Alice** | 4 750 USDC (for 5 NVDAx) | 3 NVDAx + 1 900 USDC still in `quote_vault` (2-NVDAx bid resting at 950) | Thesis running; waiting for a seller at 950 to fill the rest | +| **Carol** | 3 NVDAx (cost 800 each) | 2 843 USDC | Locked in โ‰ˆ 148 USDC/NVDAx profit net of fee | +| **Bob** | 10 NVDAx locked | Nothing yet โ€” ask at 965 unfilled | Earns the spread when a buyer at 965 arrives | +| **Maria** | โ€” | 7 USDC | Fee revenue | + +Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) stays on the book. The next seller willing to part with NVDAx at 950 or below will fill it automatically. A **TSLAx/USDC** market runs the same seven steps with different mint addresses. + +--- + +## 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` | no (client-allocated) | recommended: `["order_book", market]` | program | two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id` | +| `Order` | yes | `["order", market, order_id.to_le_bytes()]` | program | owner, side, price, original_quantity, filled_quantity, status, timestamp | +| `MarketUser` | yes | `["market_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. + +### Leaf layout in the `OrderBook` slab + +Each side of the book is a critbit tree whose leaves are 88-byte +`LeafNode`s: + +```rust +pub struct LeafNode { + pub key: u128, // high 64 bits = price; low 64 = seq_num (time priority) + pub owner: Pubkey, + pub quantity: u64, // remaining quantity on this resting order + pub order_id: u64, // links to the full Order PDA + pub timestamp: i64, +} +``` + +The full order state (`filled_quantity`, `status`, `original_quantity`) +lives on the `Order` PDA. The leaf holds just enough to drive matching +(price via the key, remaining quantity) and to let offchain tooling +display age and link back to the order. + +### `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` (not a PDA โ€” client calls `system_program::create_account` + first, sized to `ORDER_BOOK_ACCOUNT_SIZE`; verified here with + `#[account(zero)]`) +- `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 `["market_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):** + +- the taker's side of the book isn't at its 1024-leaf capacity โ†’ + `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. + +### Ensuring fast order matching performance + +The book must find the best-priced resting order on every `place_order` +call. Storing orders in a plain list (`Vec`) would work at small +scale, but finding the best price requires scanning every entry โ€” in +formal notation that's **O(n)**: double the number of open orders, +double the work. + +A [balanced binary search tree](https://en.wikipedia.org/wiki/Self-balancing_binary_search_tree) +keeps both sides of the book sorted at all times, so the best price is +always at the root. Worst-case lookup, insert, and delete are +**O(logโ‚‚ n)**: at 1 024 open orders per side that's at most 10 comparisons +instead of 1 024. + +The specific data structure used here is a +[critbit tree](https://cr.yp.to/critbit.html) (short for *critical-bit +tree*) โ€” a compact binary radix trie where each internal node splits on +the first bit where two keys disagree. It has the same O(log n) bounds +as other balanced trees but operates on fixed-width integer keys, so no +rebalancing rotations are needed. This implementation is ported from +[Openbook v2](https://github.com/openbook-dex/openbook-v2); +[Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1) uses the same +approach. Both are production Solana CLOBs worth reading alongside this +example. + +### 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 โ†’ leaf removed from + the asks critbit tree. Erin's ask was partially filled โ†’ leaf's + `quantity` decremented in place to 3 (no tree rebalancing needed). + The `Order` PDA carries `filled_quantity`; the leaf just holds the + remaining quantity the matching engine needs to plan future fills. + The next taker who wants to hit Erin's ask will pass `order_2` as a + maker and see `leaf.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` | +| `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 taker's side past 1024 leaves | +| `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: + +- **Rent reclamation on `cancel_order`.** Cancelled `Order` accounts + persist onchain indefinitely; a `close_order` instruction would let + owners reclaim that rent after a cancel. +- **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. + +### Why a balanced tree (critbit)? + +**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, 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, โ€ฆ). 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. 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 the CU limit mid-match, the transaction aborts and the +placer pays fees for nothing. Worse, *legitimate users' orders fail +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. This example uses the critbit slab from +Openbook v2 (`src/state/slab/`). + +### Harder + +- **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 (critbit wrappers, allocation, apply_fill) + โ”‚ โ”œโ”€โ”€ market_user.rs + โ”‚ โ”œโ”€โ”€ matching.rs (pure fill-planning logic) + โ”‚ โ””โ”€โ”€ slab/ (critbit tree, ported from Openbook v2) + โ”‚ โ”œโ”€โ”€ mod.rs + โ”‚ โ”œโ”€โ”€ nodes.rs + โ”‚ โ”œโ”€โ”€ ordertree.rs + โ”‚ โ””โ”€โ”€ iterator.rs + โ””โ”€โ”€ tests/ + โ””โ”€โ”€ test_order_book.rs LiteSVM tests +``` 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..f2a96860 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/errors.rs @@ -0,0 +1,67 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum ErrorCode { + #[msg("Invalid price provided")] + InvalidPrice, + + #[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..f77c8972 --- /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 not a PDA; it is a plain account whose keypair the + // client generates. The README recommends deriving that keypair + // deterministically (e.g. from `["order_book", market]`) so the address + // is predictable โ€” but the program doesn't enforce the derivation. + #[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..3fa1952d --- /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 onchain 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..8963651b --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/order_book.rs @@ -0,0 +1,257 @@ +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 onchain 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 { + // Tree keys embed price in the high 64 bits โ€” we don't have the price + // at cancellation time, so we can't reconstruct the exact key without + // scanning. Linear scan to find the full key, then remove by 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..e52f1a72 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/iterator.rs @@ -0,0 +1,88 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// This iterator yields (handle, leaf) pairs in best-price-first order. +// Matching uses pure price-time priority โ€” no oracle peg or time-in-force +// filtering needed here. + +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..1caae6c0 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/nodes.rs @@ -0,0 +1,337 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// This port covers fixed-price-only orders (no oracle-pegged prices, no +// time-in-force / expiry). Order identity lives in the onchain `Order` PDA +// rather than inline fields. 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 offchain 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..5ec08653 --- /dev/null +++ b/defi/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs @@ -0,0 +1,346 @@ +// Adapted from openbook-dex/openbook-v2 (commit f3e17421e675b083b584867594bf3cf4f675d156), +// MIT-licensed. See LICENSE-OPENBOOK in this directory. +// +// This port covers fixed-price orders with no expiry tracking (no +// time-in-force orders). Error type is 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); + +// These types are internal slab implementation details that don't need a +// rich IDL representation. The default IdlBuild impls (create_type โ†’ None, +// insert_types โ†’ no-op) satisfy anchor build's IDL generation without +// exposing the raw slab internals to clients. +#[cfg(feature = "idl-build")] +impl anchor_lang::IdlBuild for OrderTreeRoot {} + +#[cfg(feature = "idl-build")] +impl anchor_lang::IdlBuild for OrderTreeNodes {} + +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 + ); +} +