diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index 376fce48..0d1347ef 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -70,8 +70,8 @@ call `settle_funds` to pull their balances out. that can withdraw accumulated fees. - An **OrderBook** account — two stores: bids sorted highest-first, asks sorted lowest-first, each holding up to 1024 entries. Rather - than a plain list of orders, each side uses a balanced tree for fast - lookup — see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance). + 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, `order_id`); the full `Order` PDA holds the authoritative state. - A **MarketUser** PDA — one per `(market, wallet)` pair. Tracks the @@ -895,9 +895,10 @@ instead of 1 024. The specific data structure used here is a [critbit tree](https://cr.yp.to/critbit.html) (short for *critical-bit tree*) — a compact binary radix trie where each internal node splits on -the first bit where two keys disagree. It has the same O(log n) bounds -as other balanced trees but operates on fixed-width integer keys, so no -rebalancing rotations are needed. This implementation is ported from +the first bit where two keys disagree. Unlike a self-balancing BST it +never rotates or recolours nodes; its depth is instead bounded by the +*bit width of the key* rather than the number of orders, so it stays +shallow no matter what order keys arrive in. This implementation is ported from [Openbook v2](https://github.com/openbook-dex/openbook-v2); [Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1) uses the same approach. Both are production Solana CLOBs worth reading alongside this @@ -1550,15 +1551,18 @@ Ordered by difficulty. `place_order`, skip resting entries whose `expires_at` is past; add a permissionless `sweep_expired` instruction. -### Why a balanced tree (critbit)? +### Why a depth-bounded tree (critbit)? -**Tree balancing must be guaranteed, not assumed.** A plain binary +**Worst-case depth must be bounded, not assumed.** A plain binary search tree only keeps a roughly-balanced shape when its inputs arrive in random order. In an order book an attacker chooses the inputs — the prices of their orders — so nothing they choose can be allowed to -determine the tree's shape. A *balanced-by-construction* tree -(red-black, critbit, AVL, …) enforces a bounded shape via invariants -maintained on every insert and delete, regardless of input order. +inflate the tree's depth. Two families of structure defend against +this: *self-balancing* BSTs (red-black, AVL, …) that restore a bounded +height with rotations on every insert and delete, and *radix tries* +like critbit whose depth is capped by the key's bit width no matter +which keys are present. Both keep every operation cheap regardless of +input order; this example uses the second. **Concrete attack on a plain BST.** An attacker posts orders at monotonically increasing prices ($100, $101, $102, $103, …). Each new @@ -1568,20 +1572,21 @@ degenerated into a linked list of length N. Lookups, inserts, and matches all walk O(N) instead of O(log N). **Why this matters on Solana specifically.** Solana transactions have -a ~1.4M compute-unit budget. If `place_order` walks an unbalanced book +a ~1.4M compute-unit budget. If `place_order` walks a degenerate book and exceeds the CU limit mid-match, the transaction aborts and the placer pays fees for nothing. Worse, *legitimate users' orders fail -because an adversary skewed the tree shape*. A balanced-by-construction -tree bounds every operation at O(log N) regardless of input, so the -attack is structurally impossible. - -**Why critbit specifically.** Critbit (a binary radix trie keyed on -the price bits) is balanced-by-construction in a different way from a -red-black tree: tree depth is bounded by the *bit width of the sort -key* (128 bits here — price in the high 64, sequence number in the -low 64), not by insertion order. Inserts and deletes don't need -rotations or recolouring; the trie shape is a deterministic function -of which keys are present. This example uses the critbit slab from +because an adversary skewed the tree shape*. A depth-bounded tree keeps +every operation cheap regardless of input, so the attack is +structurally impossible. + +**Why critbit specifically.** Critbit is a binary radix trie keyed on +the order's sort bits — *not* a self-balancing BST, so it never rotates +or recolours nodes. Its shape is a deterministic function of which keys +are present, and its depth can never exceed the *bit width of the sort +key* (128 bits here — price in the high 64, sequence number in the low +64), so it cannot degenerate into a long chain under any insert order. +An insert splits exactly one leaf and adds exactly one inner node; a +delete splices one out. This example uses the critbit slab from Openbook v2 (`src/state/slab/`). ### Harder 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 f2a96860..b3738c77 100644 --- a/finance/order-book/anchor/programs/order-book/src/errors.rs +++ b/finance/order-book/anchor/programs/order-book/src/errors.rs @@ -23,6 +23,12 @@ pub enum ErrorCode { #[msg("Price does not align with tick size")] InvalidTickSize, + #[msg("Base lot size must be greater than zero")] + InvalidBaseLotSize, + + #[msg("Quote lot size must be greater than zero")] + InvalidQuoteLotSize, + #[msg("Quantity is below minimum order size")] BelowMinOrderSize, 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 b27316a0..984a10b7 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 @@ -27,14 +27,13 @@ pub fn handle_cancel_order(context: Context) -> Result<()> { let market_user = &mut context.accounts.market_user; match order.side { OrderSide::Bid => { - // u128 intermediate: the lock was originally taken on a - // u64 quote balance, so price * remaining must fit u64 - // — but the multiplication itself can transiently exceed - // u64. Mirror the same pattern as place_order: widen, - // multiply, narrow. + // 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 @@ -43,9 +42,14 @@ pub fn handle_cancel_order(context: Context) -> Result<()> { .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(remaining) + .checked_add(base_amount) .ok_or(ErrorCode::NumericalOverflow)?; } } diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs index f77c8972..a2d925bd 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -12,9 +12,13 @@ pub fn handle_initialize_market( context: Context, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, + quote_lot_size: u64, min_order_size: u64, ) -> Result<()> { require!(tick_size > 0, ErrorCode::InvalidTickSize); + require!(base_lot_size > 0, ErrorCode::InvalidBaseLotSize); + require!(quote_lot_size > 0, ErrorCode::InvalidQuoteLotSize); require!(min_order_size > 0, ErrorCode::BelowMinOrderSize); require!( fee_basis_points <= MAX_FEE_BASIS_POINTS, @@ -31,6 +35,8 @@ pub fn handle_initialize_market( market.order_book = context.accounts.order_book.key(); market.fee_basis_points = fee_basis_points; market.tick_size = tick_size; + market.base_lot_size = base_lot_size; + market.quote_lot_size = quote_lot_size; market.min_order_size = min_order_size; market.is_active = true; market.bump = context.bumps.market; 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 3fa1952d..13a227be 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 @@ -67,6 +67,8 @@ pub fn handle_place_order<'info>( (price as u128) .checked_mul(quantity 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))?, context.accounts.quote_vault.to_account_info(), @@ -75,7 +77,11 @@ pub fn handle_place_order<'info>( context.accounts.user_base_account.to_account_info(), context.accounts.base_mint.to_account_info(), context.accounts.base_mint.decimals, - quantity, + (quantity as u128) + .checked_mul(market.base_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?, context.accounts.base_vault.to_account_info(), ), }; @@ -190,6 +196,8 @@ pub fn handle_place_order<'info>( let gross_quote: u64 = (fill.fill_price as u128) .checked_mul(fill.fill_quantity 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))?; @@ -218,8 +226,13 @@ pub fn handle_place_order<'info>( .checked_add(net_quote_to_maker) .ok_or(ErrorCode::NumericalOverflow)?; + let base_from_fill: u64 = (fill.fill_quantity as u128) + .checked_mul(market.base_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; taker_base_received = taker_base_received - .checked_add(fill.fill_quantity) + .checked_add(base_from_fill) .ok_or(ErrorCode::NumericalOverflow)?; // Price improvement: taker locked (price * quantity) but @@ -230,6 +243,8 @@ pub fn handle_place_order<'info>( let locked_for_this_fill: u64 = (price as u128) .checked_mul(fill.fill_quantity 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))?; let rebate: u64 = locked_for_this_fill @@ -241,9 +256,14 @@ pub fn handle_place_order<'info>( } // Taker Ask, resting Bid. Taker gives base, gets quote. OrderSide::Ask => { + let base_from_fill: u64 = (fill.fill_quantity as u128) + .checked_mul(market.base_lot_size as u128) + .ok_or(ErrorCode::NumericalOverflow)? + .try_into() + .map_err(|_| error!(ErrorCode::NumericalOverflow))?; maker_market_user.unsettled_base = maker_market_user .unsettled_base - .checked_add(fill.fill_quantity) + .checked_add(base_from_fill) .ok_or(ErrorCode::NumericalOverflow)?; let net_quote_to_taker = gross_quote diff --git a/finance/order-book/anchor/programs/order-book/src/lib.rs b/finance/order-book/anchor/programs/order-book/src/lib.rs index 1e06df24..d0ca768a 100644 --- a/finance/order-book/anchor/programs/order-book/src/lib.rs +++ b/finance/order-book/anchor/programs/order-book/src/lib.rs @@ -19,12 +19,16 @@ pub mod order_book { context: Context, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, + quote_lot_size: u64, min_order_size: u64, ) -> Result<()> { instructions::initialize_market::handle_initialize_market( context, fee_basis_points, tick_size, + base_lot_size, + quote_lot_size, min_order_size, ) } diff --git a/finance/order-book/anchor/programs/order-book/src/state/market.rs b/finance/order-book/anchor/programs/order-book/src/state/market.rs index 42a81504..f8db5579 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/market.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/market.rs @@ -31,6 +31,35 @@ pub struct Market { pub tick_size: u64, + // Two-lot model (mirrors Serum/Openbook): both sides of the book are + // denominated in their respective lots rather than raw token units. + // This makes `price` and `quantity` human-readable regardless of the + // individual mints' decimal counts. + // + // raw_base = quantity × base_lot_size + // raw_quote = quantity × price × quote_lot_size + // + // Choose: + // base_lot_size = 10^max(d_base − d_quote, 0) + // quote_lot_size = 10^max(d_quote − d_base, 0) + // + // so that exactly one of the two is > 1 (or both are 1 when d_base == d_quote). + // With those values `price` equals the human-readable quote/base rate and + // `tick_size = 1` is a single atomic increment. + // + // Examples: + // NVDAx (8 dec) / USDC (6 dec): base_lot_size=100, quote_lot_size=1 + // price=130, qty=1 lot → 130 × 1 × 1 = 130 raw USDC per 100 raw NVDAx + // = $130.00 per NVDAx share ✓ + // + // WBTC (8 dec) / HD-USDC (18 dec): base_lot_size=1, quote_lot_size=10^10 + // price=60_000, qty=1 satoshi-lot → 60_000 × 1 × 10^10 = 6×10^14 raw HD-USDC + // = $60,000 per BTC ✓ + pub base_lot_size: u64, + + // Raw quote-token units per quote lot. See base_lot_size comment above. + pub quote_lot_size: u64, + pub min_order_size: u64, pub is_active: 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 89c1d0cf..e3b0a252 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 @@ -43,15 +43,20 @@ const MARKET_USER_SEED: &[u8] = b"market_user"; // `#[account(zero)]` check fails if the account size is wrong. const ORDER_BOOK_ACCOUNT_SIZE: u64 = order_book::state::ORDER_BOOK_ACCOUNT_SIZE as u64; -// Six decimals matches USDC and keeps "1 token" == 1_000_000 base units, -// which keeps the arithmetic in the assertions easy to read. -const MINT_DECIMALS: u8 = 6; - -// Market parameters used across every test. `tick_size = 1` is permissive -// enough for most scenarios; a dedicated test overrides it to verify the -// tick check fires. +// NVDAx has 8 decimals on-chain; USDC has 6. +const BASE_DECIMALS: u8 = 8; // NVDAx (https://explorer.solana.com/address/Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh) +const QUOTE_DECIMALS: u8 = 6; // USDC + +// Two-lot model for NVDAx/USDC (d_base=8, d_quote=6): +// base_lot_size = 10^max(8-6, 0) = 100 → 1 lot = 100 raw NVDAx +// quote_lot_size = 10^max(6-8, 0) = 1 → 1 quote-lot = 1 raw USDC +// raw_base = quantity × 100 +// raw_quote = price × quantity × 1 (= human USDC/share × lots) +// tick_size = 1 → $1.00 minimum price increment const FEE_BASIS_POINTS: u16 = 10; const TICK_SIZE: u64 = 1; +const BASE_LOT_SIZE: u64 = 100; +const QUOTE_LOT_SIZE: u64 = 1; const MIN_ORDER_SIZE: u64 = 1; // Funding for each trader's token accounts. Large enough to cover every @@ -148,8 +153,8 @@ fn full_setup() -> Scenario { let buyer = create_wallet(&mut svm, 10_000_000_000).unwrap(); let seller = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let base_mint = create_token_mint(&mut svm, &authority, MINT_DECIMALS, None).unwrap(); - let quote_mint = create_token_mint(&mut svm, &authority, MINT_DECIMALS, None).unwrap(); + let base_mint = create_token_mint(&mut svm, &authority, BASE_DECIMALS, None).unwrap(); + let quote_mint = create_token_mint(&mut svm, &authority, QUOTE_DECIMALS, None).unwrap(); // Create and fund every trader's ATAs up-front so individual tests do // not need to worry about mint/ATA side effects, only about order-book state. @@ -248,6 +253,8 @@ fn build_initialize_market_ix( sc: &Scenario, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, + quote_lot_size: u64, min_order_size: u64, ) -> Instruction { Instruction::new_with_bytes( @@ -255,6 +262,8 @@ fn build_initialize_market_ix( &order_book::instruction::InitializeMarket { fee_basis_points, tick_size, + base_lot_size, + quote_lot_size, min_order_size, } .data(), @@ -447,7 +456,7 @@ fn initialize_market_and_users(sc: &mut Scenario) { // program, zero-initialized) before initialize_market's `#[account(zero)]` // check passes. let create_ix = build_create_order_book_account_ix(sc, &sc.authority.pubkey()); - let init_ix = build_initialize_market_ix(sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + let init_ix = build_initialize_market_ix(sc, FEE_BASIS_POINTS, TICK_SIZE, BASE_LOT_SIZE, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -490,7 +499,7 @@ fn initialize_market_sets_market_and_order_book() { let mut sc = full_setup(); let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); - let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, BASE_LOT_SIZE, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], @@ -538,7 +547,7 @@ fn create_market_user_tracks_market_and_owner() { let mut sc = full_setup(); let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); - let init_ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, MIN_ORDER_SIZE); + let init_ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, BASE_LOT_SIZE, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -590,8 +599,8 @@ fn place_bid_locks_quote_in_vault() { send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()) .unwrap(); - // A bid locks price * quantity in the quote vault. - let locked_quote = BID_PRICE * BID_QUANTITY; + // A bid locks price * quantity * quote_lot_size raw quote tokens. + let locked_quote = BID_PRICE * BID_QUANTITY * QUOTE_LOT_SIZE; assert_eq!( get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), locked_quote @@ -635,14 +644,14 @@ fn place_ask_locks_base_in_vault() { send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey()) .unwrap(); - // An ask locks `quantity` of base tokens in the base vault. + // An ask locks quantity * base_lot_size raw base tokens in the base vault. assert_eq!( get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), - ASK_QUANTITY + ASK_QUANTITY * BASE_LOT_SIZE ); assert_eq!( get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), - TRADER_STARTING_BALANCE - ASK_QUANTITY + TRADER_STARTING_BALANCE - ASK_QUANTITY * BASE_LOT_SIZE ); assert_eq!( get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), @@ -686,7 +695,7 @@ fn place_order_rejects_unaligned_tick() { let unusual_tick_size: u64 = 50; let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); let init_ix = - build_initialize_market_ix(&sc, FEE_BASIS_POINTS, unusual_tick_size, MIN_ORDER_SIZE); + build_initialize_market_ix(&sc, FEE_BASIS_POINTS, unusual_tick_size, BASE_LOT_SIZE, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -743,7 +752,7 @@ fn place_order_rejects_below_min_order_size() { let elevated_min_order_size: u64 = 10; let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); let init_ix = - build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, elevated_min_order_size); + build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, BASE_LOT_SIZE, QUOTE_LOT_SIZE, elevated_min_order_size); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -836,12 +845,12 @@ fn cancel_ask_credits_unsettled_base() { // updates the unsettled balance. Settlement is a separate step. assert_eq!( get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), - ASK_QUANTITY + ASK_QUANTITY * BASE_LOT_SIZE ); // Seller's ATA hasn't received anything back yet. assert_eq!( get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), - TRADER_STARTING_BALANCE - ASK_QUANTITY + TRADER_STARTING_BALANCE - ASK_QUANTITY * BASE_LOT_SIZE ); } @@ -1080,7 +1089,7 @@ fn initialize_market_rejects_zero_tick_size() { let zero_tick_size: u64 = 0; let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); - let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, zero_tick_size, MIN_ORDER_SIZE); + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, zero_tick_size, BASE_LOT_SIZE, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], @@ -1096,6 +1105,48 @@ fn initialize_market_rejects_zero_tick_size() { assert!(result.is_err(), "tick_size == 0 must be rejected"); } +#[test] +fn initialize_market_rejects_zero_base_lot_size() { + let mut sc = full_setup(); + + let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, 0, QUOTE_LOT_SIZE, MIN_ORDER_SIZE); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ); + assert!(result.is_err(), "base_lot_size == 0 must be rejected"); +} + +#[test] +fn initialize_market_rejects_zero_quote_lot_size() { + let mut sc = full_setup(); + + let create_ix = build_create_order_book_account_ix(&sc, &sc.authority.pubkey()); + let ix = build_initialize_market_ix(&sc, FEE_BASIS_POINTS, TICK_SIZE, BASE_LOT_SIZE, 0, MIN_ORDER_SIZE); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, ix], + &[ + &sc.authority, + &sc.order_book, + &sc.base_vault, + &sc.quote_vault, + &sc.fee_vault, + ], + &sc.authority.pubkey(), + ); + assert!(result.is_err(), "quote_lot_size == 0 must be rejected"); +} + #[test] fn initialize_market_rejects_oversized_fee() { let mut sc = full_setup(); @@ -1107,6 +1158,8 @@ fn initialize_market_rejects_oversized_fee() { &sc, over_cap_fee_basis_points, TICK_SIZE, + BASE_LOT_SIZE, + QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); let result = send_transaction_from_instructions( @@ -1209,7 +1262,7 @@ fn taker_bid_fully_crosses_best_ask() { // that trader starting balances easily cover it. const PRICE: u64 = 1000; const QUANTITY: u64 = 100; - const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY; + const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; const EXPECTED_FEE: u64 = EXPECTED_GROSS_QUOTE * FEE_BASIS_POINTS as u64 / 10_000; const EXPECTED_NET_TO_MAKER: u64 = EXPECTED_GROSS_QUOTE - EXPECTED_FEE; @@ -1262,7 +1315,7 @@ fn taker_bid_fully_crosses_best_ask() { ); let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, QUANTITY); + assert_eq!(buyer_base, QUANTITY * BASE_LOT_SIZE); // No price improvement here — buyer's limit == maker's price — so no // quote rebate lands in the taker's unsettled_quote. assert_eq!(buyer_quote, 0); @@ -1287,7 +1340,7 @@ fn taker_ask_fully_crosses_best_bid() { const MAKER_BID_ID: u64 = 1; const PRICE: u64 = 1000; const QUANTITY: u64 = 100; - const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY; + const EXPECTED_GROSS_QUOTE: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; const EXPECTED_FEE: u64 = EXPECTED_GROSS_QUOTE * FEE_BASIS_POINTS as u64 / 10_000; const EXPECTED_NET_TO_TAKER: u64 = EXPECTED_GROSS_QUOTE - EXPECTED_FEE; @@ -1337,7 +1390,7 @@ fn taker_ask_fully_crosses_best_bid() { ); // Maker (buyer) received the base tokens they paid for. let (buyer_base, _buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, QUANTITY); + assert_eq!(buyer_base, QUANTITY * BASE_LOT_SIZE); // Taker (seller) received the net-of-fee quote. let (_seller_base, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_market_user); @@ -1407,16 +1460,16 @@ fn taker_partially_fills_resting_order_rest_stays_on_book() { // what was delivered to the taker's unsettled_base — which never left // the vault, just got re-tagged as owed to the buyer). // - // Total base in vault stays == MAKER_ASK_QUANTITY, because fills are - // bucket-accounting inside the single vault. + // Total base in vault stays == MAKER_ASK_QUANTITY * BASE_LOT_SIZE, because + // fills are bucket-accounting inside the single vault. assert_eq!( get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), - MAKER_ASK_QUANTITY + MAKER_ASK_QUANTITY * BASE_LOT_SIZE ); - // Taker received TAKER_BID_QUANTITY base tokens. + // Taker received TAKER_BID_QUANTITY lots = TAKER_BID_QUANTITY * BASE_LOT_SIZE raw base tokens. let (buyer_base, _) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, TAKER_BID_QUANTITY); + assert_eq!(buyer_base, TAKER_BID_QUANTITY * BASE_LOT_SIZE); } #[test] @@ -1584,19 +1637,18 @@ fn taker_crosses_multiple_resting_orders_best_price_first() { assert_eq!(read_order_fill_and_status(&sc.svm, &order_one).1, ORDER_STATUS_FILLED); assert_eq!(read_order_fill_and_status(&sc.svm, &order_two).1, ORDER_STATUS_FILLED); - // Taker got `TAKER_BID_QUANTITY` base tokens. + // Taker got TAKER_BID_QUANTITY lots = TAKER_BID_QUANTITY * BASE_LOT_SIZE raw base tokens. let (buyer_base, buyer_quote_rebate) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, TAKER_BID_QUANTITY); + assert_eq!(buyer_base, TAKER_BID_QUANTITY * BASE_LOT_SIZE); // Price-improvement rebate: taker locked at 1000/unit but 30 units - // filled at 900. Rebate = (1000 - 900) * 30 = 3_000. - const PRICE_IMPROVEMENT_REBATE: u64 = (TAKER_BID_PRICE - BEST_ASK_PRICE) * BEST_ASK_QUANTITY; + // filled at 900. Rebate = (1000 - 900) * 30 * quote_lot_size. + const PRICE_IMPROVEMENT_REBATE: u64 = (TAKER_BID_PRICE - BEST_ASK_PRICE) * BEST_ASK_QUANTITY * QUOTE_LOT_SIZE; assert_eq!(buyer_quote_rebate, PRICE_IMPROVEMENT_REBATE); - // Seller's net unsettled_quote = sum of (fill_price * fill_qty - fee) - // across both fills. - let gross_one: u64 = BEST_ASK_PRICE * BEST_ASK_QUANTITY; - let gross_two: u64 = SECOND_ASK_PRICE * SECOND_ASK_QUANTITY; + // Seller's net unsettled_quote = sum of (fill_price * fill_qty * quote_lot_size - fee). + let gross_one: u64 = BEST_ASK_PRICE * BEST_ASK_QUANTITY * QUOTE_LOT_SIZE; + let gross_two: u64 = SECOND_ASK_PRICE * SECOND_ASK_QUANTITY * QUOTE_LOT_SIZE; let fee_one: u64 = gross_one * FEE_BASIS_POINTS as u64 / 10_000; let fee_two: u64 = gross_two * FEE_BASIS_POINTS as u64 / 10_000; let expected_seller_quote = (gross_one - fee_one) + (gross_two - fee_two); @@ -1754,18 +1806,17 @@ fn taker_bid_gets_price_improvement_from_resting_ask() { .unwrap(); // Maker got 900-per-unit (minus fee), not 1000. - let gross_to_maker: u64 = MAKER_ASK_PRICE * QUANTITY; + let gross_to_maker: u64 = MAKER_ASK_PRICE * QUANTITY * QUOTE_LOT_SIZE; let fee: u64 = gross_to_maker * FEE_BASIS_POINTS as u64 / 10_000; let expected_net_to_maker: u64 = gross_to_maker - fee; let (_, seller_quote) = read_user_unsettled(&sc.svm, &sc.seller_market_user); assert_eq!(seller_quote, expected_net_to_maker); - // Taker locked (TAKER_BID_PRICE * QUANTITY) of quote up front; only - // (MAKER_ASK_PRICE * QUANTITY) was spent. The difference is the - // price-improvement rebate. - let expected_rebate: u64 = (TAKER_BID_PRICE - MAKER_ASK_PRICE) * QUANTITY; + // Taker locked (TAKER_BID_PRICE * QUANTITY * QUOTE_LOT_SIZE) up front; + // only (MAKER_ASK_PRICE * QUANTITY * QUOTE_LOT_SIZE) was spent. + let expected_rebate: u64 = (TAKER_BID_PRICE - MAKER_ASK_PRICE) * QUANTITY * QUOTE_LOT_SIZE; let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, QUANTITY); + assert_eq!(buyer_base, QUANTITY * BASE_LOT_SIZE); assert_eq!(buyer_quote, expected_rebate); } @@ -1779,7 +1830,7 @@ fn fee_vault_receives_exactly_bps_of_taker_gross() { const MAKER_ASK_ID: u64 = 1; const PRICE: u64 = 500; const QUANTITY: u64 = 200; - const GROSS: u64 = PRICE * QUANTITY; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; let __ix5 = build_place_order_ix( @@ -1836,7 +1887,7 @@ fn authority_can_withdraw_fees_after_match() { const MAKER_ASK_ID: u64 = 1; const PRICE: u64 = 2000; const QUANTITY: u64 = 50; - const GROSS: u64 = PRICE * QUANTITY; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; let __ix7 = build_place_order_ix( @@ -1905,7 +1956,7 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { const MAKER_ASK_ID: u64 = 1; const PRICE: u64 = 1000; const QUANTITY: u64 = 100; - const GROSS: u64 = PRICE * QUANTITY; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; const EXPECTED_FEE: u64 = GROSS * FEE_BASIS_POINTS as u64 / 10_000; const EXPECTED_NET_QUOTE_TO_SELLER: u64 = GROSS - EXPECTED_FEE;