diff --git a/CHANGELOG.md b/CHANGELOG.md index d3add3c05..b6e5d3815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-23] - The order book evicts its worst order when a side is full + +A side of the order book refused every new resting order once it was full, so +anyone willing to lock the minimum order size and pay rent for each slot could +fill a side with orders far from the spread and keep every new order on that +side out for as long as they liked. The market authority had no remedy, and +the market PDA's seeds are the two mints, so the pair could not move to a new +market. Now an order that beats a full side's worst price evicts that order +and rests in its place. The evicted order is refunded through its owner's +unsettled balance, exactly as `cancel_order` refunds it, and stamped +`Cancelled`. An order that does not beat the worst price still gets +`OrderBookFull`. The caller passes the worst order and its owner's +`MarketUser` after the maker pairs, or only the order when it is their own. +The capacity was also misstated: a side holds 512 orders, not 1024, because +every order after the first adds a leaf and an inner node to the side's +1024-node tree. All three variants (Anchor v2, Anchor v1, Quasar) change, +with the same five tests in each. + ## [2026-09-23] - Anchor v1 copies match their v2 counterparts where the version allows A scan of every `anchor/` and `anchor-v1/` pair, comparing function names, diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index 18b76d093..9744a0d90 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -9,6 +9,15 @@ doubling prices build a 64-level path to the best ask, and inserting, filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book. +- Ported from the Anchor v2 copy: a full side of the book evicts instead of + refusing. When a side already holds its 512 orders, an order that beats the + side's worst price removes that worst order and rests in its place. The + evicted order is refunded through its owner's unsettled balance, as a cancel + is, and stamped Cancelled. An order that does not beat the worst price still + gets `OrderBookFull`. The caller passes the worst order and its owner's + `MarketUser` after the maker pairs, or only the order when it is their own. + New errors: `MissingEvictedAccounts`, `EvictedAccountMismatch`. Same five + tests as the v2 copy. ### Changed @@ -25,6 +34,12 @@ it is too large for the program to create. Tests derive the vaults instead of generating them. +### Fixed + +- A side holds 512 orders, not 1024: every order after the first adds a leaf + and an inner node to the side's 1024-node tree. `MAX_ORDERS_PER_SIDE` and + the README now say so. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/order-book/anchor-v1/README.md b/finance/order-book/anchor-v1/README.md index 29b411d00..06281e9af 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -74,7 +74,7 @@ call `settle_funds` to pull their balances out. (base vault, quote vault, fee vault, order book), and the pubkey that can withdraw accumulated fees. - An **OrderBook** account - two stores: bids sorted highest-first, - asks sorted lowest-first, each holding up to 1024 entries. Rather + asks sorted lowest-first, each holding up to 512 orders. Rather than a plain list of orders, each side uses a depth-bounded tree (a critbit trie) for fast lookup - see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). Each entry stores enough to drive matching (price, quantity, @@ -372,7 +372,7 @@ Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) st ### State / data accounts - `Market`: PDA yes, seeds `["market", base_mint, quote_mint]`, authority program, holds fee rate, tick size, min order size, base/quote mint pubkeys, vault pubkeys, order book pubkey, `authority` wallet (allowed to withdraw fees) -- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `has_one`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id` +- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `has_one`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 512 orders each: every order after the first adds a leaf and an inner node to a 1024-node tree), `next_order_id` - `Order`: PDA yes, seeds `["order", market, order_id.to_le_bytes()]`, authority program, holds owner, side, price, original_quantity, filled_quantity, status, timestamp - `MarketUser`: PDA yes, seeds `["market_user", market, owner]`, authority program, holds `unsettled_base`, `unsettled_quote`, `open_orders: Vec` (max 20) @@ -627,6 +627,20 @@ remaining_accounts[2*i] = maker_order_pda (Order account) remaining_accounts[2*i + 1] = maker_user_account_pda (MarketUser) ``` +If the order will rest on a side that is already full, the side's +worst-priced order follows the maker pairs, so the program can evict it +(see the checks before resting, below): + +``` +remaining_accounts[2*fills] = evicted_order_pda (Order account) +remaining_accounts[2*fills + 1] = evicted_user_account_pda (MarketUser) +``` + +When the worst order is the caller's own, pass only the `Order` +account: the caller's `MarketUser` is already `market_user`, and the +program credits the refund there. The Anchor v2 copy has the same rule, +because Anchor v2 refuses the same writable account twice. + If the caller doesn't pass any pairs, the order is treated as pure-maker: whatever part of it is allowed by the book state becomes a resting order. @@ -639,7 +653,7 @@ resting order. - `quantity >= min_order_size` → `BelowMinOrderSize` - `open_orders.len() < 20` (mirror of the max_len on the struct) → `TooManyOpenOrders` -- `remaining_accounts.len() % 2 == 0` → `MissingMakerAccounts` +- `remaining_accounts.len() >= 2 * fills` → `MissingMakerAccounts` **Checks (per maker pair, during planning):** @@ -658,8 +672,15 @@ resting order. **Checks (before resting remainder):** -- the taker's side of the book isn't at its 1024-leaf capacity → - `OrderBookFull` +- If the taker's side already holds its 512 orders, the remainder + must beat that side's worst price (a higher bid or a lower ask; + equal is not better, because the resting order was there first) → + `OrderBookFull`. When it does, the worst order is **evicted**: + - The caller passed that order, and its owner's `MarketUser` → + `MissingEvictedAccounts` + - The passed order is the worst order on this market, and the + `MarketUser` belongs to its owner on this market → + `EvictedAccountMismatch` - Integer math throughout: every multiplication uses `checked_mul`; every addition on balances uses `checked_add`; every product of two `u64` money values is computed in `u128` @@ -737,6 +758,14 @@ On `order_book`: - Taker's remainder (if any) inserted into the correct side in price order +On an evicted order (only when the taker's side was full), exactly as +if its owner had called `cancel_order`: + +- Its owner's `unsettled_quote += price * remaining_quantity` (bid) or + `unsettled_base += remaining_quantity` (ask) +- Removed from the book and from its owner's `open_orders` +- `status = Cancelled` + On the caller's new `order`: - All fields populated @@ -1294,7 +1323,9 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `OrderNotFound`: `cancel_order` failed to locate the order in the book (sanity path) - `MarketPaused`: `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there) - `Unauthorized`: `cancel_order` by someone other than the order owner -- `OrderBookFull`: `place_order` remainder would push the taker's side past 1024 leaves +- `OrderBookFull`: `place_order` remainder would rest on a side holding 512 orders without beating that side's worst price +- `MissingEvictedAccounts`: A full side, and the worst resting order (with its owner's MarketUser) was not passed after the maker pairs +- `EvictedAccountMismatch`: The order passed for eviction is not the side's worst, or the MarketUser passed is not its owner's - `TooManyOpenOrders`: User already has 20 open orders on this market - `InvalidTickSize`: `tick_size == 0` at init, or `price % tick_size != 0` on place - `BelowMinOrderSize`: `min_order_size == 0` at init, or `quantity < min_order_size` on place @@ -1303,7 +1334,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `InvalidFeeBasisPoints`: `fee_basis_points > 10_000` at init - `InvalidFeeVault`: `market.fee_vault` on the struct does not match the passed `fee_vault` (Anchor `has_one`) - `MakerAccountMismatch`: Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order -- `MissingMakerAccounts`: `remaining_accounts.len()` not a multiple of 2 +- `MissingMakerAccounts`: Fewer remaining accounts than two per planned fill - `MakerOwnerMismatch`: Maker Order and MarketUser have different owners - `NotMarketAuthority`: `withdraw_fees` called by wrong signer @@ -1366,10 +1397,19 @@ From [`errors.rs`](programs/order-book/src/errors.rs): `fee_vault` account without re-checking its mint or authority. - **Book capacity check after matching.** The taker's remainder - check happens at the end. A bid that clears enough asks to free - up 3 slots can then rest its own 1-slot remainder even on a - previously-full book - matching the "liquidity-positive" spirit - of an order book. + check happens at the end. Matching removes orders from the other + side only, so a full side stays full; the remainder then rests only + by evicting that side's worst order. + +- **A full side evicts rather than refuses.** Each side holds 512 + orders. Without eviction, anyone willing to lock the minimum order + size and pay rent 512 times could fill a side with orders far from + the spread and hold it, and every new order on that side would be + refused for as long as they liked. With eviction, an order that + beats the side's worst price removes that order and takes its slot, + so the orders that go are the ones least likely to fill, and the + market stays open at the prices that trade. The evicted order is + refunded the same way a cancel is, through `unsettled_*`. ### 6.3 Things this example does *not* do @@ -1507,6 +1547,14 @@ test taker_partially_fills_resting_order_rest_stays_on_book ... ok - `doubling_prices_build_the_deepest_path_prices_allow`: Asks at 63 doubling prices plus two at price 1 make a 64-level path to the best ask - `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: Insert, fill, and cancel at the bottom of that path stay within 15,000 compute units of a shallow book +**Eviction (a full side, 512 bids):** + +- `full_side_refuses_an_order_no_better_than_its_worst`: A worse or equal bid gets `OrderBookFull` +- `better_order_evicts_the_worst_and_rests`: The worst bid is Cancelled and refunded, the new bid rests, the side stays full +- `evicted_maker_settles_their_refund`: The evicted owner's `settle_funds` pays out the refund +- `eviction_rejects_missing_or_wrong_evicted_accounts`: No evicted order, a non-worst order, or the wrong owner's `MarketUser` +- `trader_can_evict_their_own_worst_order`: Only the `Order` account is passed, and the caller's own `MarketUser` is credited + ### CI note The repo's `.github/workflows/anchor-v1.yml` runs `anchor build` before diff --git a/finance/order-book/anchor-v1/programs/order-book/src/errors.rs b/finance/order-book/anchor-v1/programs/order-book/src/errors.rs index b3738c775..71c558856 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/errors.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/errors.rs @@ -70,4 +70,10 @@ pub enum ErrorCode { #[msg("Order book account does not match the market's order book")] InvalidOrderBook, + + #[msg("Book side is full: pass the worst resting order and its owner's MarketUser to evict it")] + MissingEvictedAccounts, + + #[msg("Evicted order provided is not the worst resting order on the full side")] + EvictedAccountMismatch, } diff --git a/finance/order-book/anchor-v1/programs/order-book/src/instructions/cancel_order.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/cancel_order.rs index e03064d12..e5ea54a84 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/instructions/cancel_order.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/cancel_order.rs @@ -2,8 +2,8 @@ use anchor_lang::prelude::*; use crate::errors::ErrorCode; use crate::state::{ - remaining_quantity, remove_open_order, Market, Order, OrderBook, OrderSide, OrderStatus, - MarketUser, ORDER_SEED, MARKET_USER_SEED, + credit_unfilled_lock, remove_open_order, Market, Order, OrderBook, OrderStatus, MarketUser, + ORDER_SEED, MARKET_USER_SEED, }; pub fn handle_cancel_order(context: Context) -> Result<()> { @@ -22,38 +22,11 @@ pub fn handle_cancel_order(context: Context) -> R // Funds the order had locked in the vault are now owed back to the // owner. Credit the appropriate unsettled balance; settle_funds moves // those funds from the vault to the owner's token account. - let remaining = remaining_quantity(order); - if remaining > 0 { - let market_user = &mut context.accounts.market_user; - match order.side { - OrderSide::Bid => { - // u128 intermediates mirror the bid-lock formula in place_order: - // raw_quote = price × remaining × quote_lot_size - let quote_amount: u64 = (order.price as u128) - .checked_mul(remaining as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .checked_mul(context.accounts.market.quote_lot_size as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .try_into() - .map_err(|_| error!(ErrorCode::NumericalOverflow))?; - market_user.unsettled_quote = market_user - .unsettled_quote - .checked_add(quote_amount) - .ok_or(ErrorCode::NumericalOverflow)?; - } - OrderSide::Ask => { - let base_amount: u64 = (remaining as u128) - .checked_mul(context.accounts.market.base_lot_size as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .try_into() - .map_err(|_| error!(ErrorCode::NumericalOverflow))?; - market_user.unsettled_base = market_user - .unsettled_base - .checked_add(base_amount) - .ok_or(ErrorCode::NumericalOverflow)?; - } - } - } + credit_unfilled_lock( + &context.accounts.market, + order, + &mut context.accounts.market_user, + )?; // Remove the leaf from the slab. The current cancel API doesn't tell us // which side the order is on without reading the Order PDA - which we diff --git a/finance/order-book/anchor-v1/programs/order-book/src/instructions/place_order.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/place_order.rs index a00ccfbfa..91d44256e 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/instructions/place_order.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/place_order.rs @@ -5,8 +5,8 @@ use anchor_spl::token_interface::{ use crate::errors::ErrorCode; use crate::state::{ - add_open_order, plan_fills, remove_open_order, Market, Order, OrderBook, OrderSide, - OrderStatus, MarketUser, MARKET_SEED, ORDER_SEED, MARKET_USER_SEED, + add_open_order, credit_unfilled_lock, plan_fills, remove_open_order, Market, Order, OrderBook, + OrderSide, OrderStatus, MarketUser, MARKET_SEED, ORDER_SEED, MARKET_USER_SEED, }; // Mirror of MarketUser.open_orders max_len. Kept as a constant so the @@ -23,8 +23,27 @@ const BASIS_POINTS_DENOMINATOR: u128 = 10_000; // unsettled_* balance - the maker drains them later via settle_funds. This // mirrors how Openbook v2 works and keeps the per-fill account footprint // small. +// +// When the order will rest on a side that is already full, the side's +// worst-priced order follows the maker pairs: [evicted_order, +// evicted_market_user]. When the worst order is the caller's own, only +// [evicted_order] is passed and the caller's `market_user` is credited, as in +// the Anchor v2 copy (where Anchor refuses a writable account that appears +// twice). Here that rule also keeps a second loaded copy of the caller's +// MarketUser from being overwritten when the instruction's accounts are +// written back. See the eviction step below. const ACCOUNTS_PER_MAKER: usize = 2; +/// True when `price` is a better price than `other` for an order on `side`: +/// higher for a bid, lower for an ask. Equal is not better, because at equal +/// price the resting order has time priority. +fn is_better_price(side: OrderSide, price: u64, other: u64) -> bool { + match side { + OrderSide::Bid => price > other, + OrderSide::Ask => price < other, + } +} + pub fn handle_place_order<'info>( context: Context<'info, PlaceOrderAccountConstraints<'info>>, side: OrderSide, @@ -108,11 +127,10 @@ pub fn handle_place_order<'info>( // transaction's remaining_accounts, in the same price-time-priority // order the book would walk. We plan fills against the resting tree, // then verify the caller's account list matches the plan, then apply. + // The list need not be a whole number of pairs: an order evicting the + // caller's own worst order ends with a single account (see + // ACCOUNTS_PER_MAKER). let maker_accounts = &context.remaining_accounts; - require!( - maker_accounts.len() % ACCOUNTS_PER_MAKER == 0, - ErrorCode::MissingMakerAccounts - ); let order_book_loader = &context.accounts.order_book; @@ -366,6 +384,73 @@ pub fn handle_place_order<'info>( .checked_add(taker_quote_received) .ok_or(ErrorCode::NumericalOverflow)?; + // --------------------------------------------------------------- + // Eviction. A full side would otherwise refuse every new resting order, + // so anyone with the capital could hold all of its slots with orders + // nobody will fill. Instead, an order that beats the side's worst price + // removes that worst order and takes its slot. The evicted order is + // treated exactly like a cancel: its unfilled lock is credited to its + // owner's unsettled balance, and it is stamped Cancelled. An order that + // does not beat the worst price is still refused with OrderBookFull. + // --------------------------------------------------------------- + let order_to_evict = if taker_remaining > 0 { + let order_book = order_book_loader.load()?; + if order_book.is_side_full(side) { + let worst = order_book.worst(side).ok_or(ErrorCode::OrderBookFull)?; + require!( + is_better_price(side, price, worst.price), + ErrorCode::OrderBookFull + ); + Some(worst) + } else { + None + } + } else { + None + }; + + if let Some(worst) = order_to_evict { + let evicted_index = fills.len() * ACCOUNTS_PER_MAKER; + let evicted_order_info = maker_accounts + .get(evicted_index) + .ok_or(ErrorCode::MissingEvictedAccounts)?; + let mut evicted_order = Account::::try_from(evicted_order_info)?; + require!( + evicted_order.order_id == worst.order_id && evicted_order.market == market.key(), + ErrorCode::EvictedAccountMismatch + ); + + if evicted_order.owner == context.accounts.owner.key() { + // The worst order is the taker's own: credit their `market_user`, + // which the instruction writes back when it finishes. + credit_unfilled_lock(market, &evicted_order, taker_market_user)?; + remove_open_order(taker_market_user, evicted_order.order_id); + } else { + let evicted_user_info = maker_accounts + .get(evicted_index + 1) + .ok_or(ErrorCode::MissingEvictedAccounts)?; + let mut evicted_market_user = Account::::try_from(evicted_user_info)?; + require!( + evicted_market_user.owner == evicted_order.owner + && evicted_market_user.market == market.key(), + ErrorCode::EvictedAccountMismatch + ); + credit_unfilled_lock(market, &evicted_order, &mut evicted_market_user)?; + remove_open_order(&mut evicted_market_user, evicted_order.order_id); + evicted_market_user.exit(context.program_id)?; + } + + { + let mut order_book = order_book_loader.load_mut()?; + require!( + order_book.remove_from(side, worst.order_id).is_some(), + ErrorCode::OrderNotFound + ); + } + evicted_order.status = OrderStatus::Cancelled; + evicted_order.exit(context.program_id)?; + } + // --------------------------------------------------------------- // Stamp the taker's Order PDA. Either Filled (no remainder) or rested. // diff --git a/finance/order-book/anchor-v1/programs/order-book/src/state/market_user.rs b/finance/order-book/anchor-v1/programs/order-book/src/state/market_user.rs index 14d80ac9b..f663e8761 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/state/market_user.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/state/market_user.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; +use crate::errors::ErrorCode; +use crate::state::{remaining_quantity, Market, Order, OrderSide}; + pub const MARKET_USER_SEED: &[u8] = b"market_user"; // Per-user, per-market account. Tracks open order ids and amounts owed back @@ -36,3 +39,47 @@ pub fn remove_open_order(account: &mut MarketUser, order_id: u64) { account.open_orders.remove(position); } } + +/// Credit the owner of a resting `order` with the funds its unfilled +/// remainder still has locked in the vault: quote for a bid, base for an ask. +/// `settle_funds` later moves the credit to their token account. Used by +/// `cancel_order`, and by `place_order` when it evicts an order from a full +/// side. The arithmetic mirrors the lock in `place_order`, in u128 so +/// high-decimal mints cannot overflow the intermediate product. +pub fn credit_unfilled_lock( + market: &Market, + order: &Order, + market_user: &mut MarketUser, +) -> Result<()> { + let remaining = remaining_quantity(order); + if remaining == 0 { + return Ok(()); + } + match order.side { + OrderSide::Bid => { + let quote_amount: u64 = (order.price as u128) + .checked_mul(remaining as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .checked_mul(market.quote_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; + market_user.unsettled_quote = market_user + .unsettled_quote + .checked_add(quote_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + OrderSide::Ask => { + let base_amount: u64 = (remaining as u128) + .checked_mul(market.base_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; + market_user.unsettled_base = market_user + .unsettled_base + .checked_add(base_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + } + Ok(()) +} diff --git a/finance/order-book/anchor-v1/programs/order-book/src/state/order_book.rs b/finance/order-book/anchor-v1/programs/order-book/src/state/order_book.rs index ec337f828..7f8893131 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/state/order_book.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/state/order_book.rs @@ -9,11 +9,12 @@ use crate::state::OrderSide; pub const ORDER_BOOK_SEED: &[u8] = b"order_book"; -/// Per-side capacity. 1024 leaves is enough for any realistic depth a single -/// market quotes; at 88 bytes per node that's ~90 KB per side, so the whole -/// OrderBook account fits in ~180 KB - well under Solana's per-account ceiling -/// and well within the rent budget a market authority is happy to fund once. -pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; +/// Per-side capacity, in resting orders. Every order after the first adds a +/// leaf and an inner node to the tree, so the side's `MAX_TREE_NODES` slots +/// hold half as many orders. At 88 bytes per node that's ~90 KB per side, so +/// the whole OrderBook account fits in ~180 KB. When a side is full, +/// `place_order` evicts the worst-priced order to make room for a better one. +pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES / 2; /// 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 @@ -183,6 +184,22 @@ impl OrderBook { }) } + /// Resting-order view of the worst-priced leaf on `side`, if any: the + /// order eviction removes when the side is full. + pub fn worst(&self, side: OrderSide) -> Option { + let (root, nodes) = match side { + OrderSide::Bid => (&self.bids_root, &self.bids), + OrderSide::Ask => (&self.asks_root, &self.asks), + }; + let (_handle, leaf) = nodes.worst_leaf(root)?; + Some(RestingOrderView { + order_id: leaf.order_id, + price: leaf.price(), + quantity: leaf.quantity, + owner: leaf.owner, + }) + } + /// Number of resting orders on a side. O(1). pub fn count(&self, side: OrderSide) -> u32 { match side { diff --git a/finance/order-book/anchor-v1/programs/order-book/src/state/slab/ordertree.rs b/finance/order-book/anchor-v1/programs/order-book/src/state/slab/ordertree.rs index 5aeb71437..6d86ab87a 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/state/slab/ordertree.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/state/slab/ordertree.rs @@ -13,9 +13,10 @@ use static_assertions::const_assert_eq; use super::nodes::{AnyNode, FreeNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag}; use crate::errors::ErrorCode; -/// Per-side slab capacity. 1024 leaves easily covers any realistic depth at -/// the prices a single market quotes; the 88-byte node size keeps each side -/// at ~90 KB, well under Solana's 10 MB per-account ceiling. +/// Per-side slab capacity, in nodes. A critbit tree with n leaves also has +/// n - 1 inner nodes, so 1024 nodes hold 512 resting orders (see +/// `MAX_ORDERS_PER_SIDE`). 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. @@ -119,6 +120,15 @@ impl OrderTreeNodes { self.leaf_min_max(find_max, root) } + /// Worst-priced leaf for this tree: the highest ask or the lowest bid. + /// Within that price it is the latest order, because the key's low bits + /// carry the sequence number. Eviction removes this leaf when a side is + /// full. + pub fn worst_leaf(&self, root: &OrderTreeRoot) -> Option<(NodeHandle, &LeafNode)> { + let find_max = self.order_tree_type() == OrderTreeType::Asks; + self.leaf_min_max(find_max, root) + } + fn leaf_min_max( &self, find_max: bool, diff --git a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs index 3d9d53ef0..7ca39bbca 100644 --- a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs @@ -2302,3 +2302,372 @@ fn deepest_path_adds_little_compute_to_insert_fill_and_cancel() { assert!(deep_units < DEFAULT_INSTRUCTION_COMPUTE_UNITS); } } + +// --------------------------------------------------------------------------- +// Eviction: a full side makes room for a better order +// --------------------------------------------------------------------------- +// +// A side's 1024 tree nodes hold 512 resting orders, because every order after +// the first adds a leaf and an inner node. These tests fill the bid side with +// 512 one-lot bids, one per price from EVICTION_WORST_BID_PRICE upward, so the +// worst bid is the first one placed: order ID 1, at the lowest price. + +const ORDERS_PER_SIDE: u64 = 512; +// One under the 20-order cap, so every filler can still place an order of +// their own (the self-eviction test needs that). +const ORDERS_PER_FILLER: u64 = 19; +const EVICTION_WORST_BID_PRICE: u64 = 100; +const EVICTION_ORDER_QUANTITY: u64 = 1; +const EVICTION_BETTER_BID_PRICE: u64 = 10_000; +const WORST_BID_ORDER_ID: u64 = 1; +const ORDER_STATUS_CANCELLED: u8 = 3; + +/// The runtime reports a program error as `Custom(n)`, and `#[error_code]` +/// numbers the variants from 6000, so this is the text a failed transaction +/// carries for `code`. +fn custom_error(code: order_book::errors::ErrorCode) -> String { + format!("Custom({})", code as u32 + 6000) +} + +struct Trader { + keypair: Keypair, + market_user: Pubkey, + base_ata: Pubkey, + quote_ata: Pubkey, +} + +fn create_trader(sc: &mut Scenario) -> Trader { + let keypair = create_wallet(&mut sc.svm, 10_000_000_000).unwrap(); + let base_ata = + create_associated_token_account(&mut sc.svm, &keypair.pubkey(), &sc.base_mint, &sc.payer) + .unwrap(); + let quote_ata = + create_associated_token_account(&mut sc.svm, &keypair.pubkey(), &sc.quote_mint, &sc.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut sc.svm, + &sc.quote_mint, + "e_ata, + TRADER_STARTING_BALANCE, + &sc.authority, + ) + .unwrap(); + let init_ix = build_initialize_market_user_ix(sc, &keypair.pubkey()); + send_transaction_from_instructions(&mut sc.svm, vec![init_ix], &[&keypair], &keypair.pubkey()) + .unwrap(); + let market_user = market_user_pda(&sc.program_id, &sc.market, &keypair.pubkey()); + Trader { + keypair, + market_user, + base_ata, + quote_ata, + } +} + +/// Fill the bid side to capacity. Returns the fillers in order; the first one +/// owns the worst bid, `WORST_BID_ORDER_ID`. +fn fill_bid_side(sc: &mut Scenario) -> Vec { + let mut fillers: Vec = vec![]; + for order_id in 1..=ORDERS_PER_SIDE { + if (order_id - 1) % ORDERS_PER_FILLER == 0 { + fillers.push(create_trader(sc)); + } + let filler = fillers.last().unwrap(); + let price = EVICTION_WORST_BID_PRICE + (order_id - 1); + let ix = build_place_order_ix( + sc, + &filler.keypair, + filler.market_user, + filler.base_ata, + filler.quote_ata, + order_book::state::OrderSide::Bid, + order_id, + price, + EVICTION_ORDER_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&filler.keypair], + &filler.keypair.pubkey(), + ) + .unwrap_or_else(|error| panic!("bid {order_id} should rest: {error:?}")); + } + fillers +} + +#[allow(clippy::too_many_arguments)] +fn place_bid( + sc: &mut Scenario, + trader: &Trader, + order_id: u64, + price: u64, + evicted_pairs: &[(u64, Pubkey)], +) -> Result<(), String> { + let ix = build_place_order_with_makers_ix( + sc, + &trader.keypair, + trader.market_user, + trader.base_ata, + trader.quote_ata, + order_book::state::OrderSide::Bid, + order_id, + price, + EVICTION_ORDER_QUANTITY, + evicted_pairs, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&trader.keypair], + &trader.keypair.pubkey(), + ) + .map_err(|error| format!("{error:?}")) +} + +fn read_open_order_count(svm: &LiteSVM, market_user: &Pubkey) -> u32 { + // After the two unsettled balances comes the Borsh Vec length prefix. + let offset = USER_ACCOUNT_UNSETTLED_QUOTE_OFFSET + 8; + let data = svm.get_account(market_user).unwrap().data; + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +#[test] +fn full_side_refuses_an_order_no_better_than_its_worst() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let next_order_id = ORDERS_PER_SIDE + 1; + + // Worse than the worst bid, and equal to it: equal is not better, + // because the resting bid got there first. + for price in [EVICTION_WORST_BID_PRICE - 1, EVICTION_WORST_BID_PRICE] { + let error = place_bid( + &mut sc, + &fillers[1], + next_order_id, + price, + &[(WORST_BID_ORDER_ID, fillers[0].market_user)], + ) + .expect_err("a bid no better than the worst must not evict it"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::OrderBookFull)), + "{error}" + ); + } + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(status, ORDER_STATUS_OPEN); +} + +#[test] +fn better_order_evicts_the_worst_and_rests() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let new_order_id = ORDERS_PER_SIDE + 1; + + place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, fillers[0].market_user)], + ) + .unwrap(); + + let evicted_order = order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID); + let (_, evicted_status) = read_order_fill_and_status(&sc.svm, &evicted_order); + assert_eq!(evicted_status, ORDER_STATUS_CANCELLED); + + // The evicted bid's whole lock is owed back to its owner, exactly as a + // cancel would owe it. + let (_, evicted_unsettled_quote) = read_user_unsettled(&sc.svm, &fillers[0].market_user); + assert_eq!( + evicted_unsettled_quote, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + assert_eq!( + read_open_order_count(&sc.svm, &fillers[0].market_user), + (ORDERS_PER_FILLER - 1) as u32 + ); + + let (_, new_status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, new_order_id), + ); + assert_eq!(new_status, ORDER_STATUS_OPEN); + assert_eq!(read_open_order_count(&sc.svm, &newcomer.market_user), 1); + + // The side is still full, and its worst bid is now order 2, one tick up. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id + 1, + EVICTION_WORST_BID_PRICE + 1, + &[(WORST_BID_ORDER_ID + 1, fillers[0].market_user)], + ) + .expect_err("the side is still full after an eviction"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::OrderBookFull)), + "{error}" + ); +} + +#[test] +fn evicted_maker_settles_their_refund() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + + let evicted = &fillers[0]; + let quote_before = get_token_account_balance(&sc.svm, &evicted.quote_ata).unwrap(); + + place_bid( + &mut sc, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, evicted.market_user)], + ) + .unwrap(); + + let settle_ix = build_settle_funds_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + evicted.base_ata, + evicted.quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + + let quote_after = get_token_account_balance(&sc.svm, &evicted.quote_ata).unwrap(); + assert_eq!( + quote_after - quote_before, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + assert_eq!(read_user_unsettled(&sc.svm, &evicted.market_user), (0, 0)); +} + +#[test] +fn eviction_rejects_missing_or_wrong_evicted_accounts() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let new_order_id = ORDERS_PER_SIDE + 1; + + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[], + ) + .expect_err("a full side needs the evicted order named"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::MissingEvictedAccounts + )), + "{error}" + ); + + // Order 2 is resting, but it is not the worst bid. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID + 1, fillers[0].market_user)], + ) + .expect_err("only the worst order may be evicted"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::EvictedAccountMismatch + )), + "{error}" + ); + + // The right order, with someone else's MarketUser to credit. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, fillers[1].market_user)], + ) + .expect_err("the refund must go to the evicted order's owner"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::EvictedAccountMismatch + )), + "{error}" + ); + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(status, ORDER_STATUS_OPEN); +} + +#[test] +fn trader_can_evict_their_own_worst_order() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let owner = &fillers[0]; + let new_order_id = ORDERS_PER_SIDE + 1; + + // Only the evicted order is passed: the owner's MarketUser is already the + // instruction's `market_user`, and Anchor refuses it a second time. + let mut ix = build_place_order_ix( + &sc, + &owner.keypair, + owner.market_user, + owner.base_ata, + owner.quote_ata, + order_book::state::OrderSide::Bid, + new_order_id, + EVICTION_BETTER_BID_PRICE, + EVICTION_ORDER_QUANTITY, + ); + ix.accounts.push(AccountMeta::new( + order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + false, + )); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&owner.keypair], + &owner.keypair.pubkey(), + ) + .unwrap(); + + let (_, evicted_status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(evicted_status, ORDER_STATUS_CANCELLED); + let (_, unsettled_quote) = read_user_unsettled(&sc.svm, &owner.market_user); + assert_eq!( + unsettled_quote, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + // One order out, one order in. + assert_eq!( + read_open_order_count(&sc.svm, &owner.market_user), + ORDERS_PER_FILLER as u32 + ); +} diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index 8fdd4af36..31aaa26ad 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -9,6 +9,14 @@ doubling prices build a 64-level path to the best ask, and inserting, filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book. +- A full side of the book evicts instead of refusing. When a side already + holds its 512 orders, an order that beats the side's worst price removes + that worst order and rests in its place. The evicted order is refunded + through its owner's unsettled balance, as a cancel is, and stamped + Cancelled. An order that does not beat the worst price still gets + `OrderBookFull`. The caller passes the worst order and its owner's + `MarketUser` after the maker pairs, or only the order when it is their + own. New errors: `MissingEvictedAccounts`, `EvictedAccountMismatch`. ### Changed @@ -16,6 +24,12 @@ prices, 128 at most) instead of saying it stays shallow whatever order keys arrive in, and says Phoenix uses a red-black tree. +### Fixed + +- A side holds 512 orders, not 1024: every order after the first adds a + leaf and an inner node to the side's 1024-node tree. `MAX_ORDERS_PER_SIDE` + and the README now say so. + ## 2026-09-22 ### Changed diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index e03cd21dd..518689997 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -74,7 +74,7 @@ call `settle_funds` to pull their balances out. (base vault, quote vault, fee vault, order book), and the pubkey that can withdraw accumulated fees. - An **OrderBook** account - two stores: bids sorted highest-first, - asks sorted lowest-first, each holding up to 1024 entries. Rather + asks sorted lowest-first, each holding up to 512 orders. Rather than a plain list of orders, each side uses a depth-bounded tree (a critbit trie) for fast lookup - see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). Each entry stores enough to drive matching (price, quantity, @@ -374,7 +374,7 @@ Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) st ### State / data accounts - `Market`: PDA yes, seeds `["market", base_mint, quote_mint]`, authority program, holds fee rate, tick size, min order size, base/quote mint pubkeys, vault pubkeys, order book pubkey, `authority` wallet (allowed to withdraw fees) -- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `address = market.order_book`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id` +- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `address = market.order_book`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 512 orders each: every order after the first adds a leaf and an inner node to a 1024-node tree), `next_order_id` - `Order`: PDA yes, seeds `["order", market, order_id.to_le_bytes()]`, authority program, holds owner, side, price, original_quantity, filled_quantity, status, timestamp - `MarketUser`: PDA yes, seeds `["market_user", market, owner]`, authority program, holds `unsettled_base`, `unsettled_quote`, `open_orders: Vec` (max 20) @@ -636,6 +636,19 @@ remaining_accounts[2*i] = maker_order_pda (Order account) remaining_accounts[2*i + 1] = maker_user_account_pda (MarketUser) ``` +If the order will rest on a side that is already full, the side's +worst-priced order follows the maker pairs, so the program can evict it +(see the checks before resting, below): + +``` +remaining_accounts[2*fills] = evicted_order_pda (Order account) +remaining_accounts[2*fills + 1] = evicted_user_account_pda (MarketUser) +``` + +When the worst order is the caller's own, pass only the `Order` +account: the caller's `MarketUser` is already `market_user`, and Anchor +refuses the same writable account twice. + If the caller doesn't pass any pairs, the order is treated as pure-maker: whatever part of it is allowed by the book state becomes a resting order. @@ -648,7 +661,7 @@ resting order. - `quantity >= min_order_size` → `BelowMinOrderSize` - `open_orders.len() < 20` (mirror of the max_len on the struct) → `TooManyOpenOrders` -- `remaining_accounts.len() % 2 == 0` → `MissingMakerAccounts` +- `remaining_accounts.len() >= 2 * fills` → `MissingMakerAccounts` **Checks (per maker pair, during planning):** @@ -667,8 +680,15 @@ resting order. **Checks (before resting remainder):** -- the taker's side of the book isn't at its 1024-leaf capacity → - `OrderBookFull` +- If the taker's side already holds its 512 orders, the remainder + must beat that side's worst price (a higher bid or a lower ask; + equal is not better, because the resting order was there first) → + `OrderBookFull`. When it does, the worst order is **evicted**: + - The caller passed that order, and its owner's `MarketUser` → + `MissingEvictedAccounts` + - The passed order is the worst order on this market, and the + `MarketUser` belongs to its owner on this market → + `EvictedAccountMismatch` - Integer math throughout: every multiplication uses `checked_mul`; every addition on balances uses `checked_add`; every product of two `u64` money values is computed in `u128` @@ -746,6 +766,14 @@ On `order_book`: - Taker's remainder (if any) inserted into the correct side in price order +On an evicted order (only when the taker's side was full), exactly as +if its owner had called `cancel_order`: + +- Its owner's `unsettled_quote += price * remaining_quantity` (bid) or + `unsettled_base += remaining_quantity` (ask) +- Removed from the book and from its owner's `open_orders` +- `status = Cancelled` + On the caller's new `order`: - All fields populated @@ -1303,7 +1331,9 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `OrderNotFound`: `cancel_order` failed to locate the order in the book (sanity path) - `MarketPaused`: `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there) - `Unauthorized`: `cancel_order` by someone other than the order owner -- `OrderBookFull`: `place_order` remainder would push the taker's side past 1024 leaves +- `OrderBookFull`: `place_order` remainder would rest on a side holding 512 orders without beating that side's worst price +- `MissingEvictedAccounts`: A full side, and the worst resting order (with its owner's MarketUser) was not passed after the maker pairs +- `EvictedAccountMismatch`: The order passed for eviction is not the side's worst, or the MarketUser passed is not its owner's - `TooManyOpenOrders`: User already has 20 open orders on this market - `InvalidTickSize`: `tick_size == 0` at init, or `price % tick_size != 0` on place - `BelowMinOrderSize`: `min_order_size == 0` at init, or `quantity < min_order_size` on place @@ -1312,7 +1342,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `InvalidFeeBasisPoints`: `fee_basis_points > 10_000` at init - `InvalidFeeVault`: `market.fee_vault` on the struct does not match the passed `fee_vault` (the `address` constraint on `fee_vault`) - `MakerAccountMismatch`: Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order -- `MissingMakerAccounts`: `remaining_accounts.len()` not a multiple of 2 +- `MissingMakerAccounts`: Fewer remaining accounts than two per planned fill - `MakerOwnerMismatch`: Maker Order and MarketUser have different owners - `NotMarketAuthority`: `withdraw_fees` called by wrong signer @@ -1375,10 +1405,19 @@ From [`errors.rs`](programs/order-book/src/errors.rs): the `fee_vault` account without re-checking its mint or authority. - **Book capacity check after matching.** The taker's remainder - check happens at the end. A bid that clears enough asks to free - up 3 slots can then rest its own 1-slot remainder even on a - previously-full book - matching the "liquidity-positive" spirit - of an order book. + check happens at the end. Matching removes orders from the other + side only, so a full side stays full; the remainder then rests only + by evicting that side's worst order. + +- **A full side evicts rather than refuses.** Each side holds 512 + orders. Without eviction, anyone willing to lock the minimum order + size and pay rent 512 times could fill a side with orders far from + the spread and hold it, and every new order on that side would be + refused for as long as they liked. With eviction, an order that + beats the side's worst price removes that order and takes its slot, + so the orders that go are the ones least likely to fill, and the + market stays open at the prices that trade. The evicted order is + refunded the same way a cancel is, through `unsettled_*`. ### 6.3 Things this example does *not* do @@ -1516,6 +1555,14 @@ test taker_partially_fills_resting_order_rest_stays_on_book ... ok - `doubling_prices_build_the_deepest_path_prices_allow`: Asks at 63 doubling prices plus two at price 1 make a 64-level path to the best ask - `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: Insert, fill, and cancel at the bottom of that path stay within 15,000 compute units of a shallow book +**Eviction (a full side, 512 bids):** + +- `full_side_refuses_an_order_no_better_than_its_worst`: A worse or equal bid gets `OrderBookFull` +- `better_order_evicts_the_worst_and_rests`: The worst bid is Cancelled and refunded, the new bid rests, the side stays full +- `evicted_maker_settles_their_refund`: The evicted owner's `settle_funds` pays out the refund +- `eviction_rejects_missing_or_wrong_evicted_accounts`: No evicted order, a non-worst order, or the wrong owner's `MarketUser` +- `trader_can_evict_their_own_worst_order`: Only the `Order` account is passed, and the caller's own `MarketUser` is credited + ### CI note The repo's `.github/workflows/anchor.yml` runs `anchor build` before diff --git a/finance/order-book/anchor/programs/order-book/src/errors.rs b/finance/order-book/anchor/programs/order-book/src/errors.rs index b3738c775..294a644ec 100644 --- a/finance/order-book/anchor/programs/order-book/src/errors.rs +++ b/finance/order-book/anchor/programs/order-book/src/errors.rs @@ -70,4 +70,12 @@ pub enum ErrorCode { #[msg("Order book account does not match the market's order book")] InvalidOrderBook, + + #[msg( + "Book side is full: pass the worst resting order and its owner's MarketUser to evict it" + )] + MissingEvictedAccounts, + + #[msg("Evicted order provided is not the worst resting order on the full side")] + EvictedAccountMismatch, } diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs b/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs index 4ebe10625..9180f9288 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs @@ -2,8 +2,8 @@ use anchor_lang::prelude::*; use crate::errors::ErrorCode; use crate::state::{ - remaining_quantity, remove_open_order, Market, MarketUser, Order, OrderBook, OrderSide, - OrderStatus, MARKET_USER_SEED, ORDER_SEED, + credit_unfilled_lock, remove_open_order, Market, MarketUser, Order, OrderBook, OrderStatus, + MARKET_USER_SEED, ORDER_SEED, }; pub fn handle_cancel_order(context: &mut Context) -> Result<()> { @@ -22,38 +22,11 @@ pub fn handle_cancel_order(context: &mut Context) // Funds the order had locked in the vault are now owed back to the // owner. Credit the appropriate unsettled balance; settle_funds moves // those funds from the vault to the owner's token account. - let remaining = remaining_quantity(order); - if remaining > 0 { - let market_user = &mut context.accounts.market_user; - match order.side { - OrderSide::Bid => { - // u128 intermediates mirror the bid-lock formula in place_order: - // raw_quote = price × remaining × quote_lot_size - let quote_amount: u64 = (order.price as u128) - .checked_mul(remaining as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .checked_mul(context.accounts.market.quote_lot_size as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .try_into() - .map_err(|_| ErrorCode::NumericalOverflow)?; - market_user.unsettled_quote = market_user - .unsettled_quote - .checked_add(quote_amount) - .ok_or(ErrorCode::NumericalOverflow)?; - } - OrderSide::Ask => { - let base_amount: u64 = (remaining as u128) - .checked_mul(context.accounts.market.base_lot_size as u128) - .ok_or(ErrorCode::NumericalOverflow)? - .try_into() - .map_err(|_| ErrorCode::NumericalOverflow)?; - market_user.unsettled_base = market_user - .unsettled_base - .checked_add(base_amount) - .ok_or(ErrorCode::NumericalOverflow)?; - } - } - } + credit_unfilled_lock( + &context.accounts.market, + order, + &mut context.accounts.market_user, + )?; // Remove the leaf from the slab. The current cancel API doesn't tell us // which side the order is on without reading the Order PDA - which we diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs b/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs index e2b6dcfee..539b7199a 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs @@ -5,8 +5,8 @@ use anchor_spl::token_interface::{ use crate::errors::ErrorCode; use crate::state::{ - add_open_order, plan_fills, remove_open_order, Market, MarketUser, Order, OrderBook, OrderSide, - OrderStatus, MARKET_SEED, MARKET_USER_SEED, ORDER_SEED, + add_open_order, credit_unfilled_lock, plan_fills, remove_open_order, Market, MarketUser, Order, + OrderBook, OrderSide, OrderStatus, MARKET_SEED, MARKET_USER_SEED, ORDER_SEED, }; // Mirror of MarketUser.open_orders max_len. Kept as a constant so the @@ -23,8 +23,25 @@ const BASIS_POINTS_DENOMINATOR: u128 = 10_000; // unsettled_* balance - the maker drains them later via settle_funds. This // mirrors how Openbook v2 works and keeps the per-fill account footprint // small. +// +// When the order will rest on a side that is already full, the side's +// worst-priced order follows the maker pairs: [evicted_order, +// evicted_market_user]. When the worst order is the caller's own, only +// [evicted_order] is passed: Anchor refuses a writable account that appears +// twice, and the caller's MarketUser is already `market_user`. See the +// eviction step below. const ACCOUNTS_PER_MAKER: usize = 2; +/// True when `price` is a better price than `other` for an order on `side`: +/// higher for a bid, lower for an ask. Equal is not better, because at equal +/// price the resting order has time priority. +fn is_better_price(side: OrderSide, price: u64, other: u64) -> bool { + match side { + OrderSide::Bid => price > other, + OrderSide::Ask => price < other, + } +} + pub fn handle_place_order( context: &mut Context, side: OrderSide, @@ -121,10 +138,9 @@ pub fn handle_place_order( // transaction's remaining_accounts, in the same price-time-priority // order the book would walk. We plan fills against the resting tree, // then verify the caller's account list matches the plan, then apply. - require!( - maker_accounts.len() % ACCOUNTS_PER_MAKER == 0, - ErrorCode::MissingMakerAccounts - ); + // The list need not be a whole number of pairs: an order evicting the + // caller's own worst order ends with a single account (see + // ACCOUNTS_PER_MAKER). let order_book_loader = &mut context.accounts.order_book; @@ -406,6 +422,75 @@ pub fn handle_place_order( .checked_add(taker_quote_received) .ok_or(ErrorCode::NumericalOverflow)?; + // --------------------------------------------------------------- + // Eviction. A full side would otherwise refuse every new resting order, + // so anyone with the capital could hold all of its slots with orders + // nobody will fill. Instead, an order that beats the side's worst price + // removes that worst order and takes its slot. The evicted order is + // treated exactly like a cancel: its unfilled lock is credited to its + // owner's unsettled balance, and it is stamped Cancelled. An order that + // does not beat the worst price is still refused with OrderBookFull. + // --------------------------------------------------------------- + let order_to_evict = if taker_remaining > 0 && order_book_loader.is_side_full(side) { + let worst = order_book_loader + .worst(side) + .ok_or(ErrorCode::OrderBookFull)?; + require!( + is_better_price(side, price, worst.price), + ErrorCode::OrderBookFull + ); + Some(worst) + } else { + None + }; + + if let Some(worst) = order_to_evict { + let evicted_index = fills.len() * ACCOUNTS_PER_MAKER; + let evicted_order_info = maker_accounts + .get(evicted_index) + .ok_or(ErrorCode::MissingEvictedAccounts)?; + let market = &context.accounts.market; + + // SAFETY: as for the maker accounts above. The evicted order is on + // the taker's own side, so it is never one of the maker orders. + let mut evicted_order = unsafe { BorshAccount::::load_mut(*evicted_order_info) }?; + require!( + evicted_order.order_id == worst.order_id && evicted_order.market == *market.address(), + ErrorCode::EvictedAccountMismatch + ); + + if evicted_order.owner == *context.accounts.owner.address() { + // The worst order is the taker's own. Their MarketUser is already + // loaded as `market_user`, so it is credited there and is not + // passed a second time. + credit_unfilled_lock(market, &evicted_order, taker_market_user)?; + remove_open_order(taker_market_user, evicted_order.order_id); + } else { + let evicted_user_info = maker_accounts + .get(evicted_index + 1) + .ok_or(ErrorCode::MissingEvictedAccounts)?; + let mut evicted_market_user = + unsafe { BorshAccount::::load_mut(*evicted_user_info) }?; + require!( + evicted_market_user.owner == evicted_order.owner + && evicted_market_user.market == *market.address(), + ErrorCode::EvictedAccountMismatch + ); + credit_unfilled_lock(market, &evicted_order, &mut evicted_market_user)?; + remove_open_order(&mut evicted_market_user, evicted_order.order_id); + evicted_market_user.exit()?; + } + + require!( + order_book_loader + .remove_from(side, worst.order_id) + .is_some(), + ErrorCode::OrderNotFound + ); + evicted_order.status = OrderStatus::Cancelled; + evicted_order.exit()?; + } + // --------------------------------------------------------------- // Stamp the taker's Order PDA. Either Filled (no remainder) or rested. // diff --git a/finance/order-book/anchor/programs/order-book/src/state/market_user.rs b/finance/order-book/anchor/programs/order-book/src/state/market_user.rs index bfef5d9e5..c591f0ab0 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/market_user.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/market_user.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; +use crate::errors::ErrorCode; +use crate::state::{remaining_quantity, Market, Order, OrderSide}; + pub const MARKET_USER_SEED: &[u8] = b"market_user"; // Per-user, per-market account. Tracks open order ids and amounts owed back @@ -36,3 +39,47 @@ pub fn remove_open_order(account: &mut MarketUser, order_id: u64) { account.open_orders.remove(position); } } + +/// Credit the owner of a resting `order` with the funds its unfilled +/// remainder still has locked in the vault: quote for a bid, base for an ask. +/// `settle_funds` later moves the credit to their token account. Used by +/// `cancel_order`, and by `place_order` when it evicts an order from a full +/// side. The arithmetic mirrors the lock in `place_order`, in u128 so +/// high-decimal mints cannot overflow the intermediate product. +pub fn credit_unfilled_lock( + market: &Market, + order: &Order, + market_user: &mut MarketUser, +) -> Result<()> { + let remaining = remaining_quantity(order); + if remaining == 0 { + return Ok(()); + } + match order.side { + OrderSide::Bid => { + let quote_amount: u64 = (order.price as u128) + .checked_mul(remaining as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .checked_mul(market.quote_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| ErrorCode::NumericalOverflow)?; + market_user.unsettled_quote = market_user + .unsettled_quote + .checked_add(quote_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + OrderSide::Ask => { + let base_amount: u64 = (remaining as u128) + .checked_mul(market.base_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| ErrorCode::NumericalOverflow)?; + market_user.unsettled_base = market_user + .unsettled_base + .checked_add(base_amount) + .ok_or(ErrorCode::NumericalOverflow)?; + } + } + Ok(()) +} diff --git a/finance/order-book/anchor/programs/order-book/src/state/order_book.rs b/finance/order-book/anchor/programs/order-book/src/state/order_book.rs index 6f0bcb18a..35b33958c 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/order_book.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/order_book.rs @@ -9,11 +9,12 @@ use crate::state::OrderSide; pub const ORDER_BOOK_SEED: &[u8] = b"order_book"; -/// Per-side capacity. 1024 leaves is enough for any realistic depth a single -/// market quotes; at 88 bytes per node that's ~90 KB per side, so the whole -/// OrderBook account fits in ~180 KB - well under Solana's per-account ceiling -/// and well within the rent budget a market authority is happy to fund once. -pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES; +/// Per-side capacity, in resting orders. Every order after the first adds a +/// leaf and an inner node to the tree, so the side's `MAX_TREE_NODES` slots +/// hold half as many orders. At 88 bytes per node that's ~90 KB per side, so +/// the whole OrderBook account fits in ~180 KB. When a side is full, +/// `place_order` evicts the worst-priced order to make room for a better one. +pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES / 2; /// 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 @@ -186,6 +187,22 @@ impl OrderBook { }) } + /// Resting-order view of the worst-priced leaf on `side`, if any: the + /// order eviction removes when the side is full. + pub fn worst(&self, side: OrderSide) -> Option { + let (root, nodes) = match side { + OrderSide::Bid => (&self.bids_root, &self.bids), + OrderSide::Ask => (&self.asks_root, &self.asks), + }; + let (_handle, leaf) = nodes.worst_leaf(root)?; + Some(RestingOrderView { + order_id: leaf.order_id, + price: leaf.price(), + quantity: leaf.quantity, + owner: leaf.owner, + }) + } + /// Number of resting orders on a side. O(1). pub fn count(&self, side: OrderSide) -> u32 { match side { diff --git a/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs b/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs index 4a9ab219d..c226030f5 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/slab/ordertree.rs @@ -13,9 +13,10 @@ use static_assertions::const_assert_eq; use super::nodes::{AnyNode, FreeNode, InnerNode, LeafNode, NodeHandle, NodeRef, NodeTag}; use crate::errors::ErrorCode; -/// Per-side slab capacity. 1024 leaves easily covers any realistic depth at -/// the prices a single market quotes; the 88-byte node size keeps each side -/// at ~90 KB, well under Solana's 10 MB per-account ceiling. +/// Per-side slab capacity, in nodes. A critbit tree with n leaves also has +/// n - 1 inner nodes, so 1024 nodes hold 512 resting orders (see +/// `MAX_ORDERS_PER_SIDE`). 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. @@ -119,6 +120,15 @@ impl OrderTreeNodes { self.leaf_min_max(find_max, root) } + /// Worst-priced leaf for this tree: the highest ask or the lowest bid. + /// Within that price it is the latest order, because the key's low bits + /// carry the sequence number. Eviction removes this leaf when a side is + /// full. + pub fn worst_leaf(&self, root: &OrderTreeRoot) -> Option<(NodeHandle, &LeafNode)> { + let find_max = self.order_tree_type() == OrderTreeType::Asks; + self.leaf_min_max(find_max, root) + } + fn leaf_min_max( &self, find_max: bool, diff --git a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs index 4db4d97ec..2488ae679 100644 --- a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -2393,3 +2393,372 @@ fn deepest_path_adds_little_compute_to_insert_fill_and_cancel() { assert!(deep_units < DEFAULT_INSTRUCTION_COMPUTE_UNITS); } } + +// --------------------------------------------------------------------------- +// Eviction: a full side makes room for a better order +// --------------------------------------------------------------------------- +// +// A side's 1024 tree nodes hold 512 resting orders, because every order after +// the first adds a leaf and an inner node. These tests fill the bid side with +// 512 one-lot bids, one per price from EVICTION_WORST_BID_PRICE upward, so the +// worst bid is the first one placed: order ID 1, at the lowest price. + +const ORDERS_PER_SIDE: u64 = 512; +// One under the 20-order cap, so every filler can still place an order of +// their own (the self-eviction test needs that). +const ORDERS_PER_FILLER: u64 = 19; +const EVICTION_WORST_BID_PRICE: u64 = 100; +const EVICTION_ORDER_QUANTITY: u64 = 1; +const EVICTION_BETTER_BID_PRICE: u64 = 10_000; +const WORST_BID_ORDER_ID: u64 = 1; +const ORDER_STATUS_CANCELLED: u8 = 3; + +/// The runtime reports a program error as `Custom(n)`, and `#[error_code]` +/// numbers the variants from 6000, so this is the text a failed transaction +/// carries for `code`. +fn custom_error(code: order_book::errors::ErrorCode) -> String { + format!("Custom({})", code as u32 + 6000) +} + +struct Trader { + keypair: Keypair, + market_user: Address, + base_ata: Address, + quote_ata: Address, +} + +fn create_trader(sc: &mut Scenario) -> Trader { + let keypair = create_wallet(&mut sc.svm, 10_000_000_000).unwrap(); + let base_ata = + create_associated_token_account(&mut sc.svm, &keypair.pubkey(), &sc.base_mint, &sc.payer) + .unwrap(); + let quote_ata = + create_associated_token_account(&mut sc.svm, &keypair.pubkey(), &sc.quote_mint, &sc.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut sc.svm, + &sc.quote_mint, + "e_ata, + TRADER_STARTING_BALANCE, + &sc.authority, + ) + .unwrap(); + let init_ix = build_initialize_market_user_ix(sc, &keypair.pubkey()); + send_transaction_from_instructions(&mut sc.svm, vec![init_ix], &[&keypair], &keypair.pubkey()) + .unwrap(); + let market_user = market_user_pda(&sc.program_id, &sc.market, &keypair.pubkey()); + Trader { + keypair, + market_user, + base_ata, + quote_ata, + } +} + +/// Fill the bid side to capacity. Returns the fillers in order; the first one +/// owns the worst bid, `WORST_BID_ORDER_ID`. +fn fill_bid_side(sc: &mut Scenario) -> Vec { + let mut fillers: Vec = vec![]; + for order_id in 1..=ORDERS_PER_SIDE { + if (order_id - 1) % ORDERS_PER_FILLER == 0 { + fillers.push(create_trader(sc)); + } + let filler = fillers.last().unwrap(); + let price = EVICTION_WORST_BID_PRICE + (order_id - 1); + let ix = build_place_order_ix( + sc, + &filler.keypair, + filler.market_user, + filler.base_ata, + filler.quote_ata, + order_book::state::OrderSide::Bid, + order_id, + price, + EVICTION_ORDER_QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&filler.keypair], + &filler.keypair.pubkey(), + ) + .unwrap_or_else(|error| panic!("bid {order_id} should rest: {error:?}")); + } + fillers +} + +#[allow(clippy::too_many_arguments)] +fn place_bid( + sc: &mut Scenario, + trader: &Trader, + order_id: u64, + price: u64, + evicted_pairs: &[(u64, Address)], +) -> Result<(), String> { + let ix = build_place_order_with_makers_ix( + sc, + &trader.keypair, + trader.market_user, + trader.base_ata, + trader.quote_ata, + order_book::state::OrderSide::Bid, + order_id, + price, + EVICTION_ORDER_QUANTITY, + evicted_pairs, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&trader.keypair], + &trader.keypair.pubkey(), + ) + .map_err(|error| format!("{error:?}")) +} + +fn read_open_order_count(svm: &LiteSVM, market_user: &Address) -> u32 { + // After the two unsettled balances comes the Borsh Vec length prefix. + let offset = USER_ACCOUNT_UNSETTLED_QUOTE_OFFSET + 8; + let data = svm.get_account(market_user).unwrap().data; + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +#[test] +fn full_side_refuses_an_order_no_better_than_its_worst() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let next_order_id = ORDERS_PER_SIDE + 1; + + // Worse than the worst bid, and equal to it: equal is not better, + // because the resting bid got there first. + for price in [EVICTION_WORST_BID_PRICE - 1, EVICTION_WORST_BID_PRICE] { + let error = place_bid( + &mut sc, + &fillers[1], + next_order_id, + price, + &[(WORST_BID_ORDER_ID, fillers[0].market_user)], + ) + .expect_err("a bid no better than the worst must not evict it"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::OrderBookFull)), + "{error}" + ); + } + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(status, ORDER_STATUS_OPEN); +} + +#[test] +fn better_order_evicts_the_worst_and_rests() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let new_order_id = ORDERS_PER_SIDE + 1; + + place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, fillers[0].market_user)], + ) + .unwrap(); + + let evicted_order = order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID); + let (_, evicted_status) = read_order_fill_and_status(&sc.svm, &evicted_order); + assert_eq!(evicted_status, ORDER_STATUS_CANCELLED); + + // The evicted bid's whole lock is owed back to its owner, exactly as a + // cancel would owe it. + let (_, evicted_unsettled_quote) = read_user_unsettled(&sc.svm, &fillers[0].market_user); + assert_eq!( + evicted_unsettled_quote, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + assert_eq!( + read_open_order_count(&sc.svm, &fillers[0].market_user), + (ORDERS_PER_FILLER - 1) as u32 + ); + + let (_, new_status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, new_order_id), + ); + assert_eq!(new_status, ORDER_STATUS_OPEN); + assert_eq!(read_open_order_count(&sc.svm, &newcomer.market_user), 1); + + // The side is still full, and its worst bid is now order 2, one tick up. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id + 1, + EVICTION_WORST_BID_PRICE + 1, + &[(WORST_BID_ORDER_ID + 1, fillers[0].market_user)], + ) + .expect_err("the side is still full after an eviction"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::OrderBookFull)), + "{error}" + ); +} + +#[test] +fn evicted_maker_settles_their_refund() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + + let evicted = &fillers[0]; + let quote_before = get_token_account_balance(&sc.svm, &evicted.quote_ata).unwrap(); + + place_bid( + &mut sc, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, evicted.market_user)], + ) + .unwrap(); + + let settle_ix = build_settle_funds_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + evicted.base_ata, + evicted.quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + + let quote_after = get_token_account_balance(&sc.svm, &evicted.quote_ata).unwrap(); + assert_eq!( + quote_after - quote_before, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + assert_eq!(read_user_unsettled(&sc.svm, &evicted.market_user), (0, 0)); +} + +#[test] +fn eviction_rejects_missing_or_wrong_evicted_accounts() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let new_order_id = ORDERS_PER_SIDE + 1; + + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[], + ) + .expect_err("a full side needs the evicted order named"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::MissingEvictedAccounts + )), + "{error}" + ); + + // Order 2 is resting, but it is not the worst bid. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID + 1, fillers[0].market_user)], + ) + .expect_err("only the worst order may be evicted"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::EvictedAccountMismatch + )), + "{error}" + ); + + // The right order, with someone else's MarketUser to credit. + let error = place_bid( + &mut sc, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, fillers[1].market_user)], + ) + .expect_err("the refund must go to the evicted order's owner"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::EvictedAccountMismatch + )), + "{error}" + ); + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(status, ORDER_STATUS_OPEN); +} + +#[test] +fn trader_can_evict_their_own_worst_order() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let owner = &fillers[0]; + let new_order_id = ORDERS_PER_SIDE + 1; + + // Only the evicted order is passed: the owner's MarketUser is already the + // instruction's `market_user`, and Anchor refuses it a second time. + let mut ix = build_place_order_ix( + &sc, + &owner.keypair, + owner.market_user, + owner.base_ata, + owner.quote_ata, + order_book::state::OrderSide::Bid, + new_order_id, + EVICTION_BETTER_BID_PRICE, + EVICTION_ORDER_QUANTITY, + ); + ix.accounts.push(AccountMeta::new( + order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + false, + )); + send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&owner.keypair], + &owner.keypair.pubkey(), + ) + .unwrap(); + + let (_, evicted_status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID), + ); + assert_eq!(evicted_status, ORDER_STATUS_CANCELLED); + let (_, unsettled_quote) = read_user_unsettled(&sc.svm, &owner.market_user); + assert_eq!( + unsettled_quote, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + // One order out, one order in. + assert_eq!( + read_open_order_count(&sc.svm, &owner.market_user), + ORDERS_PER_FILLER as u32 + ); +} diff --git a/finance/order-book/quasar/CHANGELOG.md b/finance/order-book/quasar/CHANGELOG.md index 8a940e024..372087b3c 100644 --- a/finance/order-book/quasar/CHANGELOG.md +++ b/finance/order-book/quasar/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2026-09-23 + +### Added + +- A full side of the book evicts instead of refusing. When a side already + holds its 512 orders, an order that beats the side's worst price removes + that worst order and rests in its place. The evicted order is refunded + through its owner's unsettled balance, as a cancel is, and stamped + Cancelled. An order that does not beat the worst price still gets + `OrderBookFull`. The caller passes the worst order and its owner's + `MarketUser` after the maker pairs, or only the order when it is their + own. New errors: `MissingEvictedAccounts`, `EvictedAccountMismatch`. + +### Fixed + +- A side holds 512 orders, not 1024: every order after the first adds a + leaf and an inner node to the side's 1024-node tree. `MAX_ORDERS_PER_SIDE` + now says so. + ## 2026-09-22 ### Changed diff --git a/finance/order-book/quasar/README.md b/finance/order-book/quasar/README.md index 1a99bdbbd..b3a9d61a1 100644 --- a/finance/order-book/quasar/README.md +++ b/finance/order-book/quasar/README.md @@ -34,7 +34,7 @@ funds, and it moves them only along the place / cancel / settle paths below. ## Accounts and PDAs - `Market` (PDA, seeds `["market", base_mint, quote_mint]`): One trading pair. Stores config + vault addresses. Its PDA is the vaults' token authority. -- `OrderBook` (at a public key the client generates, not a PDA): Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. +- `OrderBook` (at a public key the client generates, not a PDA): Two critbit slabs (bids + asks), ~180 KB, each holding 512 orders: every order after the first adds a leaf and an inner node to a 1024-node tree. Zero-copy. Bound to its market by the market's stored `order_book`. - `MarketUser` (PDA, seeds `["market_user", market, owner]`): Per-user, per-market. Tracks open order ids and `unsettled_*` balances owed back to the user. - `Order` (PDA, seeds `["order", market, order_id]`): One order. `order_id` is the book's monotonic counter at placement time. - `base_vault` / `quote_vault` (token accounts, PDAs at seeds `["base_vault", market]` and `["quote_vault", market]`): Hold locked funds while orders are open. Market PDA is the authority. @@ -73,6 +73,15 @@ resting maker orders to cross as **remaining accounts**, in pairs of `(maker_ord 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. +When the order will rest on a side that already holds its 512 orders, it must beat that side's worst price (a +higher bid or a lower ask; equal is not better, because the resting order was there first), or it fails with +`OrderBookFull`. When it does, the worst order is **evicted**: refunded to its owner's `unsettled_*` exactly as +`cancel_order` would, removed from the book and its owner's open orders, and stamped `Cancelled`. The caller +passes that order and its owner's `MarketUser` after the maker pairs, or only the order when it is their own. +Without eviction, anyone willing to lock the minimum order size and pay rent 512 times could fill a side with +orders far from the spread and keep every new order on that side out; with it, the orders that go are the ones +least likely to fill. + 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. @@ -114,6 +123,8 @@ are all consequences of Quasar being zero-copy, `no_std`, and zero-allocation: 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. +- A full side **evicts its worst order** for a better one instead of refusing every new order, so filling the + book with far-off orders cannot shut a market (see the lifecycle section). ## Building and testing @@ -130,7 +141,9 @@ cargo test # QuasarSVM integration tests (they load the compiled .so) [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 onchain state, token balances, and fee accounting at -each step, plus an authorization rejection. +each step, plus an authorization rejection. Five eviction tests fill the bid side with 512 bids and check that a +worse or equal bid is refused, a better bid evicts the worst and rests, the evicted owner settles their refund, +wrong or missing evicted accounts are rejected, and a trader can evict their own worst order. ## Extending diff --git a/finance/order-book/quasar/src/errors.rs b/finance/order-book/quasar/src/errors.rs index d3708dd6b..adc419217 100644 --- a/finance/order-book/quasar/src/errors.rs +++ b/finance/order-book/quasar/src/errors.rs @@ -33,4 +33,10 @@ pub enum OrderBookError { OrderBookAlreadyInitialized, OrderIdMismatch, InvalidSide, + /// A full side, and the worst resting order (with its owner's MarketUser) + /// was not passed after the maker pairs. + MissingEvictedAccounts, + /// The order passed for eviction is not the side's worst, or the + /// MarketUser passed is not its owner's. + EvictedAccountMismatch, } diff --git a/finance/order-book/quasar/src/instructions/cancel_order.rs b/finance/order-book/quasar/src/instructions/cancel_order.rs index a7a84c394..fa332b05f 100644 --- a/finance/order-book/quasar/src/instructions/cancel_order.rs +++ b/finance/order-book/quasar/src/instructions/cancel_order.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; use crate::errors::OrderBookError; use crate::state::{ - load_order_book_mut, remaining_quantity, remove_open_order, snapshot_market_user, + credit_unfilled_lock, load_order_book_mut, remove_open_order, snapshot_market_user, snapshot_order, Market, MarketUser, Order, OrderSide, OrderStatus, }; @@ -47,55 +47,19 @@ pub fn handle_cancel_order( // 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); - } + let mut market_user = snapshot_market_user(&accounts.market_user); + credit_unfilled_lock( + &order, + u64::from(accounts.market.base_lot_size), + u64::from(accounts.market.quote_lot_size), + &mut 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. diff --git a/finance/order-book/quasar/src/instructions/place_order.rs b/finance/order-book/quasar/src/instructions/place_order.rs index 04d1267af..89e6dbe2a 100644 --- a/finance/order-book/quasar/src/instructions/place_order.rs +++ b/finance/order-book/quasar/src/instructions/place_order.rs @@ -8,9 +8,9 @@ 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, + add_open_order, credit_unfilled_lock, 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. @@ -21,8 +21,24 @@ const BASIS_POINTS_DENOMINATOR: u128 = 10_000; // 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. +// +// When the order will rest on a side that is already full, the side's +// worst-priced order follows the maker pairs: [evicted_order, +// evicted_market_user]. When the worst order is the caller's own, only +// [evicted_order] is passed, matching the Anchor build, where the framework +// refuses a writable account that appears twice. See the eviction step below. const ACCOUNTS_PER_MAKER: usize = 2; +/// True when `price` is a better price than `other` for an order on `side`: +/// higher for a bid, lower for an ask. Equal is not better, because at equal +/// price the resting order has time priority. +fn is_better_price(side: OrderSide, price: u64, other: u64) -> bool { + match side { + OrderSide::Bid => price > other, + OrderSide::Ask => price < other, + } +} + /// 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 { @@ -398,6 +414,123 @@ pub fn handle_place_order( .invoke_signed(&seeds)?; } + // The taker's own balances, credited below with the fills, and with the + // refund if the order evicted is their own. + let mut taker_user = snapshot_market_user(&accounts.market_user); + + // --------------------------------------------------------------- + // Eviction. A full side would otherwise refuse every new resting order, + // so anyone with the capital could hold all of its slots with orders + // nobody will fill. Instead, an order that beats the side's worst price + // removes that worst order and takes its slot. The evicted order is + // treated exactly like a cancel: its unfilled lock is credited to its + // owner's unsettled balance, and it is stamped Cancelled. An order that + // does not beat the worst price is still refused with OrderBookFull. + // --------------------------------------------------------------- + let order_to_evict = if plan.taker_remaining > 0 { + 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)?; + if order_book.is_side_full(side) { + let (worst_order_id, worst_price) = order_book + .worst(side) + .ok_or(OrderBookError::OrderBookFull)?; + require!( + is_better_price(side, price, worst_price), + OrderBookError::OrderBookFull + ); + Some(worst_order_id) + } else { + None + } + } else { + None + }; + + if let Some(worst_order_id) = order_to_evict { + let evicted_index = plan.count * ACCOUNTS_PER_MAKER; + let mut order_ra = remaining + .get(evicted_index)? + .ok_or(OrderBookError::MissingEvictedAccounts)?; + let order_view = unsafe { order_ra.as_account_view_unchecked_mut() }; + Account::::from_account_view(&*order_view)?; + let evicted_order_acc = + unsafe { Account::::from_account_view_unchecked_mut(order_view) }; + let mut evicted_order = snapshot_order(evicted_order_acc); + require!( + evicted_order.order_id == worst_order_id, + OrderBookError::EvictedAccountMismatch + ); + require_keys_eq!( + evicted_order.market, + market_key, + OrderBookError::EvictedAccountMismatch + ); + + if evicted_order.owner == *accounts.owner.address() { + // The worst order is the taker's own: credit their snapshot, + // which is written back below. + credit_unfilled_lock( + &evicted_order, + base_lot_size, + quote_lot_size, + &mut taker_user, + )?; + remove_open_order( + &mut taker_user.open_orders, + &mut taker_user.open_orders_len, + evicted_order.order_id, + ); + } else { + let mut user_ra = remaining + .get(evicted_index + 1)? + .ok_or(OrderBookError::MissingEvictedAccounts)?; + let user_view = unsafe { user_ra.as_account_view_unchecked_mut() }; + Account::::from_account_view(&*user_view)?; + let evicted_user_acc = + unsafe { Account::::from_account_view_unchecked_mut(user_view) }; + let mut evicted_user = snapshot_market_user(evicted_user_acc); + require_keys_eq!( + evicted_user.owner, + evicted_order.owner, + OrderBookError::EvictedAccountMismatch + ); + require_keys_eq!( + evicted_user.market, + market_key, + OrderBookError::EvictedAccountMismatch + ); + credit_unfilled_lock( + &evicted_order, + base_lot_size, + quote_lot_size, + &mut evicted_user, + )?; + remove_open_order( + &mut evicted_user.open_orders, + &mut evicted_user.open_orders_len, + evicted_order.order_id, + ); + evicted_user_acc.set_inner(evicted_user); + } + + { + 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)?; + require!( + order_book.remove_from(side, worst_order_id), + OrderBookError::OrderNotFound + ); + } + evicted_order.status = OrderStatus::Cancelled as u8; + evicted_order_acc.set_inner(evicted_order); + } + // --------------------------------------------------------------- // Allocate the taker's order id (rolling the book counter) and rest any // unmatched remainder on the book. @@ -427,7 +560,6 @@ pub fn handle_place_order( }; // 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) diff --git a/finance/order-book/quasar/src/state/market_user.rs b/finance/order-book/quasar/src/state/market_user.rs index 50cf9b2f6..163adcc90 100644 --- a/finance/order-book/quasar/src/state/market_user.rs +++ b/finance/order-book/quasar/src/state/market_user.rs @@ -1,5 +1,8 @@ use quasar_lang::prelude::*; +use crate::errors::OrderBookError; +use crate::state::{remaining_quantity, OrderInner, OrderSide}; + pub const MARKET_USER_SEED: &[u8] = b"market_user"; /// Per-user open-order cap. Matches the matching engine's upper bound so a @@ -104,3 +107,49 @@ pub fn snapshot_market_user(market_user: &Account) -> MarketUserInne open_orders: market_user.open_orders, } } + +/// Credit the owner of a resting `order` with the funds its unfilled +/// remainder still has locked in the vault: quote for a bid, base for an ask. +/// `settle_funds` later moves the credit to their token account. Used by +/// `cancel_order`, and by `place_order` when it evicts an order from a full +/// side. The arithmetic mirrors the lock in `place_order`, in u128 so +/// high-decimal mints cannot overflow the intermediate product. +pub fn credit_unfilled_lock( + order: &OrderInner, + base_lot_size: u64, + quote_lot_size: u64, + market_user: &mut MarketUserInner, +) -> Result<(), ProgramError> { + let remaining = remaining_quantity(order.original_quantity, order.filled_quantity); + if remaining == 0 { + return Ok(()); + } + let side = OrderSide::from_u8(order.side).ok_or(OrderBookError::InvalidSide)?; + match side { + OrderSide::Bid => { + 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)?; + } + } + Ok(()) +} diff --git a/finance/order-book/quasar/src/state/order_book.rs b/finance/order-book/quasar/src/state/order_book.rs index 7b924a0ff..2537486f7 100644 --- a/finance/order-book/quasar/src/state/order_book.rs +++ b/finance/order-book/quasar/src/state/order_book.rs @@ -9,10 +9,12 @@ 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; +/// Per-side capacity, in resting orders. Every order after the first adds a +/// leaf and an inner node to the tree, so the side's `MAX_TREE_NODES` slots +/// hold half as many orders. At 88 bytes per node that's ~90 KB per side, so +/// the whole OrderBook account fits in ~180 KB. When a side is full, +/// `place_order` evicts the worst-priced order to make room for a better one. +pub const MAX_ORDERS_PER_SIDE: usize = MAX_TREE_NODES / 2; /// 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 @@ -163,6 +165,17 @@ impl OrderBook { Ok(()) } + /// Order ID and price of the worst-priced resting order on `side`, if + /// any: the order eviction removes when the side is full. + pub fn worst(&self, side: OrderSide) -> Option<(u64, u64)> { + let (root, nodes) = match side { + OrderSide::Bid => (&self.bids_root, &self.bids), + OrderSide::Ask => (&self.asks_root, &self.asks), + }; + let (_handle, leaf) = nodes.worst_leaf(root)?; + Some((leaf.order_id, leaf.price())) + } + /// 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. diff --git a/finance/order-book/quasar/src/state/slab/ordertree.rs b/finance/order-book/quasar/src/state/slab/ordertree.rs index 80b35e320..ddc85f293 100644 --- a/finance/order-book/quasar/src/state/slab/ordertree.rs +++ b/finance/order-book/quasar/src/state/slab/ordertree.rs @@ -16,9 +16,10 @@ 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. +/// Per-side slab capacity, in nodes. A critbit tree with n leaves also has +/// n - 1 inner nodes, so 1024 nodes hold 512 resting orders (see +/// `MAX_ORDERS_PER_SIDE`). 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. @@ -112,6 +113,15 @@ impl OrderTreeNodes { self.leaf_min_max(find_max, root) } + /// Worst-priced leaf for this tree: the highest ask or the lowest bid. + /// Within that price it is the latest order, because the key's low bits + /// carry the sequence number. Eviction removes this leaf when a side is + /// full. + pub fn worst_leaf(&self, root: &OrderTreeRoot) -> Option<(NodeHandle, &LeafNode)> { + let find_max = self.order_tree_type() == OrderTreeType::Asks; + self.leaf_min_max(find_max, root) + } + fn leaf_min_max( &self, find_max: bool, diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index cc3710d33..018b5af83 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -393,3 +393,307 @@ fn withdraw_fees_rejects_a_non_authority_signer(test: &mut Test) { }) .fails_with(OrderBookError::NotMarketAuthority); } + +// --- Eviction: a full side makes room for a better order --- +// +// A side's 1024 tree nodes hold 512 resting orders, because every order after +// the first adds a leaf and an inner node. These tests fill the bid side with +// 512 one-lot bids, one per price from EVICTION_WORST_BID_PRICE upward, so the +// worst bid is the first one placed: order ID 1, at the lowest price. + +const BID: u8 = 0; +const ORDERS_PER_SIDE: u64 = 512; +// One under the 20-order cap, so every filler can still place an order of +// their own (the self-eviction test needs that). +const ORDERS_PER_FILLER: u64 = 19; +const EVICTION_WORST_BID_PRICE: u64 = 100; +const EVICTION_ORDER_QUANTITY: u64 = 1; +const EVICTION_BETTER_BID_PRICE: u64 = 10_000; +const WORST_BID_ORDER_ID: u64 = 1; +const EVICTION_TRADER_QUOTE: u64 = 1_000_000_000; + +struct Trader { + owner: Pubkey, + market_user: Pubkey, + base: Pubkey, + quote: Pubkey, +} + +/// A distinct address per trader and role, clear of the fixed addresses above. +fn trader_address(index: u8, role: u8) -> Pubkey { + let mut bytes = [0u8; 32]; + bytes[0] = 200; + bytes[1] = index; + bytes[2] = role; + Pubkey::new_from_array(bytes) +} + +/// A funded trader with a MarketUser. +fn create_trader(test: &mut Test, market: Pubkey, index: u8) -> Trader { + let owner = trader_address(index, 1); + let base = trader_address(index, 2); + let quote = trader_address(index, 3); + let market_user = initialize_market_user(test, market, owner); + test.add(TokenAccount::new(BASE_MINT, owner).at(base)); + test.add( + TokenAccount::new(QUOTE_MINT, owner) + .at(quote) + .amount(EVICTION_TRADER_QUOTE), + ); + Trader { + owner, + market_user, + base, + quote, + } +} + +/// Fill the bid side to capacity. Returns the fillers in order; the first one +/// owns the worst bid, `WORST_BID_ORDER_ID`. +fn fill_bid_side(test: &mut Test, market: Pubkey) -> Vec { + let mut fillers: Vec = Vec::new(); + for order_id in 1..=ORDERS_PER_SIDE { + if (order_id - 1) % ORDERS_PER_FILLER == 0 { + let index = fillers.len() as u8; + fillers.push(create_trader(test, market, index)); + } + let filler = fillers.last().unwrap(); + let (owner, base, quote) = (filler.owner, filler.base, filler.quote); + place_order( + test, + market, + owner, + base, + quote, + BID, + EVICTION_WORST_BID_PRICE + (order_id - 1), + EVICTION_ORDER_QUANTITY, + order_id, + &[], + ) + .succeeds(); + } + fillers +} + +/// Place a one-lot bid, passing `evicted` as the accounts after the (empty) +/// maker pairs. +fn place_bid( + test: &mut Test, + market: Pubkey, + trader: &Trader, + order_id: u64, + price: u64, + evicted: &[Pubkey], +) -> Outcome { + let remaining_accounts = evicted + .iter() + .map(|address| AccountMeta::new(*address, false)) + .collect(); + let vaults = vaults(test, market); + test.send(PlaceOrderInstruction { + market, + order_book: ORDER_BOOK, + base_vault: vaults.base, + quote_vault: vaults.quote, + fee_vault: vaults.fee, + user_base_account: trader.base, + user_quote_account: trader.quote, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + owner: trader.owner, + side: BID, + price, + quantity: EVICTION_ORDER_QUANTITY, + order_id, + remaining_accounts, + }) +} + +#[quasar_test] +fn full_side_refuses_an_order_no_better_than_its_worst(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + + // Worse than the worst bid, and equal to it: equal is not better, because + // the resting bid got there first. + for price in [EVICTION_WORST_BID_PRICE - 1, EVICTION_WORST_BID_PRICE] { + place_bid( + test, + market, + &fillers[1], + ORDERS_PER_SIDE + 1, + price, + &[worst_order, fillers[0].market_user], + ) + .fails_with(OrderBookError::OrderBookFull); + } + assert_eq!( + test.read::(worst_order).status, + OrderStatus::Open as u8 + ); +} + +#[quasar_test] +fn better_order_evicts_the_worst_and_rests(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let newcomer = create_trader(test, market, 100); + let new_order_id = ORDERS_PER_SIDE + 1; + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + + place_bid( + test, + market, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[worst_order, fillers[0].market_user], + ) + .succeeds(); + + assert_eq!( + test.read::(worst_order).status, + OrderStatus::Cancelled as u8 + ); + // The evicted bid's whole lock is owed back to its owner, exactly as a + // cancel would owe it. + let evicted_user = test.read::(fillers[0].market_user); + assert_eq!( + u64::from(evicted_user.unsettled_quote), + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + assert_eq!(evicted_user.open_orders_len as u64, ORDERS_PER_FILLER - 1); + + let new_order = test.derive_pda(Order::seeds(&market, new_order_id)); + assert_eq!( + test.read::(new_order).status, + OrderStatus::Open as u8 + ); + assert_eq!( + test.read::(newcomer.market_user) + .open_orders_len, + 1 + ); + + // The side is still full, and its worst bid is now order 2, one tick up. + let second_worst = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID + 1)); + place_bid( + test, + market, + &newcomer, + new_order_id + 1, + EVICTION_WORST_BID_PRICE + 1, + &[second_worst, fillers[0].market_user], + ) + .fails_with(OrderBookError::OrderBookFull); +} + +#[quasar_test] +fn evicted_maker_settles_their_refund(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let newcomer = create_trader(test, market, 100); + let evicted = &fillers[0]; + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + let quote_before = test.tokens(evicted.quote); + + place_bid( + test, + market, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[worst_order, evicted.market_user], + ) + .succeeds(); + settle_funds(test, market, evicted.owner, evicted.base, evicted.quote).succeeds(); + + assert_eq!( + test.tokens(evicted.quote) - quote_before, + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + let evicted_user = test.read::(evicted.market_user); + assert_eq!(u64::from(evicted_user.unsettled_base), 0); + assert_eq!(u64::from(evicted_user.unsettled_quote), 0); +} + +#[quasar_test] +fn eviction_rejects_missing_or_wrong_evicted_accounts(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let newcomer = create_trader(test, market, 100); + let new_order_id = ORDERS_PER_SIDE + 1; + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + let second_worst = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID + 1)); + + place_bid( + test, + market, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[], + ) + .fails_with(OrderBookError::MissingEvictedAccounts); + + // Order 2 is resting, but it is not the worst bid. + place_bid( + test, + market, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[second_worst, fillers[0].market_user], + ) + .fails_with(OrderBookError::EvictedAccountMismatch); + + // The right order, with someone else's MarketUser to credit. + place_bid( + test, + market, + &newcomer, + new_order_id, + EVICTION_BETTER_BID_PRICE, + &[worst_order, fillers[1].market_user], + ) + .fails_with(OrderBookError::EvictedAccountMismatch); + + assert_eq!( + test.read::(worst_order).status, + OrderStatus::Open as u8 + ); +} + +#[quasar_test] +fn trader_can_evict_their_own_worst_order(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let owner = &fillers[0]; + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + + // Only the evicted order is passed: the owner's MarketUser is already the + // instruction's `market_user`. + place_bid( + test, + market, + owner, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[worst_order], + ) + .succeeds(); + + assert_eq!( + test.read::(worst_order).status, + OrderStatus::Cancelled as u8 + ); + let user = test.read::(owner.market_user); + assert_eq!( + u64::from(user.unsettled_quote), + EVICTION_WORST_BID_PRICE * EVICTION_ORDER_QUANTITY * QUOTE_LOT_SIZE + ); + // One order out, one order in. + assert_eq!(user.open_orders_len as u64, ORDERS_PER_FILLER); +}