diff --git a/finance/order-book/quasar/Cargo.toml b/finance/order-book/quasar/Cargo.toml new file mode 100644 index 00000000..c03dad5c --- /dev/null +++ b/finance/order-book/quasar/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "quasar-order-book" +version = "0.1.0" +edition = "2021" + +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# quasar pin rationale: matches the finance/escrow Quasar example. master HEAD +# currently fails to compile because zeropod 0.3.x auto-generates accessor +# methods that conflict with hand-written ones in quasar-spl. 623bb70 is the +# last working rev on master before that bump. Unpin (back to branch = "master") +# once upstream merges the fix. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } +# The ported Openbook slab casts a contiguous byte region to fixed-layout node +# structs. `derive` gives `Pod`/`Zeroable`; `min_const_generics` lets the +# 1024-slot `[AnyNode; N]` array derive `Pod` without hitting bytemuck's +# default 32-element array cap. +bytemuck = { version = "1.18", features = ["derive", "min_const_generics"] } +# Compile-time slab-layout asserts (node size, alignment), matching upstream. +static_assertions = "1.1" + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/order-book/quasar/Quasar.toml b/finance/order-book/quasar/Quasar.toml new file mode 100644 index 00000000..ecbd28c6 --- /dev/null +++ b/finance/order-book/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_order_book" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/order-book/quasar/README.md b/finance/order-book/quasar/README.md new file mode 100644 index 00000000..2f4857cb --- /dev/null +++ b/finance/order-book/quasar/README.md @@ -0,0 +1,144 @@ +# Order Book — Central Limit Order Book (CLOB), Quasar port + +A [central limit order book (CLOB)](https://www.investopedia.com/terms/l/limitorderbook.asp) — the market +structure NYSE, NASDAQ, CME, and onchain venues like Phoenix and OpenBook run on. Users post buy or sell +offers at prices they pick; the program matches crossing offers in **price-time priority** and settles the +resulting token movements. + +This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor example in +[`../anchor`](../anchor). Quasar is a zero-copy, `no_std`, zero-allocation Solana framework with Anchor-like +syntax (`#[program]`, `#[derive(Accounts)]`, `#[account]`) that compiles to a much smaller binary. Both builds +use the **same program ID** (`C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx`), so offchain tooling and PDA +derivations work against either unchanged. The Anchor README carries the full conceptual walkthrough; this one +focuses on how the program works and what the Quasar port does differently. + +## What the program does + +Two users want to swap tokens at prices they each chose. **Alice** holds USDC (the *quote* mint — the pricing +unit) and wants to buy NVDAx (the *base* mint — the asset being priced), but only at 900 USDC/share or lower. +**Bob** holds NVDAx and will sell at 900 or higher. They post a **bid** and an **ask**; when the prices cross, +the program fills them. + +- The party whose order **rests** on the book first is the **maker**. +- The party whose order arrives and **crosses** the resting order is the **taker**. + +A fill always clears at the **maker's** posted price (standard CLOB rule): the taker gets price improvement +versus their own limit. The taker pays a fee (in basis points) routed to a fee vault; the maker's proceeds are +credited net of that fee. + +Funds are held in **program-owned vaults** (the market PDA is their token authority) from the moment an order +locks them until settlement. There is no admin escape hatch — the market authority can only withdraw +accumulated **fees**, never user balances. The deployed program bytecode is the only thing that can move vault +funds, and it moves them only along the place / cancel / settle paths below. + +## Accounts and PDAs + +| Account | Kind | Seeds | Role | +| --- | --- | --- | --- | +| `Market` | PDA | `["market", base_mint, quote_mint]` | One trading pair. Stores config + vault addresses. Its PDA is the vaults' token authority. | +| `OrderBook` | keypair account | — (not a PDA) | Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. | +| `MarketUser` | PDA | `["market_user", market, owner]` | Per-user, per-market. Tracks open order ids and `unsettled_*` balances owed back to the user. | +| `Order` | PDA | `["order", market, order_id]` | One order. `order_id` is the book's monotonic counter at placement time. | +| `base_vault` / `quote_vault` | token accounts | — | Hold locked funds while orders are open. Market PDA is the authority. | +| `fee_vault` | token account | — | Accumulates taker fees (quote mint). Kept separate so user balances and fees can't be confused. | + +The order book is **not** a PDA. Solana caps inner-CPI account allocations at 10 KB, so a ~180 KB account can't +be created with an `init` constraint — the client calls `system_program::create_account` directly (sizing it to +`ORDER_BOOK_ACCOUNT_SIZE`, program-owned, zeroed) and passes it to `initialize_market`, which verifies and +initializes it in place. + +## The two-lot pricing model + +Both sides of the book are denominated in **lots**, mirroring Serum/OpenBook, so `price` and `quantity` read +as human numbers regardless of each mint's decimals: + +``` +raw_base = quantity × base_lot_size +raw_quote = quantity × price × quote_lot_size +``` + +Choose `base_lot_size = 10^max(d_base − d_quote, 0)` and `quote_lot_size = 10^max(d_quote − d_base, 0)`. For +NVDAx (9 decimals) / USDC (6 decimals): `base_lot_size = 1000`, `quote_lot_size = 1`, so `price = 100` means +100 USDC-units per base lot and `tick_size = 1` is one atomic increment. + +## Instruction lifecycle + +| Handler | What it does | +| --- | --- | +| `initialize_market` | Create the `Market` PDA, the two vaults, and the fee vault; initialize the pre-created order-book account. | +| `create_market_user` | Create a caller's `MarketUser` for a market. | +| `place_order` | Lock funds, cross the opposing side in price-time priority, credit fills to maker/taker `unsettled_*`, route the taker fee, and rest any remainder. | +| `cancel_order` | Credit an open order's locked remainder back to the owner's `unsettled_*` and remove it from the book. | +| `settle_funds` | Move a user's `unsettled_*` balances out of the vaults into their token accounts. | +| `withdraw_fees` | Authority-only: drain the fee vault to the authority's token account. | + +`place_order` takes `side` (`0` = bid, `1` = ask), `price`, `quantity`, and `order_id`. The caller passes the +resting maker orders to cross as **remaining accounts**, in pairs of `(maker_order, maker_market_user)`, in the +book's price-time priority. `order_id` must equal the book's current `next_order_id` (the program verifies it), +so the client derives the `Order` PDA deterministically. + +Fills never transfer tokens directly to the counterparty — they credit `unsettled_*` balances that each user +drains later via `settle_funds`. This keeps the per-fill account footprint small (no maker ATAs in the fill +path), as in OpenBook v2. + +## The matching engine + +Each side of the book is a **critbit tree** (radix trie) ported from +[openbook-v2](https://github.com/openbook-dex/openbook-v2) (MIT; see `src/state/slab/LICENSE-OPENBOOK`). Leaves +carry the resting order's price, remaining quantity, owner, and id; a 128-bit key packs `[price][seq_num]` so an +in-order walk yields best-price-first, and within a price level the sequence number preserves time priority. +Insert, remove, and best-price lookup are all sub-linear, so matching stays cheap as depth grows. + +## What the Quasar port does differently + +The trading logic, fee model, lot math, and matching engine are identical to the Anchor build. The differences +are all consequences of Quasar being zero-copy, `no_std`, and zero-allocation: + +- **The order book is accessed by casting raw account bytes.** Anchor uses `AccountLoader`; Quasar + has no `AccountLoader`, so `initialize_market` / `place_order` / `cancel_order` cast the account's byte region + to `&mut OrderBook` with `bytemuck` (an 8-byte discriminator guards against a wrong account). The slab code is + otherwise a near-verbatim port. +- **`MarketUser.open_orders` is a fixed `[u8; 160]` (20 packed u64s) plus a length**, not a borsh `Vec`. A + resizable field would make the account dynamic, which is awkward to mutate on the maker side where the account + arrives as a remaining account. The fixed buffer keeps every mutation an in-place write. +- **The slab is heap-free.** Its transient traversal stack is a fixed array (a critbit tree over 128-bit keys is + at most 128 nodes deep), and two write-only stacks the upstream carried were dropped. A single `place_order` + crosses at most `MAX_FILLS` (16) resting orders; any remainder rests on the book for a later order to cross. +- **`side` and order status/side enums are stored as `u8`** (zero-copy accounts hold POD scalars). The `u8` + wire encoding of `side` matches the Anchor build's borsh enum discriminant. + +## Safety and custody + +- Every vault transfer out is signed by the **market PDA** via `invoke_signed`; only the deployed program can + move locked funds. +- `settle_funds` zeroes a user's `unsettled_*` **before** transferring (checks-effects-interactions), so no + token-hook re-entry could double-withdraw. +- `place_order` binds every market-owned account (`base_vault`, `quote_vault`, `fee_vault`, both mints, the + order book) to the addresses stored on the `Market` PDA with `has_one`, so a caller can't substitute the fee + vault for a user vault and drain fees. +- Taker fees use **ceiling** division, rounding in the protocol's favor so many tiny fills can't leak a minor + unit to the maker. + +## Building and testing + +Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) (for `cargo build-sbf`) and the +[Quasar CLI](https://github.com/blueshift-gg/quasar): + +```sh +cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked +quasar build # compiles the program to target/deploy/quasar_order_book.so +cargo test # QuasarSVM integration tests (they load the compiled .so) +``` + +`quasar build` must run before `cargo test`: the tests load the compiled `.so` into +[QuasarSVM](https://github.com/blueshift-gg/quasar-svm), an in-process SVM, via `include`/`fs::read`. The suite +in `src/tests.rs` drives the full lifecycle — initialize a market, create users, rest an ask, cross it with a +bid, settle both sides, and withdraw the fee — asserting on-chain state, token balances, and fee accounting at +each step, plus an authorization rejection. + +## Extending + +- **Self-trade prevention:** reject or cancel-back when a taker would cross their own resting order. +- **Post-only / IOC / FOK** order types by gating the rest-vs-cancel behaviour on a flag. +- **Multiple fee tiers** keyed on the taker's `MarketUser`. +- **Market pause/resume** by flipping `Market.is_active` from an authority-gated handler. diff --git a/finance/order-book/quasar/src/errors.rs b/finance/order-book/quasar/src/errors.rs new file mode 100644 index 00000000..d3708dd6 --- /dev/null +++ b/finance/order-book/quasar/src/errors.rs @@ -0,0 +1,36 @@ +use quasar_lang::prelude::*; + +/// Program errors. `#[error_code]` assigns the numeric codes and generates the +/// `From for ProgramError` conversion that `?` and `require!` +/// rely on. Codes start at 6000, matching Anchor's custom-error base so the two +/// builds report the same numbers to clients. +#[error_code] +pub enum OrderBookError { + InvalidPrice = 6000, + OrderNotFound, + MarketPaused, + Unauthorized, + OrderBookFull, + TooManyOpenOrders, + InvalidTickSize, + InvalidBaseLotSize, + InvalidQuoteLotSize, + BelowMinOrderSize, + OrderNotCancellable, + NumericalOverflow, + InvalidFeeBasisPoints, + InvalidFeeVault, + InvalidBaseVault, + InvalidQuoteVault, + InvalidBaseMint, + InvalidQuoteMint, + MakerAccountMismatch, + MissingMakerAccounts, + MakerOwnerMismatch, + NotMarketAuthority, + InvalidOrderBook, + InvalidOrderBookOwner, + OrderBookAlreadyInitialized, + OrderIdMismatch, + InvalidSide, +} diff --git a/finance/order-book/quasar/src/instructions/admin/mod.rs b/finance/order-book/quasar/src/instructions/admin/mod.rs new file mode 100644 index 00000000..ef5bf86c --- /dev/null +++ b/finance/order-book/quasar/src/instructions/admin/mod.rs @@ -0,0 +1,3 @@ +pub mod withdraw_fees; + +pub use withdraw_fees::*; diff --git a/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs b/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs new file mode 100644 index 00000000..7032592f --- /dev/null +++ b/finance/order-book/quasar/src/instructions/admin/withdraw_fees.rs @@ -0,0 +1,69 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::OrderBookError; +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. +#[derive(Accounts)] +pub struct WithdrawFeesAccountConstraints { + #[account(has_one(fee_vault) @ OrderBookError::InvalidFeeVault)] + pub market: Account, + + #[account(mut)] + pub fee_vault: Account, + + #[account(mut)] + pub authority_quote_account: Account, + + pub quote_mint: Account, + + pub authority: Signer, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_withdraw_fees( + accounts: &mut WithdrawFeesAccountConstraints, +) -> Result<(), ProgramError> { + require_keys_eq!( + *accounts.authority.address(), + accounts.market.authority, + OrderBookError::NotMarketAuthority + ); + + let fee_balance = 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 base_mint = accounts.market.base_mint; + let quote_mint = accounts.market.quote_mint; + let bump = [accounts.market.bump]; + let seeds = [ + Seed::from(MARKET_SEED), + Seed::from(base_mint.as_ref()), + Seed::from(quote_mint.as_ref()), + Seed::from(bump.as_ref()), + ]; + + accounts + .token_program + .transfer_checked( + &accounts.fee_vault, + &accounts.quote_mint, + &accounts.authority_quote_account, + &accounts.market, + fee_balance, + accounts.quote_mint.decimals, + ) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/cancel_order.rs b/finance/order-book/quasar/src/instructions/cancel_order.rs new file mode 100644 index 00000000..aa429703 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/cancel_order.rs @@ -0,0 +1,111 @@ +use quasar_lang::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::{ + load_order_book_mut, remaining_quantity, remove_open_order, snapshot_market_user, + snapshot_order, Market, MarketUser, Order, OrderSide, OrderStatus, +}; + +#[derive(Accounts)] +pub struct CancelOrderAccountConstraints { + #[account(has_one(order_book) @ OrderBookError::InvalidOrderBook)] + pub market: Account, + + // Not a PDA (see initialize_market); bound to `market` via has_one. + #[account(mut)] + pub order_book: UncheckedAccount, + + #[account(mut, address = Order::seeds(market.address(), order.order_id.into()))] + pub order: Account, + + #[account(mut, address = MarketUser::seeds(market.address(), owner.address()))] + pub market_user: Account, + + pub owner: Signer, +} + +#[inline(always)] +pub fn handle_cancel_order( + accounts: &mut CancelOrderAccountConstraints, +) -> Result<(), ProgramError> { + let mut order = snapshot_order(&accounts.order); + + require_keys_eq!(order.owner, *accounts.owner.address(), OrderBookError::Unauthorized); + + require!( + order.status == OrderStatus::Open as u8 + || order.status == OrderStatus::PartiallyFilled as u8, + OrderBookError::OrderNotCancellable + ); + + let side = OrderSide::from_u8(order.side).ok_or(OrderBookError::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.original_quantity, order.filled_quantity); + if remaining > 0 { + let quote_lot_size = u64::from(accounts.market.quote_lot_size); + let base_lot_size = u64::from(accounts.market.base_lot_size); + let mut market_user = snapshot_market_user(&accounts.market_user); + match side { + OrderSide::Bid => { + // raw_quote = price × remaining × quote_lot_size (u128 to + // mirror the bid-lock formula in place_order). + let quote_amount: u64 = (order.price as u128) + .checked_mul(remaining as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .checked_mul(quote_lot_size as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .try_into() + .map_err(|_| OrderBookError::NumericalOverflow)?; + market_user.unsettled_quote = market_user + .unsettled_quote + .checked_add(quote_amount) + .ok_or(OrderBookError::NumericalOverflow)?; + } + OrderSide::Ask => { + let base_amount: u64 = (remaining as u128) + .checked_mul(base_lot_size as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .try_into() + .map_err(|_| OrderBookError::NumericalOverflow)?; + market_user.unsettled_base = market_user + .unsettled_base + .checked_add(base_amount) + .ok_or(OrderBookError::NumericalOverflow)?; + } + } + remove_open_order( + &mut market_user.open_orders, + &mut market_user.open_orders_len, + order.order_id, + ); + accounts.market_user.set_inner(market_user); + } else { + // No locked remainder, but the id is still tracked as open - drop it. + let mut market_user = snapshot_market_user(&accounts.market_user); + remove_open_order( + &mut market_user.open_orders, + &mut market_user.open_orders_len, + order.order_id, + ); + accounts.market_user.set_inner(market_user); + } + + // Remove the leaf from the slab. The side comes from the Order account, so + // no cross-side scan is needed. + { + let view = accounts.order_book.to_account_view(); + let data = + unsafe { core::slice::from_raw_parts_mut(view.data_ptr() as *mut u8, view.data_len()) }; + let order_book = load_order_book_mut(data)?; + let removed = order_book.remove_from(side, order.order_id); + require!(removed, OrderBookError::OrderNotFound); + } + + order.status = OrderStatus::Cancelled as u8; + accounts.order.set_inner(order); + + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/create_market_user.rs b/finance/order-book/quasar/src/instructions/create_market_user.rs new file mode 100644 index 00000000..7d298b65 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/create_market_user.rs @@ -0,0 +1,38 @@ +use quasar_lang::prelude::*; + +use crate::state::{Market, MarketUser, MarketUserInner, OPEN_ORDERS_BYTES}; + +#[derive(Accounts)] +pub struct CreateMarketUserAccountConstraints { + #[account(mut)] + pub owner: Signer, + + pub market: Account, + + #[account( + init, + payer = owner, + address = MarketUser::seeds(market.address(), owner.address()) + )] + pub market_user: Account, + + pub rent: Sysvar, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_create_market_user( + accounts: &mut CreateMarketUserAccountConstraints, + bumps: &CreateMarketUserAccountConstraintsBumps, +) -> Result<(), ProgramError> { + accounts.market_user.set_inner(MarketUserInner { + market: *accounts.market.address(), + owner: *accounts.owner.address(), + unsettled_base: 0, + unsettled_quote: 0, + open_orders_len: 0, + bump: bumps.market_user, + open_orders: [0u8; OPEN_ORDERS_BYTES], + }); + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/initialize_market.rs b/finance/order-book/quasar/src/instructions/initialize_market.rs new file mode 100644 index 00000000..cbd7e133 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/initialize_market.rs @@ -0,0 +1,125 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::{load_order_book_init, Market, MarketInner}; + +// 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; + +#[derive(Accounts)] +pub struct InitializeMarketAccountConstraints { + #[account(mut)] + pub authority: Signer, + + #[account( + init, + payer = authority, + address = Market::seeds(base_mint.address(), quote_mint.address()) + )] + pub market: Account, + + // The order book is a ~180 KB zero-copy account (two 1024-slot critbit + // slabs back to back). Solana's BPF runtime caps inner-CPI account + // allocations at 10 KB, so it can't be `init`-ed 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. The handler verifies ownership + the zero + // discriminator, then stamps and initializes it in place. + // + // Not a PDA. create_account requires the new account to sign its own + // creation, and a PDA has no key to sign with, so the client generates a + // real keypair for it. The program ties this account to its market via the + // market's stored `order_book` field, not via seeds. + #[account(mut)] + pub order_book: UncheckedAccount, + + pub base_mint: Account, + pub quote_mint: Account, + + #[account( + init, + payer = authority, + token(mint = base_mint, authority = market, token_program = token_program), + )] + pub base_vault: Account, + + #[account( + init, + payer = authority, + token(mint = quote_mint, authority = market, token_program = token_program), + )] + pub quote_vault: Account, + + // 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, authority = market, token_program = token_program), + )] + pub fee_vault: Account, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn handle_initialize_market( + accounts: &mut InitializeMarketAccountConstraints, + fee_basis_points: u16, + tick_size: u64, + base_lot_size: u64, + quote_lot_size: u64, + min_order_size: u64, + bumps: &InitializeMarketAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!(tick_size > 0, OrderBookError::InvalidTickSize); + require!(base_lot_size > 0, OrderBookError::InvalidBaseLotSize); + require!(quote_lot_size > 0, OrderBookError::InvalidQuoteLotSize); + require!(min_order_size > 0, OrderBookError::BelowMinOrderSize); + require!( + fee_basis_points <= MAX_FEE_BASIS_POINTS, + OrderBookError::InvalidFeeBasisPoints + ); + + let market_address = *accounts.market.address(); + let order_book_address = *accounts.order_book.to_account_view().address(); + + // Initialize the order book in place. The client pre-created it as a + // program-owned, zeroed account; verify ownership before casting. + { + let view = accounts.order_book.to_account_view(); + require!(view.owned_by(&crate::ID), OrderBookError::InvalidOrderBookOwner); + // SAFETY: `order_book` is writable and not aliased elsewhere in this + // instruction. The cast mirrors the read-only raw-slice pattern used + // in the pyth example, extended to a mutable slice for initialization. + let data = + unsafe { core::slice::from_raw_parts_mut(view.data_ptr() as *mut u8, view.data_len()) }; + let order_book = load_order_book_init(data)?; + // The order book is not a PDA, so its stored `bump` is unused (0). + order_book.initialize(market_address.to_bytes(), 0); + } + + accounts.market.set_inner(MarketInner { + authority: *accounts.authority.address(), + base_mint: *accounts.base_mint.address(), + quote_mint: *accounts.quote_mint.address(), + base_vault: *accounts.base_vault.address(), + quote_vault: *accounts.quote_vault.address(), + fee_vault: *accounts.fee_vault.address(), + order_book: order_book_address, + fee_basis_points, + tick_size, + base_lot_size, + quote_lot_size, + min_order_size, + is_active: PodBool::from(true), + bump: bumps.market, + }); + + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/mod.rs b/finance/order-book/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..aa1118af --- /dev/null +++ b/finance/order-book/quasar/src/instructions/mod.rs @@ -0,0 +1,13 @@ +pub mod admin; +pub mod cancel_order; +pub mod create_market_user; +pub mod initialize_market; +pub mod place_order; +pub mod settle_funds; + +pub use admin::*; +pub use cancel_order::*; +pub use create_market_user::*; +pub use initialize_market::*; +pub use place_order::*; +pub use settle_funds::*; diff --git a/finance/order-book/quasar/src/instructions/place_order.rs b/finance/order-book/quasar/src/instructions/place_order.rs new file mode 100644 index 00000000..f531f928 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/place_order.rs @@ -0,0 +1,440 @@ +use quasar_lang::prelude::*; +use quasar_lang::remaining::RemainingAccounts; +// Anonymous import: brings the `Sysvar` trait's `Clock::get()` into scope +// without shadowing the `Sysvar` account-wrapper type used below. +use quasar_lang::sysvars::Sysvar as _; +use quasar_spl::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::{ + add_open_order, load_order_book, load_order_book_mut, plan_fills, remove_open_order, + snapshot_market_user, snapshot_order, Market, MarketUser, Order, OrderInner, OrderSide, + OrderStatus, MARKET_SEED, MAX_OPEN_ORDERS, +}; + +// 10_000 bps == 100% - the universal rate convention on every major exchange. +const BASIS_POINTS_DENOMINATOR: u128 = 10_000; + +// Remaining accounts arrive in groups of 2 per resting order we intend to +// cross: [maker_order, maker_market_user]. Fills land in the maker's +// unsettled_* balance (drained later via settle_funds), so the maker's ATAs +// aren't needed here - keeping the per-fill account footprint small, as in +// Openbook v2. +const ACCOUNTS_PER_MAKER: usize = 2; + +/// raw token units for `lots × lot_size`, via a u128 intermediate so a +/// high-decimal mint can't overflow the multiply before it's range-checked. +fn raw_from_lots(lots: u64, lot_size: u64) -> Result { + (lots as u128) + .checked_mul(lot_size as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .try_into() + .map_err(|_| OrderBookError::NumericalOverflow.into()) +} + +/// raw quote units for `price × lots × quote_lot_size`. +fn quote_value(price: u64, lots: u64, quote_lot_size: u64) -> Result { + (price as u128) + .checked_mul(lots as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .checked_mul(quote_lot_size as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .try_into() + .map_err(|_| OrderBookError::NumericalOverflow.into()) +} + +/// Taker fee on a fill's gross quote, rounded up (ceiling division) so the +/// protocol never leaks a minor unit to the maker across many tiny fills. +fn ceil_fee(gross_quote: u64, fee_basis_points: u16) -> Result { + (gross_quote as u128) + .checked_mul(fee_basis_points as u128) + .ok_or(OrderBookError::NumericalOverflow)? + .checked_add(BASIS_POINTS_DENOMINATOR - 1) + .ok_or(OrderBookError::NumericalOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR) + .ok_or(OrderBookError::NumericalOverflow)? + .try_into() + .map_err(|_| OrderBookError::NumericalOverflow.into()) +} + +#[derive(Accounts)] +// Only `order_id` is referenced (for the Order PDA); the leading args must be +// listed so it lands in the right wire position, but are unused here. +#[instruction(_side: u8, _price: u64, _quantity: u64, order_id: u64)] +pub struct PlaceOrderAccountConstraints { + // `has_one` ties every market-owned account to the addresses recorded on + // the Market PDA. Without has_one on the vaults/mints a caller could swap + // fee_vault in for quote_vault (same mint + authority) and steer the fee + // transfer to drain real fees instead of routing them in. + #[account( + has_one(fee_vault) @ OrderBookError::InvalidFeeVault, + has_one(base_vault) @ OrderBookError::InvalidBaseVault, + has_one(quote_vault) @ OrderBookError::InvalidQuoteVault, + has_one(base_mint) @ OrderBookError::InvalidBaseMint, + has_one(quote_mint) @ OrderBookError::InvalidQuoteMint, + has_one(order_book) @ OrderBookError::InvalidOrderBook, + )] + pub market: Account, + + // Zero-copy order book, accessed by casting its raw bytes (see + // state/order_book.rs). Not a PDA - bound to `market` via has_one. + #[account(mut)] + pub order_book: UncheckedAccount, + + // The order id is supplied as an instruction argument so its PDA can be + // derived here at parse time; the handler verifies it equals the book's + // `next_order_id` before use, so it is not a free parameter. + #[account( + init, + payer = owner, + address = Order::seeds(market.address(), order_id) + )] + pub order: Account, + + #[account(mut, address = MarketUser::seeds(market.address(), owner.address()))] + pub market_user: Account, + + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub fee_vault: Account, + #[account(mut)] + pub user_base_account: Account, + #[account(mut)] + pub user_quote_account: Account, + + pub base_mint: Account, + pub quote_mint: Account, + + #[account(mut)] + pub owner: Signer, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn handle_place_order( + accounts: &mut PlaceOrderAccountConstraints, + remaining: RemainingAccounts<'_>, + side_byte: u8, + price: u64, + quantity: u64, + order_id_arg: u64, + bumps: &PlaceOrderAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let side = OrderSide::from_u8(side_byte).ok_or(OrderBookError::InvalidSide)?; + + require!(accounts.market.is_active.is_true(), OrderBookError::MarketPaused); + require!(price > 0, OrderBookError::InvalidPrice); + + let tick_size = u64::from(accounts.market.tick_size); + require!(price.is_multiple_of(tick_size), OrderBookError::InvalidTickSize); + + let min_order_size = u64::from(accounts.market.min_order_size); + require!(quantity >= min_order_size, OrderBookError::BelowMinOrderSize); + + require!( + (accounts.market_user.open_orders_len as usize) < MAX_OPEN_ORDERS, + OrderBookError::TooManyOpenOrders + ); + + let quote_lot_size = u64::from(accounts.market.quote_lot_size); + let base_lot_size = u64::from(accounts.market.base_lot_size); + let fee_basis_points = u16::from(accounts.market.fee_basis_points); + let market_bump = accounts.market.bump; + let base_mint_addr = accounts.market.base_mint; + let quote_mint_addr = accounts.market.quote_mint; + let market_key = *accounts.market.address(); + let owner_bytes = accounts.owner.address().to_bytes(); + + // --------------------------------------------------------------- + // Lock the funds the order would need if fully filled. Bids lock quote + // (price × quantity × quote_lot_size); asks lock base (quantity × + // base_lot_size). Matching consumes from this locked pot; any unmatched + // remainder rests with its lock in place. + // --------------------------------------------------------------- + match side { + OrderSide::Bid => { + let amount = quote_value(price, quantity, quote_lot_size)?; + accounts + .token_program + .transfer_checked( + &accounts.user_quote_account, + &accounts.quote_mint, + &accounts.quote_vault, + &accounts.owner, + amount, + accounts.quote_mint.decimals, + ) + .invoke()?; + } + OrderSide::Ask => { + let amount = raw_from_lots(quantity, base_lot_size)?; + accounts + .token_program + .transfer_checked( + &accounts.user_base_account, + &accounts.base_mint, + &accounts.base_vault, + &accounts.owner, + amount, + accounts.base_mint.decimals, + ) + .invoke()?; + } + } + + // --------------------------------------------------------------- + // Plan fills against the resting side, verifying the caller passed the + // order id that matches the book's counter (so the Order PDA the client + // derived is the one the book will assign). + // --------------------------------------------------------------- + let plan = { + let view = accounts.order_book.to_account_view(); + // SAFETY: read-only cast of the order-book bytes; no other reference + // to this account's data is live. + let data = unsafe { core::slice::from_raw_parts(view.data_ptr(), view.data_len()) }; + let order_book = load_order_book(data)?; + require!(order_book.next_order_id == order_id_arg, OrderBookError::OrderIdMismatch); + plan_fills(order_book, side, price, quantity) + }; + + // --------------------------------------------------------------- + // Apply fills: credit maker/taker balances, route the taker fee, and stamp + // the maker Order accounts. Each fill's maker accounts arrive as a + // remaining-account pair in price-time-priority order. + // --------------------------------------------------------------- + let mut taker_base_received: u64 = 0; + let mut taker_quote_rebate: u64 = 0; + let mut taker_quote_received: u64 = 0; + // Aggregate per-fill fees into one transfer at the end - halves CU cost + // vs one CPI per fill. + let mut total_fee_quote: u64 = 0; + + for fill_index in 0..plan.count { + let fill = plan.fills[fill_index]; + + let mut order_ra = remaining + .get(fill_index * ACCOUNTS_PER_MAKER)? + .ok_or(OrderBookError::MissingMakerAccounts)?; + let mut user_ra = remaining + .get(fill_index * ACCOUNTS_PER_MAKER + 1)? + .ok_or(OrderBookError::MissingMakerAccounts)?; + + // Validate owner (== this program) + discriminator, then take typed + // mutable handles. + let order_view = unsafe { order_ra.as_account_view_unchecked_mut() }; + Account::::from_account_view(&*order_view)?; + let maker_order_acc = unsafe { Account::::from_account_view_unchecked_mut(order_view) }; + let mut maker_order = snapshot_order(maker_order_acc); + + let user_view = unsafe { user_ra.as_account_view_unchecked_mut() }; + Account::::from_account_view(&*user_view)?; + let maker_user_acc = + unsafe { Account::::from_account_view_unchecked_mut(user_view) }; + let mut maker_user = snapshot_market_user(maker_user_acc); + + require!( + maker_order.order_id == fill.maker_order_id, + OrderBookError::MakerAccountMismatch + ); + require_keys_eq!(maker_order.market, market_key, OrderBookError::MakerAccountMismatch); + require_keys_eq!(maker_order.owner, maker_user.owner, OrderBookError::MakerOwnerMismatch); + require_keys_eq!(maker_user.market, market_key, OrderBookError::MakerAccountMismatch); + + // Fee model (maker-funded, no extra taker deposit): + // gross = fill_price × fill_quantity × quote_lot_size + // fee = ceil(gross × fee_bps / 10_000) + // maker gets gross - fee, fee_vault gets fee, taker pays gross net + // out of their pre-locked quote. + let gross_quote = quote_value(fill.fill_price, fill.fill_quantity, quote_lot_size)?; + let fee_quote = ceil_fee(gross_quote, fee_basis_points)?; + // Defensive: fees are a fraction of gross. `fee_bps <= 10_000` is + // enforced at init, so this should be unreachable. + require!(fee_quote <= gross_quote, OrderBookError::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(OrderBookError::NumericalOverflow)?; + maker_user.unsettled_quote = maker_user + .unsettled_quote + .checked_add(net_quote_to_maker) + .ok_or(OrderBookError::NumericalOverflow)?; + + let base_from_fill = raw_from_lots(fill.fill_quantity, base_lot_size)?; + taker_base_received = taker_base_received + .checked_add(base_from_fill) + .ok_or(OrderBookError::NumericalOverflow)?; + + // Price improvement: taker locked (price × qty) but only needs + // (fill_price × qty) for this fill; refund the difference. + let locked_for_this_fill = quote_value(price, fill.fill_quantity, quote_lot_size)?; + let rebate = locked_for_this_fill + .checked_sub(gross_quote) + .ok_or(OrderBookError::NumericalOverflow)?; + taker_quote_rebate = taker_quote_rebate + .checked_add(rebate) + .ok_or(OrderBookError::NumericalOverflow)?; + } + // Taker Ask, resting Bid. Taker gives base, gets quote. + OrderSide::Ask => { + let base_from_fill = raw_from_lots(fill.fill_quantity, base_lot_size)?; + maker_user.unsettled_base = maker_user + .unsettled_base + .checked_add(base_from_fill) + .ok_or(OrderBookError::NumericalOverflow)?; + + let net_quote_to_taker = gross_quote + .checked_sub(fee_quote) + .ok_or(OrderBookError::NumericalOverflow)?; + taker_quote_received = taker_quote_received + .checked_add(net_quote_to_taker) + .ok_or(OrderBookError::NumericalOverflow)?; + } + } + + total_fee_quote = total_fee_quote + .checked_add(fee_quote) + .ok_or(OrderBookError::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(OrderBookError::NumericalOverflow)?; + let maker_fully_filled = maker_order.filled_quantity >= maker_order.original_quantity; + maker_order.status = if maker_fully_filled { + OrderStatus::Filled as u8 + } else { + OrderStatus::PartiallyFilled as u8 + }; + if maker_fully_filled { + remove_open_order( + &mut maker_user.open_orders, + &mut maker_user.open_orders_len, + maker_order.order_id, + ); + } + + maker_order_acc.set_inner(maker_order); + maker_user_acc.set_inner(maker_user); + } + + // --------------------------------------------------------------- + // Apply the planned fills to the book (decrement remaining qty / remove + // fully-filled leaves). + // --------------------------------------------------------------- + let maker_side = side.opposite(); + { + let view = accounts.order_book.to_account_view(); + let data = + unsafe { core::slice::from_raw_parts_mut(view.data_ptr() as *mut u8, view.data_len()) }; + let order_book = load_order_book_mut(data)?; + for fill_index in 0..plan.count { + let fill = plan.fills[fill_index]; + 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 bump = [market_bump]; + let seeds = [ + Seed::from(MARKET_SEED), + Seed::from(base_mint_addr.as_ref()), + Seed::from(quote_mint_addr.as_ref()), + Seed::from(bump.as_ref()), + ]; + accounts + .token_program + .transfer_checked( + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.fee_vault, + &accounts.market, + total_fee_quote, + accounts.quote_mint.decimals, + ) + .invoke_signed(&seeds)?; + } + + // --------------------------------------------------------------- + // Allocate the taker's order id (rolling the book counter) and rest any + // unmatched remainder on the book. + // --------------------------------------------------------------- + let timestamp = i64::from(Clock::get()?.unix_timestamp); + let order_id = { + let view = accounts.order_book.to_account_view(); + let data = + unsafe { core::slice::from_raw_parts_mut(view.data_ptr() as *mut u8, view.data_len()) }; + let order_book = load_order_book_mut(data)?; + let id = order_book.allocate_order_id()?; + if plan.taker_remaining > 0 { + require!(!order_book.is_side_full(side), OrderBookError::OrderBookFull); + order_book.place_resting(side, price, plan.taker_remaining, owner_bytes, id, timestamp)?; + } + id + }; + + // Apply the taker's accumulated deltas + track the resting order. + let mut taker_user = snapshot_market_user(&accounts.market_user); + taker_user.unsettled_base = taker_user + .unsettled_base + .checked_add(taker_base_received) + .ok_or(OrderBookError::NumericalOverflow)?; + taker_user.unsettled_quote = taker_user + .unsettled_quote + .checked_add(taker_quote_rebate) + .ok_or(OrderBookError::NumericalOverflow)? + .checked_add(taker_quote_received) + .ok_or(OrderBookError::NumericalOverflow)?; + if plan.taker_remaining > 0 { + add_open_order( + &mut taker_user.open_orders, + &mut taker_user.open_orders_len, + order_id, + ); + } + accounts.market_user.set_inner(taker_user); + + // Stamp the taker's Order PDA. checked_sub, not saturating: a remainder + // larger than the original would be a real matching-engine bug. + let filled_quantity = quantity + .checked_sub(plan.taker_remaining) + .ok_or(OrderBookError::NumericalOverflow)?; + let status = if plan.taker_remaining == 0 { + OrderStatus::Filled + } else if plan.taker_remaining < quantity { + OrderStatus::PartiallyFilled + } else { + OrderStatus::Open + }; + accounts.order.set_inner(OrderInner { + market: market_key, + owner: *accounts.owner.address(), + order_id, + side: side_byte, + price, + original_quantity: quantity, + filled_quantity, + status: status as u8, + timestamp, + bump: bumps.order, + }); + + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/settle_funds.rs b/finance/order-book/quasar/src/instructions/settle_funds.rs new file mode 100644 index 00000000..1ac5f198 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/settle_funds.rs @@ -0,0 +1,97 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::{snapshot_market_user, Market, MarketUser, MARKET_SEED}; + +#[derive(Accounts)] +pub struct SettleFundsAccountConstraints { + pub owner: Signer, + + // `has_one` binds these vaults/mints to the addresses stored on the Market + // PDA. Without them a caller could substitute the fee_vault (same mint + + // 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( + has_one(base_vault) @ OrderBookError::InvalidBaseVault, + has_one(quote_vault) @ OrderBookError::InvalidQuoteVault, + has_one(base_mint) @ OrderBookError::InvalidBaseMint, + has_one(quote_mint) @ OrderBookError::InvalidQuoteMint, + )] + pub market: Account, + + #[account(mut, address = MarketUser::seeds(market.address(), owner.address()))] + pub market_user: Account, + + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + #[account(mut)] + pub user_base_account: Account, + #[account(mut)] + pub user_quote_account: Account, + + pub base_mint: Account, + pub quote_mint: Account, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_settle_funds( + accounts: &mut SettleFundsAccountConstraints, +) -> Result<(), ProgramError> { + // Snapshot the amounts owed, then zero the counters BEFORE the token + // transfers (checks-effects-interactions): updating state first makes a + // re-entry double-withdraw impossible even if a token hook ever gained a + // path back into this program. + let mut market_user = snapshot_market_user(&accounts.market_user); + let base_amount = market_user.unsettled_base; + let quote_amount = market_user.unsettled_quote; + market_user.unsettled_base = 0; + market_user.unsettled_quote = 0; + accounts.market_user.set_inner(market_user); + + // Seeds to sign as the market PDA (the authority of both vaults). + let base_mint = accounts.market.base_mint; + let quote_mint = accounts.market.quote_mint; + let bump = [accounts.market.bump]; + let seeds = [ + Seed::from(MARKET_SEED), + Seed::from(base_mint.as_ref()), + Seed::from(quote_mint.as_ref()), + Seed::from(bump.as_ref()), + ]; + + if base_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.base_vault, + &accounts.base_mint, + &accounts.user_base_account, + &accounts.market, + base_amount, + accounts.base_mint.decimals, + ) + .invoke_signed(&seeds)?; + } + + if quote_amount > 0 { + accounts + .token_program + .transfer_checked( + &accounts.quote_vault, + &accounts.quote_mint, + &accounts.user_quote_account, + &accounts.market, + quote_amount, + accounts.quote_mint.decimals, + ) + .invoke_signed(&seeds)?; + } + + Ok(()) +} diff --git a/finance/order-book/quasar/src/lib.rs b/finance/order-book/quasar/src/lib.rs new file mode 100644 index 00000000..2935c9a1 --- /dev/null +++ b/finance/order-book/quasar/src/lib.rs @@ -0,0 +1,106 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +pub mod errors; +pub mod instructions; +pub mod state; + +use instructions::*; + +#[cfg(test)] +mod tests; + +declare_id!("C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"); + +/// Central limit order book (CLOB) for a single (base, quote) token pair. Users +/// post bids or asks at their chosen prices; the program crosses opposing +/// orders in price-time priority, credits fills to maker/taker unsettled +/// balances, routes the taker fee to a fee vault, and rests any unmatched +/// remainder on the book. See README.md for the full walkthrough. +#[program] +mod quasar_order_book { + use super::*; + + /// Create a market for a (base, quote) pair. The Market PDA, the two + /// PDA-authority vaults, and the fee vault are created here; the client + /// pre-creates the large order-book account (see `initialize_market`). + #[instruction(discriminator = 0)] + pub fn initialize_market( + ctx: Ctx, + fee_basis_points: u16, + tick_size: u64, + base_lot_size: u64, + quote_lot_size: u64, + min_order_size: u64, + ) -> Result<(), ProgramError> { + instructions::initialize_market::handle_initialize_market( + &mut ctx.accounts, + fee_basis_points, + tick_size, + base_lot_size, + quote_lot_size, + min_order_size, + &ctx.bumps, + ) + } + + /// Create a per-user, per-market account tracking a user's open orders and + /// unsettled balances. + #[instruction(discriminator = 1)] + pub fn create_market_user( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::create_market_user::handle_create_market_user(&mut ctx.accounts, &ctx.bumps) + } + + /// Place a bid or ask (`side`: 0 = Bid, 1 = Ask). Locks the required funds, + /// crosses the opposing side of the book in price-time priority, credits + /// fills to maker/taker `unsettled_*` balances, routes the taker fee to the + /// fee vault, and rests any remainder at the caller's limit price. + /// + /// Resting maker orders to cross are supplied as remaining accounts, in + /// pairs of `(maker_order, maker_market_user)`, in the book's price-time + /// priority. `order_id` must equal the book's current `next_order_id`. + #[instruction(discriminator = 2)] + pub fn place_order( + ctx: CtxWithRemaining, + side: u8, + price: u64, + quantity: u64, + order_id: u64, + ) -> Result<(), ProgramError> { + let remaining = ctx.remaining_accounts(); + instructions::place_order::handle_place_order( + &mut ctx.accounts, + remaining, + side, + price, + quantity, + order_id, + &ctx.bumps, + ) + } + + /// Cancel an open (or partially filled) order. Credits the remaining locked + /// amount back to the owner's unsettled balance; the token transfer happens + /// on settle_funds. + #[instruction(discriminator = 3)] + pub fn cancel_order(ctx: Ctx) -> Result<(), ProgramError> { + instructions::cancel_order::handle_cancel_order(&mut ctx.accounts) + } + + /// Move accumulated unsettled balances out of the market vaults into the + /// user's token accounts. No-op if both balances are zero. + #[instruction(discriminator = 4)] + pub fn settle_funds(ctx: Ctx) -> Result<(), ProgramError> { + instructions::settle_funds::handle_settle_funds(&mut ctx.accounts) + } + + /// Drain the fee vault into the market authority's token account. + /// Authority-gated - only the market's stored `authority` may call this. + #[instruction(discriminator = 5)] + pub fn withdraw_fees(ctx: Ctx) -> Result<(), ProgramError> { + instructions::withdraw_fees::handle_withdraw_fees(&mut ctx.accounts) + } +} diff --git a/finance/order-book/quasar/src/state/market.rs b/finance/order-book/quasar/src/state/market.rs new file mode 100644 index 00000000..87278404 --- /dev/null +++ b/finance/order-book/quasar/src/state/market.rs @@ -0,0 +1,50 @@ +use quasar_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). +/// +/// PDA: `["market", base_mint, quote_mint]`. +#[account(discriminator = 1, set_inner)] +#[seeds(b"market", base_mint: Address, quote_mint: Address)] +pub struct Market { + pub authority: Address, + pub base_mint: Address, + pub quote_mint: Address, + pub base_vault: Address, + pub quote_vault: Address, + + /// 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: Address, + + /// The order-book account (created directly by the client, not a PDA - see + /// `initialize_market`). Bound to this market via this stored address. + pub order_book: Address, + + pub fee_basis_points: u16, + pub tick_size: u64, + + // Two-lot model (mirrors Serum/Openbook): both sides of the book are + // denominated in their respective lots rather than raw token units. + // + // raw_base = quantity × base_lot_size + // raw_quote = quantity × price × quote_lot_size + // + // Choose: + // base_lot_size = 10^max(d_base − d_quote, 0) + // quote_lot_size = 10^max(d_quote − d_base, 0) + // + // so `price` reads as the human-readable quote/base rate and + // `tick_size = 1` is a single atomic increment. + pub base_lot_size: u64, + pub quote_lot_size: u64, + pub min_order_size: u64, + pub is_active: PodBool, + pub bump: u8, +} diff --git a/finance/order-book/quasar/src/state/market_user.rs b/finance/order-book/quasar/src/state/market_user.rs new file mode 100644 index 00000000..50cf9b2f --- /dev/null +++ b/finance/order-book/quasar/src/state/market_user.rs @@ -0,0 +1,106 @@ +use quasar_lang::prelude::*; + +pub const MARKET_USER_SEED: &[u8] = b"market_user"; + +/// Per-user open-order cap. Matches the matching engine's upper bound so a +/// single user can't spam the book. Kept in sync with the `TooManyOpenOrders` +/// check in `place_order`. +pub const MAX_OPEN_ORDERS: usize = 20; + +/// Byte length of the packed open-order-id list: `MAX_OPEN_ORDERS` u64s. +pub const OPEN_ORDERS_BYTES: usize = MAX_OPEN_ORDERS * 8; + +/// 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`. +/// +/// PDA: `["market_user", market, owner]`. +/// +/// The Anchor build stores open order ids in a borsh `Vec`. Quasar +/// accounts are zero-copy, and a resizable `Vec` field would make the account +/// dynamic - awkward to mutate on the *maker* side, where the account arrives +/// as a remaining account. Instead the ids live in a fixed `[u8; 160]` buffer +/// (20 little-endian u64s) with an explicit `open_orders_len`, keeping the +/// account fixed-size so both taker and maker mutations are plain in-place +/// writes. +#[account(discriminator = 2, set_inner)] +#[seeds(b"market_user", market: Address, owner: Address)] +pub struct MarketUser { + pub market: Address, + pub owner: Address, + pub unsettled_base: u64, + pub unsettled_quote: u64, + pub open_orders_len: u8, + pub bump: u8, + pub open_orders: [u8; OPEN_ORDERS_BYTES], +} + +fn read_id(open_orders: &[u8; OPEN_ORDERS_BYTES], index: usize) -> u64 { + let start = index * 8; + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&open_orders[start..start + 8]); + u64::from_le_bytes(bytes) +} + +fn write_id(open_orders: &mut [u8; OPEN_ORDERS_BYTES], index: usize, order_id: u64) { + let start = index * 8; + open_orders[start..start + 8].copy_from_slice(&order_id.to_le_bytes()); +} + +pub fn open_orders_contains( + open_orders: &[u8; OPEN_ORDERS_BYTES], + open_orders_len: u8, + order_id: u64, +) -> bool { + (0..open_orders_len as usize).any(|index| read_id(open_orders, index) == order_id) +} + +/// Append `order_id` if there's room and it isn't already tracked. A full list +/// or a duplicate is a silent no-op - the `TooManyOpenOrders` cap is enforced +/// separately in `place_order` before the order rests. +pub fn add_open_order( + open_orders: &mut [u8; OPEN_ORDERS_BYTES], + open_orders_len: &mut u8, + order_id: u64, +) { + let len = *open_orders_len as usize; + if len >= MAX_OPEN_ORDERS || open_orders_contains(open_orders, *open_orders_len, order_id) { + return; + } + write_id(open_orders, len, order_id); + *open_orders_len = (len + 1) as u8; +} + +/// Remove `order_id` if present by swapping in the last entry (order within the +/// list is not significant - it's a set). No-op if the id isn't tracked. +pub fn remove_open_order( + open_orders: &mut [u8; OPEN_ORDERS_BYTES], + open_orders_len: &mut u8, + order_id: u64, +) { + let len = *open_orders_len as usize; + let Some(position) = (0..len).find(|&index| read_id(open_orders, index) == order_id) else { + return; + }; + let last = len - 1; + if position != last { + let moved = read_id(open_orders, last); + write_id(open_orders, position, moved); + } + write_id(open_orders, last, 0); + *open_orders_len = last as u8; +} + +/// Copy the account's current state into an owned `MarketUserInner` so a +/// handler can mutate it and write it back with `set_inner`. +pub fn snapshot_market_user(market_user: &Account) -> MarketUserInner { + MarketUserInner { + market: market_user.market, + owner: market_user.owner, + unsettled_base: u64::from(market_user.unsettled_base), + unsettled_quote: u64::from(market_user.unsettled_quote), + open_orders_len: market_user.open_orders_len, + bump: market_user.bump, + open_orders: market_user.open_orders, + } +} diff --git a/finance/order-book/quasar/src/state/matching.rs b/finance/order-book/quasar/src/state/matching.rs new file mode 100644 index 00000000..3645a939 --- /dev/null +++ b/finance/order-book/quasar/src/state/matching.rs @@ -0,0 +1,110 @@ +//! Matching engine helpers. Pure logic (no CPIs) that walks the resting side +//! of the book in price-time priority and produces the 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}; + +/// Maximum resting orders one `place_order` can cross in a single call. Each +/// fill needs a `(maker_order, maker_market_user)` account pair supplied as +/// remaining accounts, so the real ceiling is the transaction's account limit; +/// this fixed cap keeps `plan_fills` heap-free. Any taker remainder past the +/// cap simply rests on the book to be crossed by a later order. +pub const MAX_FILLS: usize = 16; + +/// 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, invalidating other handles in the same plan. We +/// look the leaf up by its tree key (built from price + order_id) at apply +/// time instead. +#[derive(Copy, Clone, Default)] +pub struct Fill { + /// order_id of the resting order being filled. Used both to sanity-check + /// the maker `Order` account the caller passed, and to reconstruct the + /// tree key for the apply-time lookup. + pub maker_order_id: u64, + + /// Quantity filled (in base lots). + pub fill_quantity: u64, + + /// Price at which the fill clears. Always the resting (maker) order's + /// price - standard order-book rule: the maker's posted price wins. + pub fill_price: u64, +} + +/// Output of `plan_fills`: the fills to apply (first `count` entries of +/// `fills`) and the taker quantity left over to rest on the book. +pub struct FillPlan { + pub fills: [Fill; MAX_FILLS], + pub count: usize, + pub taker_remaining: u64, +} + +/// Walk the opposite side of the book and produce the fills that should occur +/// for the incoming taker order. Does not mutate the book. +pub fn plan_fills( + order_book: &OrderBook, + incoming_side: OrderSide, + incoming_price: u64, + incoming_quantity: u64, +) -> FillPlan { + // 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 = [Fill::default(); MAX_FILLS]; + let mut count = 0usize; + let mut taker_remaining = incoming_quantity; + + for (_handle, leaf) in OrderTreeIter::new(nodes, root) { + if taker_remaining == 0 || count >= MAX_FILLS { + break; + } + + // Crossing condition: a bid takes when its limit is >= the resting + // ask's price; an ask takes when its limit is <= the resting bid's + // price. The walk is best-price-first 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; + } + + let leaf_quantity = leaf.quantity; + if leaf_quantity == 0 { + // Defensive: fully-filled leaves are removed, but skip a stray + // zero-quantity leaf rather than emit a zero-size fill. + continue; + } + + let fill_quantity = taker_remaining.min(leaf_quantity); + fills[count] = Fill { + maker_order_id: leaf.order_id, + fill_quantity, + fill_price: resting_price, + }; + count += 1; + taker_remaining = taker_remaining.saturating_sub(fill_quantity); + } + + FillPlan { + fills, + count, + taker_remaining, + } +} diff --git a/finance/order-book/quasar/src/state/mod.rs b/finance/order-book/quasar/src/state/mod.rs new file mode 100644 index 00000000..3dd3bd3d --- /dev/null +++ b/finance/order-book/quasar/src/state/mod.rs @@ -0,0 +1,12 @@ +pub mod market; +pub mod market_user; +pub mod matching; +pub mod order; +pub mod order_book; +pub mod slab; + +pub use market::*; +pub use market_user::*; +pub use matching::*; +pub use order::*; +pub use order_book::*; diff --git a/finance/order-book/quasar/src/state/order.rs b/finance/order-book/quasar/src/state/order.rs new file mode 100644 index 00000000..c89d880f --- /dev/null +++ b/finance/order-book/quasar/src/state/order.rs @@ -0,0 +1,83 @@ +use quasar_lang::prelude::*; + +pub const ORDER_SEED: &[u8] = b"order"; + +/// Side of the book an order sits on. Stored on-chain as a `u8` (Quasar +/// zero-copy accounts hold POD scalars, not Rust enums); the instruction wire +/// format also encodes `side` as a single byte, matching the Anchor build's +/// borsh enum discriminant. `0 = Bid`, `1 = Ask`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum OrderSide { + Bid = 0, + Ask = 1, +} + +impl OrderSide { + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(OrderSide::Bid), + 1 => Some(OrderSide::Ask), + _ => None, + } + } + + pub fn opposite(self) -> Self { + match self { + OrderSide::Bid => OrderSide::Ask, + OrderSide::Ask => OrderSide::Bid, + } + } +} + +/// Lifecycle of an order. Stored on-chain as a `u8`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum OrderStatus { + Open = 0, + PartiallyFilled = 1, + Filled = 2, + Cancelled = 3, +} + +/// A single order. PDA: `["order", market, order_id]`. The order id is the +/// book's monotonic `next_order_id` at placement time, so each order gets a +/// unique, deterministic address. +#[account(discriminator = 3, set_inner)] +#[seeds(b"order", market: Address, order_id: u64)] +pub struct Order { + pub market: Address, + pub owner: Address, + pub order_id: u64, + pub side: u8, + pub price: u64, + pub original_quantity: u64, + pub filled_quantity: u64, + pub status: u8, + pub timestamp: i64, + pub bump: u8, +} + +/// Base lots still resting: original minus filled. Saturating because a +/// cosmetic read should never trap; the matching engine keeps +/// `filled_quantity <= original_quantity` by construction. +pub fn remaining_quantity(original_quantity: u64, filled_quantity: u64) -> u64 { + original_quantity.saturating_sub(filled_quantity) +} + +/// Copy an order account's current state into an owned `OrderInner` so a +/// handler can mutate it and write it back with `set_inner`. +pub fn snapshot_order(order: &Account) -> OrderInner { + OrderInner { + market: order.market, + owner: order.owner, + order_id: u64::from(order.order_id), + side: order.side, + price: u64::from(order.price), + original_quantity: u64::from(order.original_quantity), + filled_quantity: u64::from(order.filled_quantity), + status: order.status, + timestamp: i64::from(order.timestamp), + bump: order.bump, + } +} diff --git a/finance/order-book/quasar/src/state/order_book.rs b/finance/order-book/quasar/src/state/order_book.rs new file mode 100644 index 00000000..7b924a0f --- /dev/null +++ b/finance/order-book/quasar/src/state/order_book.rs @@ -0,0 +1,247 @@ +use quasar_lang::prelude::ProgramError; + +use crate::errors::OrderBookError; +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. +pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; + +/// 8-byte marker written at the front of the order-book account so a wrong or +/// uninitialized account can't be cast as a live book. Analogous to Anchor's +/// account discriminator; the value is arbitrary but stable. +pub const ORDER_BOOK_DISCRIMINATOR: [u8; 8] = *b"ORDRBOOK"; + +/// 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`. +/// +/// Accessed zero-copy: the handler casts the account's raw byte region to +/// `&mut OrderBook` with `bytemuck`. The account is far larger than a borsh +/// (de)serialization would happily handle on every instruction, so the raw +/// cast pays no per-field cost. This mirrors the Anchor build's +/// `AccountLoader`; Quasar has no `AccountLoader`, so the cast is +/// done by hand (see `load_order_book*`). +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct OrderBook { + /// Market this book belongs to (raw 32-byte address). Bound to the market + /// via the market's stored `order_book` address, checked in each handler. + pub market: [u8; 32], + + /// 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, back-to-back so the whole struct is one contiguous + /// zero-copy region. + pub bids: OrderTreeNodes, + pub asks: OrderTreeNodes, +} + +/// Total on-chain size: discriminator + struct. The client sizes the +/// order-book account to exactly this at `create_account` time. +pub const ORDER_BOOK_ACCOUNT_SIZE: usize = 8 + core::mem::size_of::(); + +// Compile-time check: catch accidental layout drift if anyone adds a field +// without recomputing space. 32 (market) + 8 (bids_root) + 8 (asks_root) +// + 8 (next_order_id) + 1 (bump) + 7 (pad) + 2 * (1 + 3 + 4 + 4 + 4 + 88*1024). +const _: () = { + assert!( + core::mem::size_of::() + == 32 + 8 + 8 + 8 + 1 + 7 + 2 * (1 + 3 + 4 + 4 + 4 + NODE_SIZE * MAX_TREE_NODES) + ); +}; + +/// Cast an initialized order-book account's bytes to `&OrderBook`. +pub fn load_order_book(data: &[u8]) -> Result<&OrderBook, ProgramError> { + if data.len() < ORDER_BOOK_ACCOUNT_SIZE { + return Err(ProgramError::AccountDataTooSmall); + } + if data[..8] != ORDER_BOOK_DISCRIMINATOR { + return Err(OrderBookError::InvalidOrderBook.into()); + } + bytemuck::try_from_bytes(&data[8..8 + core::mem::size_of::()]) + .map_err(|_| ProgramError::InvalidAccountData) +} + +/// Cast an initialized order-book account's bytes to `&mut OrderBook`. +pub fn load_order_book_mut(data: &mut [u8]) -> Result<&mut OrderBook, ProgramError> { + if data.len() < ORDER_BOOK_ACCOUNT_SIZE { + return Err(ProgramError::AccountDataTooSmall); + } + if data[..8] != ORDER_BOOK_DISCRIMINATOR { + return Err(OrderBookError::InvalidOrderBook.into()); + } + bytemuck::try_from_bytes_mut(&mut data[8..8 + core::mem::size_of::()]) + .map_err(|_| ProgramError::InvalidAccountData) +} + +/// First-time cast of a freshly `create_account`-d (zeroed) order-book account. +/// Verifies the discriminator slot is still zero (not already initialized), +/// stamps the discriminator, and returns the zeroed `&mut OrderBook` for the +/// handler to initialize in place. +pub fn load_order_book_init(data: &mut [u8]) -> Result<&mut OrderBook, ProgramError> { + if data.len() < ORDER_BOOK_ACCOUNT_SIZE { + return Err(ProgramError::AccountDataTooSmall); + } + if data[..8] != [0u8; 8] { + return Err(OrderBookError::OrderBookAlreadyInitialized.into()); + } + data[..8].copy_from_slice(&ORDER_BOOK_DISCRIMINATOR); + bytemuck::try_from_bytes_mut(&mut data[8..8 + core::mem::size_of::()]) + .map_err(|_| ProgramError::InvalidAccountData) +} + +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: [u8; 32], 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. 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(OrderBookError::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: [u8; 32], + order_id: u64, + timestamp: i64, + ) -> Result<(), ProgramError> { + 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 from a specific side. Returns `true` if it was + /// found and removed. The side is known at cancel time (from the Order + /// PDA), so no cross-side scan is needed. + pub fn remove_from(&mut self, side: OrderSide, order_id: u64) -> bool { + let (root, nodes) = match side { + OrderSide::Bid => (&mut self.bids_root, &mut self.bids), + OrderSide::Ask => (&mut self.asks_root, &mut self.asks), + }; + + // 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. + // Cheap relative to a CPI, and only happens at cancellation. + 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; + } + } + match found_key { + Some(key) => nodes.remove_by_key(root, key).is_some(), + None => false, + } + } + + /// 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<(), ProgramError> { + 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, deciding 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(OrderBookError::OrderNotFound)?; + let remaining = leaf + .quantity + .checked_sub(fill_quantity) + .ok_or(OrderBookError::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(OrderBookError::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(), + } + } +} diff --git a/finance/order-book/quasar/src/state/slab/LICENSE-OPENBOOK b/finance/order-book/quasar/src/state/slab/LICENSE-OPENBOOK new file mode 100644 index 00000000..c1b4c881 --- /dev/null +++ b/finance/order-book/quasar/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/finance/order-book/quasar/src/state/slab/iterator.rs b/finance/order-book/quasar/src/state/slab/iterator.rs new file mode 100644 index 00000000..189c2531 --- /dev/null +++ b/finance/order-book/quasar/src/state/slab/iterator.rs @@ -0,0 +1,110 @@ +// 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. +// +// The traversal stack is a fixed-size array of node handles rather than a heap +// `Vec` of node references: this program is `no_std` with no allocator. A +// critbit tree over 128-bit keys is at most 128 inner nodes deep (each step +// down strictly increases the shared-prefix length, which is < 128), so the +// stack can never overflow. + +use super::nodes::{NodeHandle, NodeRef}; +use super::ordertree::{OrderTreeNodes, OrderTreeRoot, OrderTreeType}; +use super::LeafNode; + +/// Upper bound on critbit-tree depth: one inner node per meaningful key bit. +const MAX_ITER_DEPTH: usize = 128; + +/// 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, + + /// Handles of inner nodes whose "second" branch (in walk direction) we + /// still owe a visit to. + stack: [NodeHandle; MAX_ITER_DEPTH], + stack_len: usize, + + /// Cached next leaf so `next` can return it while pre-computing the one + /// after. + 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: [0; MAX_ITER_DEPTH], + stack_len: 0, + next_leaf: None, + first, + second, + }; + if let Some(handle) = root.node() { + iter.next_leaf = iter.walk_to_first_leaf(handle); + } + iter + } + + fn walk_to_first_leaf(&mut self, start: NodeHandle) -> Option<(NodeHandle, &'a LeafNode)> { + // Copy the `&'a` node-arena reference out so leaf references we return + // carry lifetime `'a`, independent of the `&mut self` used to push + // onto the stack. + let nodes = self.nodes; + let mut current = start; + loop { + match nodes.node(current)?.case()? { + NodeRef::Inner(inner) => { + if self.stack_len < MAX_ITER_DEPTH { + self.stack[self.stack_len] = current; + self.stack_len += 1; + } + 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 = if self.stack_len == 0 { + None + } else { + self.stack_len -= 1; + let inner_handle = self.stack[self.stack_len]; + match self.nodes.node(inner_handle).and_then(|node| node.case()) { + Some(NodeRef::Inner(inner)) => { + let start = inner.children[self.second]; + self.walk_to_first_leaf(start) + } + _ => None, + } + }; + Some(current) + } +} diff --git a/finance/order-book/quasar/src/state/slab/mod.rs b/finance/order-book/quasar/src/state/slab/mod.rs new file mode 100644 index 00000000..ff9ea16f --- /dev/null +++ b/finance/order-book/quasar/src/state/slab/mod.rs @@ -0,0 +1,17 @@ +// 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, AnyNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag, NODE_SIZE, +}; +pub use ordertree::{OrderTreeNodes, OrderTreeRoot, OrderTreeType, MAX_TREE_NODES}; diff --git a/finance/order-book/quasar/src/state/slab/nodes.rs b/finance/order-book/quasar/src/state/slab/nodes.rs new file mode 100644 index 00000000..a88a3793 --- /dev/null +++ b/finance/order-book/quasar/src/state/slab/nodes.rs @@ -0,0 +1,341 @@ +// 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 core::mem::{align_of, size_of}; + +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). +/// +/// `owner` is the raw 32-byte address (not Quasar's `Address`) so `LeafNode` +/// stays a plain `bytemuck::Pod`; the handler converts to/from `Address` at +/// the boundary. +/// +/// `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 (raw 32-byte address). 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: [u8; 32], + + /// Quantity remaining (in base lots). 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: [u8; 32], + 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/finance/order-book/quasar/src/state/slab/ordertree.rs b/finance/order-book/quasar/src/state/slab/ordertree.rs new file mode 100644 index 00000000..80b35e32 --- /dev/null +++ b/finance/order-book/quasar/src/state/slab/ordertree.rs @@ -0,0 +1,330 @@ +// 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 +// `OrderBookError`. The tree mechanics (critbit / radix-trie insert + remove, +// min/max walks) are preserved so future contributors can diff against +// upstream cleanly. Two write-only traversal stacks upstream carried have been +// dropped: this program is `no_std` with no heap, and neither stack was ever +// read. + +use bytemuck::{cast, cast_ref}; +use quasar_lang::prelude::ProgramError; +use static_assertions::const_assert_eq; + +use super::nodes::{AnyNode, FreeNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag}; +use crate::errors::OrderBookError; + +/// 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, Default, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct OrderTreeRoot { + pub maybe_node: NodeHandle, + pub leaf_count: u32, +} +const_assert_eq!(core::mem::size_of::(), 8); + +impl OrderTreeRoot { + pub fn node(&self) -> Option { + if self.leaf_count == 0 { + None + } else { + Some(self.maybe_node) + } + } +} + +/// Which side of the book this tree represents. +/// +/// Iteration order is determined by this: bids walk highest-key-first (best +/// bid first), asks walk lowest-key-first (best ask first). Since price is +/// stored in the high bits of the key, that's also best-price-first on both +/// sides. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum OrderTreeType { + Bids = 0, + Asks = 1, +} + +/// The slab itself. A fixed-size array of nodes plus the bookkeeping needed +/// to allocate and free slots in O(1): +/// - `bump_index` is the next never-used slot (used when the free list is +/// empty) +/// - `free_list_head` + `free_list_len` chain reclaimed slots, so cancels +/// reuse memory without compaction. +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct OrderTreeNodes { + /// `OrderTreeType`, as a u8 so the struct stays Pod. + pub order_tree_type: u8, + pub padding: [u8; 3], + pub bump_index: u32, + pub free_list_len: u32, + pub free_list_head: NodeHandle, + pub nodes: [AnyNode; MAX_TREE_NODES], +} +const_assert_eq!( + core::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 { + 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), + }; + + 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; + } + 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(OrderBookError::OrderBookFull.into()), + }; + + if self.free_list_len == 0 { + if !((self.bump_index as usize) < self.nodes.len() && self.bump_index < u32::MAX) { + return Err(OrderBookError::OrderBookFull.into()); + } + 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), ProgramError> { + // 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)); + } + }; + + loop { + let parent_contents = *self + .node(parent_handle) + .ok_or(OrderBookError::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); + 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(); + + 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/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs new file mode 100644 index 00000000..cb71fd15 --- /dev/null +++ b/finance/order-book/quasar/src/tests.rs @@ -0,0 +1,637 @@ +//! QuasarSVM integration tests. They drive the real program instructions +//! end-to-end: initialize a market, create users, place and cross orders, +//! settle, and withdraw fees, asserting on-chain state and token balances at +//! each step. +//! +//! Multi-step flows use `process_instruction_chain`, which runs several +//! instructions atomically over a shared, evolving account set - so a resting +//! order placed by one instruction is visible to the crossing order in the +//! next, without hand-building the zero-copy slab. + +extern crate std; + +use { + alloc::vec, + alloc::vec::Vec, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::state::{MARKET_SEED, MARKET_USER_SEED, ORDER_BOOK_ACCOUNT_SIZE, ORDER_SEED}; + +// --- Market parameters used across the tests (NVDAx-style base / USDC-style +// quote): base 9 decimals, quote 6 decimals. base_lot_size = 10^(9-6) = 1000, +// quote_lot_size = 1, so `price` reads as quote units per base lot. --- +const FEE_BASIS_POINTS: u16 = 100; // 1% +const TICK_SIZE: u64 = 1; +const BASE_LOT_SIZE: u64 = 1000; +const QUOTE_LOT_SIZE: u64 = 1; +const MIN_ORDER_SIZE: u64 = 1; +const BASE_DECIMALS: u8 = 9; +const QUOTE_DECIMALS: u8 = 6; + +const STARTING_LAMPORTS: u64 = 1_000_000_000; + +fn program_id() -> Pubkey { + Pubkey::new_from_array(crate::ID.to_bytes()) +} + +fn setup() -> QuasarSvm { + let elf = std::fs::read("target/deploy/quasar_order_book.so").unwrap(); + QuasarSvm::new() + .with_program(&program_id(), &elf) + .with_token_program() +} + +fn rent_id() -> Pubkey { + quasar_svm::solana_sdk_ids::sysvar::rent::ID +} +fn token_program_id() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn system_program_id() -> Pubkey { + quasar_svm::system_program::ID +} + +fn signer_account(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) +} + +fn empty_account(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: system_program_id(), + executable: false, + } +} + +fn mint_account(address: Pubkey, authority: Pubkey, decimals: u8) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: 1_000_000_000_000, + decimals, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} + +fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} + +/// A program-owned, zeroed order-book account of the exact size the program +/// expects. Stands in for the client's `create_account` call. +fn order_book_account(address: Pubkey) -> Account { + Account { + address, + lamports: 5_000_000_000, + data: vec![0u8; ORDER_BOOK_ACCOUNT_SIZE], + owner: program_id(), + executable: false, + } +} + +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +fn derive_market(base_mint: &Pubkey, quote_mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[MARKET_SEED, base_mint.as_ref(), quote_mint.as_ref()], + &program_id(), + ) + .0 +} + +fn derive_market_user(market: &Pubkey, owner: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[MARKET_USER_SEED, market.as_ref(), owner.as_ref()], + &program_id(), + ) + .0 +} + +fn derive_order(market: &Pubkey, order_id: u64) -> Pubkey { + Pubkey::find_program_address( + &[ORDER_SEED, market.as_ref(), &order_id.to_le_bytes()], + &program_id(), + ) + .0 +} + +// --- Instruction data builders (discriminator byte + little-endian args) --- + +fn initialize_market_data() -> Vec { + let mut data = vec![0u8]; + data.extend_from_slice(&FEE_BASIS_POINTS.to_le_bytes()); + data.extend_from_slice(&TICK_SIZE.to_le_bytes()); + data.extend_from_slice(&BASE_LOT_SIZE.to_le_bytes()); + data.extend_from_slice("E_LOT_SIZE.to_le_bytes()); + data.extend_from_slice(&MIN_ORDER_SIZE.to_le_bytes()); + data +} + +fn create_market_user_data() -> Vec { + vec![1u8] +} + +fn place_order_data(side: u8, price: u64, quantity: u64, order_id: u64) -> Vec { + let mut data = vec![2u8, side]; + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&quantity.to_le_bytes()); + data.extend_from_slice(&order_id.to_le_bytes()); + data +} + +fn cancel_order_data() -> Vec { + vec![3u8] +} + +fn settle_funds_data() -> Vec { + vec![4u8] +} + +fn withdraw_fees_data() -> Vec { + vec![5u8] +} + +/// A market fixture: the mints, PDA, vaults, and order-book account for one +/// (base, quote) pair. +struct MarketFixture { + authority: Pubkey, + base_mint: Pubkey, + quote_mint: Pubkey, + market: Pubkey, + order_book: Pubkey, + base_vault: Pubkey, + quote_vault: Pubkey, + fee_vault: Pubkey, +} + +fn market_fixture() -> MarketFixture { + let authority = Pubkey::new_unique(); + let base_mint = Pubkey::new_unique(); + let quote_mint = Pubkey::new_unique(); + MarketFixture { + authority, + base_mint, + quote_mint, + market: derive_market(&base_mint, "e_mint), + order_book: Pubkey::new_unique(), + base_vault: Pubkey::new_unique(), + quote_vault: Pubkey::new_unique(), + fee_vault: Pubkey::new_unique(), + } +} + +fn initialize_market_ix(fx: &MarketFixture) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(fx.authority, true), + AccountMeta::new(fx.market, false), + AccountMeta::new(fx.order_book, false), + AccountMeta::new_readonly(fx.base_mint, false), + AccountMeta::new_readonly(fx.quote_mint, false), + AccountMeta::new(fx.base_vault, true), + AccountMeta::new(fx.quote_vault, true), + AccountMeta::new(fx.fee_vault, true), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: initialize_market_data(), + } +} + +fn create_market_user_ix(fx: &MarketFixture, owner: Pubkey, market_user: Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(owner, true), + AccountMeta::new_readonly(fx.market, false), + AccountMeta::new(market_user, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: create_market_user_data(), + } +} + +/// One trader's per-market accounts. +struct Trader { + owner: Pubkey, + market_user: Pubkey, + base_account: Pubkey, + quote_account: Pubkey, +} + +fn trader(fx: &MarketFixture) -> Trader { + let owner = Pubkey::new_unique(); + Trader { + owner, + market_user: derive_market_user(&fx.market, &owner), + base_account: Pubkey::new_unique(), + quote_account: Pubkey::new_unique(), + } +} + +#[allow(clippy::too_many_arguments)] +fn place_order_ix( + fx: &MarketFixture, + trader: &Trader, + order: Pubkey, + side: u8, + price: u64, + quantity: u64, + order_id: u64, + makers: &[(Pubkey, Pubkey)], +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new_readonly(fx.market, false), + AccountMeta::new(fx.order_book, false), + AccountMeta::new(order, false), + AccountMeta::new(trader.market_user, false), + AccountMeta::new(fx.base_vault, false), + AccountMeta::new(fx.quote_vault, false), + AccountMeta::new(fx.fee_vault, false), + AccountMeta::new(trader.base_account, false), + AccountMeta::new(trader.quote_account, false), + AccountMeta::new_readonly(fx.base_mint, false), + AccountMeta::new_readonly(fx.quote_mint, false), + AccountMeta::new(trader.owner, true), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ]; + for (maker_order, maker_market_user) in makers { + accounts.push(AccountMeta::new(*maker_order, false)); + accounts.push(AccountMeta::new(*maker_market_user, false)); + } + Instruction { + program_id: program_id(), + accounts, + data: place_order_data(side, price, quantity, order_id), + } +} + +fn cancel_order_ix(fx: &MarketFixture, trader: &Trader, order: Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new_readonly(fx.market, false), + AccountMeta::new(fx.order_book, false), + AccountMeta::new(order, false), + AccountMeta::new(trader.market_user, false), + AccountMeta::new_readonly(trader.owner, true), + ], + data: cancel_order_data(), + } +} + +fn settle_funds_ix(fx: &MarketFixture, trader: &Trader) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new_readonly(trader.owner, true), + AccountMeta::new_readonly(fx.market, false), + AccountMeta::new(trader.market_user, false), + AccountMeta::new(fx.base_vault, false), + AccountMeta::new(fx.quote_vault, false), + AccountMeta::new(trader.base_account, false), + AccountMeta::new(trader.quote_account, false), + AccountMeta::new_readonly(fx.base_mint, false), + AccountMeta::new_readonly(fx.quote_mint, false), + AccountMeta::new_readonly(token_program_id(), false), + ], + data: settle_funds_data(), + } +} + +fn withdraw_fees_ix(fx: &MarketFixture, authority: Pubkey, authority_quote: Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new_readonly(fx.market, false), + AccountMeta::new(fx.fee_vault, false), + AccountMeta::new(authority_quote, false), + AccountMeta::new_readonly(fx.quote_mint, false), + AccountMeta::new_readonly(authority, true), + AccountMeta::new_readonly(token_program_id(), false), + ], + data: withdraw_fees_data(), + } +} + +// --- Order-account field offsets (dense discriminator + fields, little-endian). +// disc(1) market(32) owner(32) order_id(8) side(1) price(8) original(8) +// filled(8) status(1) timestamp(8) bump(1). --- +const ORDER_STATUS_OFFSET: usize = 1 + 32 + 32 + 8 + 1 + 8 + 8 + 8; +const ORDER_FILLED_OFFSET: usize = 1 + 32 + 32 + 8 + 1 + 8 + 8; + +fn order_status(account: &Account) -> u8 { + account.data[ORDER_STATUS_OFFSET] +} +fn order_filled(account: &Account) -> u64 { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&account.data[ORDER_FILLED_OFFSET..ORDER_FILLED_OFFSET + 8]); + u64::from_le_bytes(bytes) +} + +// --- MarketUser field offsets. disc(1) market(32) owner(32) unsettled_base(8) +// unsettled_quote(8) open_orders_len(1) bump(1) open_orders(160). --- +const MU_UNSETTLED_BASE_OFFSET: usize = 1 + 32 + 32; +const MU_UNSETTLED_QUOTE_OFFSET: usize = 1 + 32 + 32 + 8; +const MU_OPEN_ORDERS_LEN_OFFSET: usize = 1 + 32 + 32 + 8 + 8; + +fn mu_unsettled_base(account: &Account) -> u64 { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&account.data[MU_UNSETTLED_BASE_OFFSET..MU_UNSETTLED_BASE_OFFSET + 8]); + u64::from_le_bytes(bytes) +} +fn mu_unsettled_quote(account: &Account) -> u64 { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&account.data[MU_UNSETTLED_QUOTE_OFFSET..MU_UNSETTLED_QUOTE_OFFSET + 8]); + u64::from_le_bytes(bytes) +} +fn mu_open_orders_len(account: &Account) -> u8 { + account.data[MU_OPEN_ORDERS_LEN_OFFSET] +} + +// Order status codes (mirror state::OrderStatus). +const STATUS_FILLED: u8 = 2; +const STATUS_CANCELLED: u8 = 3; + +#[test] +fn test_initialize_market() { + let mut svm = setup(); + let fx = market_fixture(); + + let accounts = vec![ + signer_account(fx.authority), + empty_account(fx.market), + order_book_account(fx.order_book), + mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), + mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), + empty_account(fx.base_vault), + empty_account(fx.quote_vault), + empty_account(fx.fee_vault), + ]; + + let result = svm.process_instruction(&initialize_market_ix(&fx), &accounts); + assert!(result.is_ok(), "initialize_market failed: {:?}", result.raw_result); + + // Market discriminator (dense = 1) is stamped. + let market = result.account(&fx.market).unwrap(); + assert_eq!(market.data[0], 1, "market discriminator"); + + // Order-book discriminator + next_order_id == 1. Layout: disc(8) then + // market(32), bids_root(8), asks_root(8), next_order_id(8)... + let order_book = result.account(&fx.order_book).unwrap(); + assert_eq!(&order_book.data[0..8], b"ORDRBOOK", "order-book discriminator"); + let next_order_id_offset = 8 + 32 + 8 + 8; + let mut id_bytes = [0u8; 8]; + id_bytes.copy_from_slice(&order_book.data[next_order_id_offset..next_order_id_offset + 8]); + assert_eq!(u64::from_le_bytes(id_bytes), 1, "next_order_id starts at 1"); + + println!(" INITIALIZE_MARKET CU: {}", result.compute_units_consumed); +} + +#[test] +fn test_create_market_user() { + let mut svm = setup(); + let fx = market_fixture(); + let maker = trader(&fx); + + let accounts = vec![ + signer_account(fx.authority), + empty_account(fx.market), + order_book_account(fx.order_book), + mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), + mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), + empty_account(fx.base_vault), + empty_account(fx.quote_vault), + empty_account(fx.fee_vault), + signer_account(maker.owner), + empty_account(maker.market_user), + ]; + + let instructions = vec![ + initialize_market_ix(&fx), + create_market_user_ix(&fx, maker.owner, maker.market_user), + ]; + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "chain failed: {:?}", result.raw_result); + + let market_user = result.account(&maker.market_user).unwrap(); + assert_eq!(market_user.data[0], 2, "market_user discriminator"); + assert_eq!(mu_unsettled_base(market_user), 0); + assert_eq!(mu_unsettled_quote(market_user), 0); + assert_eq!(mu_open_orders_len(market_user), 0); + + println!(" CREATE_MARKET_USER CU: {}", result.compute_units_consumed); +} + +/// Full lifecycle: a maker rests an ask, a taker bid crosses it fully, both +/// settle, and the authority withdraws the fee. Prices in the NVDAx/USDC lot +/// model: ask 5 lots @ 100 -> gross 500 quote, 1% fee = 5, maker nets 495 +/// quote, taker receives 5000 raw base. +#[test] +fn test_place_match_settle_withdraw() { + let mut svm = setup(); + let fx = market_fixture(); + let maker = trader(&fx); + let taker = trader(&fx); + let maker_order = derive_order(&fx.market, 1); + let taker_order = derive_order(&fx.market, 2); + let authority_quote = Pubkey::new_unique(); + + // Maker sells 5 base lots (locks 5 * 1000 = 5000 raw base); taker buys 5 + // lots at 100 (locks 100 * 5 * 1 = 500 raw quote). + const PRICE: u64 = 100; + const QUANTITY: u64 = 5; + const MAKER_BASE_LOCK: u64 = QUANTITY * BASE_LOT_SIZE; // 5000 + const TAKER_QUOTE_LOCK: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; // 500 + const GROSS_QUOTE: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; // 500 + const FEE_QUOTE: u64 = 5; // ceil(500 * 100 / 10000) + const MAKER_NET_QUOTE: u64 = GROSS_QUOTE - FEE_QUOTE; // 495 + + let accounts = vec![ + signer_account(fx.authority), + empty_account(fx.market), + order_book_account(fx.order_book), + mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), + mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), + empty_account(fx.base_vault), + empty_account(fx.quote_vault), + empty_account(fx.fee_vault), + // maker + signer_account(maker.owner), + empty_account(maker.market_user), + empty_account(maker_order), + token_account(maker.base_account, fx.base_mint, maker.owner, MAKER_BASE_LOCK), + token_account(maker.quote_account, fx.quote_mint, maker.owner, 0), + // taker + signer_account(taker.owner), + empty_account(taker.market_user), + empty_account(taker_order), + token_account(taker.base_account, fx.base_mint, taker.owner, 0), + token_account(taker.quote_account, fx.quote_mint, taker.owner, TAKER_QUOTE_LOCK), + // fee withdrawal destination + token_account(authority_quote, fx.quote_mint, fx.authority, 0), + ]; + + let instructions = vec![ + initialize_market_ix(&fx), + create_market_user_ix(&fx, maker.owner, maker.market_user), + create_market_user_ix(&fx, taker.owner, taker.market_user), + // Maker ask (id 1) rests on the book. + place_order_ix(&fx, &maker, maker_order, 1, PRICE, QUANTITY, 1, &[]), + // Taker bid (id 2) crosses the maker ask; maker accounts supplied as + // remaining accounts. + place_order_ix( + &fx, + &taker, + taker_order, + 0, + PRICE, + QUANTITY, + 2, + &[(maker_order, maker.market_user)], + ), + // Both settle, then the authority sweeps the fee vault. + settle_funds_ix(&fx, &maker), + settle_funds_ix(&fx, &taker), + withdraw_fees_ix(&fx, fx.authority, authority_quote), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "lifecycle chain failed: {:?}", result.raw_result); + + // Both orders fully filled. + assert_eq!(order_status(result.account(&maker_order).unwrap()), STATUS_FILLED); + assert_eq!(order_filled(result.account(&maker_order).unwrap()), QUANTITY); + assert_eq!(order_status(result.account(&taker_order).unwrap()), STATUS_FILLED); + assert_eq!(order_filled(result.account(&taker_order).unwrap()), QUANTITY); + + // Maker's open-orders list emptied when its resting order fully filled. + assert_eq!(mu_open_orders_len(result.account(&maker.market_user).unwrap()), 0); + + // Settlement moved tokens: maker received net quote, taker received base. + assert_eq!( + token_amount(result.account(&maker.quote_account).unwrap()), + MAKER_NET_QUOTE + ); + assert_eq!( + token_amount(result.account(&taker.base_account).unwrap()), + MAKER_BASE_LOCK + ); + + // Fee swept to the authority. + assert_eq!(token_amount(result.account(&authority_quote).unwrap()), FEE_QUOTE); + assert_eq!(token_amount(result.account(&fx.fee_vault).unwrap()), 0); + + // Vaults drained after settlement (maker sold all base, taker paid gross). + assert_eq!(token_amount(result.account(&fx.base_vault).unwrap()), 0); + assert_eq!(token_amount(result.account(&fx.quote_vault).unwrap()), 0); + + println!(" LIFECYCLE CU: {}", result.compute_units_consumed); +} + +/// Cancelling a resting order credits the locked base back to the owner's +/// unsettled balance and marks the order cancelled. +#[test] +fn test_cancel_order() { + let mut svm = setup(); + let fx = market_fixture(); + let maker = trader(&fx); + let maker_order = derive_order(&fx.market, 1); + + const PRICE: u64 = 100; + const QUANTITY: u64 = 5; + const MAKER_BASE_LOCK: u64 = QUANTITY * BASE_LOT_SIZE; + + let accounts = vec![ + signer_account(fx.authority), + empty_account(fx.market), + order_book_account(fx.order_book), + mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), + mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), + empty_account(fx.base_vault), + empty_account(fx.quote_vault), + empty_account(fx.fee_vault), + signer_account(maker.owner), + empty_account(maker.market_user), + empty_account(maker_order), + token_account(maker.base_account, fx.base_mint, maker.owner, MAKER_BASE_LOCK), + token_account(maker.quote_account, fx.quote_mint, maker.owner, 0), + ]; + + let instructions = vec![ + initialize_market_ix(&fx), + create_market_user_ix(&fx, maker.owner, maker.market_user), + place_order_ix(&fx, &maker, maker_order, 1, PRICE, QUANTITY, 1, &[]), + cancel_order_ix(&fx, &maker, maker_order), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "cancel chain failed: {:?}", result.raw_result); + + assert_eq!(order_status(result.account(&maker_order).unwrap()), STATUS_CANCELLED); + + // The locked base is credited back to the owner's unsettled balance and + // the open-order slot is freed. + let market_user = result.account(&maker.market_user).unwrap(); + assert_eq!(mu_unsettled_base(market_user), MAKER_BASE_LOCK); + assert_eq!(mu_open_orders_len(market_user), 0); + + println!(" CANCEL_ORDER CU: {}", result.compute_units_consumed); +} + +/// A non-authority signer cannot withdraw the fee vault. +#[test] +fn test_withdraw_fees_rejects_non_authority() { + let mut svm = setup(); + let fx = market_fixture(); + let attacker = Pubkey::new_unique(); + let attacker_quote = Pubkey::new_unique(); + + let init_accounts = vec![ + signer_account(fx.authority), + empty_account(fx.market), + order_book_account(fx.order_book), + mint_account(fx.base_mint, fx.authority, BASE_DECIMALS), + mint_account(fx.quote_mint, fx.authority, QUOTE_DECIMALS), + empty_account(fx.base_vault), + empty_account(fx.quote_vault), + empty_account(fx.fee_vault), + signer_account(attacker), + token_account(attacker_quote, fx.quote_mint, attacker, 0), + ]; + + let instructions = vec![ + initialize_market_ix(&fx), + withdraw_fees_ix(&fx, attacker, attacker_quote), + ]; + let result = svm.process_instruction_chain(&instructions, &init_accounts); + assert!( + !result.is_ok(), + "withdraw_fees must reject a signer who is not the market authority" + ); +}