From 432d2ea09d3d2a9bc5ce8d38f972e4a9299d4113 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 01:39:03 +0000 Subject: [PATCH 1/2] Order book: evict the worst order when a side is full A full side refused every new resting order, 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 it out. 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. place_order now evicts: when the order will rest on a full side and beats that side's worst price, the worst order is refunded through its owner's unsettled balance, exactly as cancel_order refunds it (the refund is now one shared function, credit_unfilled_lock), removed from the book and its owner's open orders, 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, because Anchor refuses a writable account that appears twice. New errors: MissingEvictedAccounts and EvictedAccountMismatch. Also corrects the stated capacity: a side holds 512 orders, not 1024, since every order after the first adds a leaf and an inner node to the side's 1024-node tree. MAX_ORDERS_PER_SIDE is now MAX_TREE_NODES / 2. Both the Anchor v2 and Quasar variants change, each with five tests that fill the bid side to 512 orders. Anchor v1 is a frozen snapshot per CONTRIBUTING.md and does not change. Claude-Session: https://claude.ai/code/session_01RBfNjrQd2J6muoi3thCUin --- CHANGELOG.md | 18 + finance/order-book/anchor/CHANGELOG.md | 14 + finance/order-book/anchor/README.md | 69 +++- .../anchor/programs/order-book/src/errors.rs | 8 + .../src/instructions/cancel_order.rs | 41 +- .../src/instructions/place_order.rs | 97 ++++- .../order-book/src/state/market_user.rs | 47 +++ .../order-book/src/state/order_book.rs | 27 +- .../order-book/src/state/slab/ordertree.rs | 16 +- .../order-book/tests/test_order_book.rs | 369 ++++++++++++++++++ finance/order-book/quasar/CHANGELOG.md | 19 + finance/order-book/quasar/README.md | 17 +- finance/order-book/quasar/src/errors.rs | 6 + .../quasar/src/instructions/cancel_order.rs | 64 +-- .../quasar/src/instructions/place_order.rs | 140 ++++++- .../quasar/src/state/market_user.rs | 49 +++ .../order-book/quasar/src/state/order_book.rs | 21 +- .../quasar/src/state/slab/ordertree.rs | 16 +- finance/order-book/quasar/src/tests.rs | 304 +++++++++++++++ 19 files changed, 1220 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3add3c05..0b2337a57 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. The Anchor v2 and Quasar variants change; the Anchor v1 port +is a frozen snapshot and does not change. + ## [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/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); +} From 5dde8f69bad0bac096f933eb56af55317c49fa8b Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 02:38:19 +0000 Subject: [PATCH 2/2] Order book (Anchor v1): port eviction from the Anchor v2 copy CONTRIBUTING.md now says an Anchor v1 copy tracks its v2 counterpart, so eviction goes into all three variants rather than only Anchor v2 and Quasar. The port matches the v2 copy: worst_leaf and OrderBook::worst, the 512-order capacity fix, credit_unfilled_lock shared with cancel_order, the MissingEvictedAccounts and EvictedAccountMismatch errors, the eviction step in place_order, the README and CHANGELOG, and the same five tests. When the worst order is the caller's own, only the order is passed and the caller's market_user is credited, as in v2. Anchor v1 would accept the caller's MarketUser a second time, but a second loaded copy would be overwritten when the instruction writes its accounts back, so the rule matters here too. This copy is not rustfmt-clean on main and is outside the workspace CI formats, so the change is left unformatted to keep the diff to the port. Claude-Session: https://claude.ai/code/session_01RBfNjrQd2J6muoi3thCUin --- CHANGELOG.md | 4 +- finance/order-book/anchor-v1/CHANGELOG.md | 15 + finance/order-book/anchor-v1/README.md | 70 +++- .../programs/order-book/src/errors.rs | 6 + .../src/instructions/cancel_order.rs | 41 +- .../src/instructions/place_order.rs | 97 ++++- .../order-book/src/state/market_user.rs | 47 +++ .../order-book/src/state/order_book.rs | 27 +- .../order-book/src/state/slab/ordertree.rs | 16 +- .../order-book/tests/test_order_book.rs | 369 ++++++++++++++++++ 10 files changed, 631 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2337a57..b6e5d3815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ unsettled balance, exactly as `cancel_order` refunds it, and stamped `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. The Anchor v2 and Quasar variants change; the Anchor v1 port -is a frozen snapshot and does not change. +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 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 + ); +}