From 3ed874c571726061d2e65fca69fecf589a60cfc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 19:47:27 +0000 Subject: [PATCH 1/4] fix(tests): use correct NVDAx (8) and USDC (6) mint decimals The base mint was using 6 decimals matching USDC, but the real NVDAx token on-chain has 8 decimals. Split MINT_DECIMALS into BASE_DECIMALS=8 and QUOTE_DECIMALS=6 and add a comment explaining the price resolution consequence: with these two mints, one tick = 10^(8-6) = $100/share. https://claude.ai/code/session_01G6iaAjzg8aoFwe8ZWWG9VR --- .../programs/order-book/tests/test_order_book.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs index 89c1d0cf..11da28f4 100644 --- a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -43,9 +43,11 @@ 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; +// NVDAx has 8 decimals on-chain; USDC has 6. Because the program stores +// price as raw_quote per raw_base, one tick = 10^(base_dec - quote_dec) = 100 +// USDC/share — the minimum representable price step with these two mints. +const BASE_DECIMALS: u8 = 8; // NVDAx (https://explorer.solana.com/address/Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh) +const QUOTE_DECIMALS: u8 = 6; // USDC // Market parameters used across every test. `tick_size = 1` is permissive // enough for most scenarios; a dedicated test overrides it to verify the @@ -148,8 +150,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. From 9c22bf26f6a4478faacfb318f107ceafd1807049 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:58:49 +0000 Subject: [PATCH 2/4] feat: add base_lot_size to fix NVDAx/USDC price resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without base_lot_size, raw-unit prices for NVDAx (8 dec) / USDC (6 dec) produce a $100 minimum tick — unusable at real NVIDIA prices. Fix: quantities are now in lots (1 lot = base_lot_size raw base tokens); setting base_lot_size = 10^(d_base - d_quote) = 100 makes `price` equal the human-readable USDC/share rate with a $1 minimum tick. All base-token flows updated with u128 overflow-safe arithmetic per Solana best practices: ask lock in place_order, bid fill receipt, ask fill maker credit, and ask cancel refund in cancel_order. Tests split MINT_DECIMALS into BASE_DECIMALS=8 / QUOTE_DECIMALS=6, add BASE_LOT_SIZE=100, update all assertions, and add a rejection test for zero base_lot_size. https://claude.ai/code/session_01G6iaAjzg8aoFwe8ZWWG9VR --- .../anchor/programs/order-book/src/errors.rs | 3 + .../src/instructions/cancel_order.rs | 7 +- .../src/instructions/initialize_market.rs | 3 + .../src/instructions/place_order.rs | 20 ++++- .../anchor/programs/order-book/src/lib.rs | 2 + .../programs/order-book/src/state/market.rs | 13 ++++ .../order-book/tests/test_order_book.rs | 75 +++++++++++++------ 7 files changed, 95 insertions(+), 28 deletions(-) diff --git a/defi/order-book/anchor/programs/order-book/src/errors.rs b/defi/order-book/anchor/programs/order-book/src/errors.rs index f2a96860..f362524e 100644 --- a/defi/order-book/anchor/programs/order-book/src/errors.rs +++ b/defi/order-book/anchor/programs/order-book/src/errors.rs @@ -23,6 +23,9 @@ pub enum ErrorCode { #[msg("Price does not align with tick size")] InvalidTickSize, + #[msg("Base lot size must be greater than zero")] + InvalidBaseLotSize, + #[msg("Quantity is below minimum order size")] BelowMinOrderSize, diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs index b27316a0..ddf4caa3 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs @@ -43,9 +43,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/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs index f77c8972..90fc1699 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -12,9 +12,11 @@ pub fn handle_initialize_market( context: Context, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, min_order_size: u64, ) -> Result<()> { require!(tick_size > 0, ErrorCode::InvalidTickSize); + require!(base_lot_size > 0, ErrorCode::InvalidBaseLotSize); require!(min_order_size > 0, ErrorCode::BelowMinOrderSize); require!( fee_basis_points <= MAX_FEE_BASIS_POINTS, @@ -31,6 +33,7 @@ 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.min_order_size = min_order_size; market.is_active = true; market.bump = context.bumps.market; diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs index 3fa1952d..b203752e 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs @@ -75,7 +75,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(), ), }; @@ -218,8 +222,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 @@ -241,9 +250,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/defi/order-book/anchor/programs/order-book/src/lib.rs b/defi/order-book/anchor/programs/order-book/src/lib.rs index 1e06df24..2589acb3 100644 --- a/defi/order-book/anchor/programs/order-book/src/lib.rs +++ b/defi/order-book/anchor/programs/order-book/src/lib.rs @@ -19,12 +19,14 @@ pub mod order_book { context: Context, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, min_order_size: u64, ) -> Result<()> { instructions::initialize_market::handle_initialize_market( context, fee_basis_points, tick_size, + base_lot_size, min_order_size, ) } diff --git a/defi/order-book/anchor/programs/order-book/src/state/market.rs b/defi/order-book/anchor/programs/order-book/src/state/market.rs index 42a81504..2cb87efd 100644 --- a/defi/order-book/anchor/programs/order-book/src/state/market.rs +++ b/defi/order-book/anchor/programs/order-book/src/state/market.rs @@ -31,6 +31,19 @@ pub struct Market { pub tick_size: u64, + // Number of raw base-token units per lot. Quantities throughout the + // program are in lots; this factor converts them to raw token units for + // SPL transfers. For a base mint with d_base decimals and quote with + // d_quote, set base_lot_size = 10^(d_base - d_quote) so that one raw + // quote unit buys exactly one lot of base at price = 1, making `price` + // equal to the human-readable USDC-per-token rate. + // + // Example — NVDAx (8 dec) / USDC (6 dec): + // base_lot_size = 10^(8-6) = 100 raw NVDAx per lot + // price = 130 → $130.00 per NVDAx share + // tick_size = 1 → $1.00 minimum price increment + pub base_lot_size: u64, + pub min_order_size: u64, pub is_active: bool, diff --git a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs index 11da28f4..e33c8e2a 100644 --- a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -49,11 +49,14 @@ const ORDER_BOOK_ACCOUNT_SIZE: u64 = order_book::state::ORDER_BOOK_ACCOUNT_SIZE const BASE_DECIMALS: u8 = 8; // NVDAx (https://explorer.solana.com/address/Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh) const QUOTE_DECIMALS: u8 = 6; // USDC -// 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. +// Market parameters used across every test. `tick_size = 1` and +// `base_lot_size = 100` match the NVDAx/USDC decimal configuration +// (BASE_DECIMALS=8, QUOTE_DECIMALS=6): price = human USDC/share, +// tick = $1.00, 1 lot = 100 raw NVDAx. A dedicated test overrides +// tick_size to verify the tick check fires. const FEE_BASIS_POINTS: u16 = 10; const TICK_SIZE: u64 = 1; +const BASE_LOT_SIZE: u64 = 100; const MIN_ORDER_SIZE: u64 = 1; // Funding for each trader's token accounts. Large enough to cover every @@ -250,6 +253,7 @@ fn build_initialize_market_ix( sc: &Scenario, fee_basis_points: u16, tick_size: u64, + base_lot_size: u64, min_order_size: u64, ) -> Instruction { Instruction::new_with_bytes( @@ -257,6 +261,7 @@ fn build_initialize_market_ix( &order_book::instruction::InitializeMarket { fee_basis_points, tick_size, + base_lot_size, min_order_size, } .data(), @@ -449,7 +454,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, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -492,7 +497,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, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], @@ -540,7 +545,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, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -637,14 +642,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(), @@ -688,7 +693,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, MIN_ORDER_SIZE); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -745,7 +750,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, elevated_min_order_size); send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], @@ -838,12 +843,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 ); } @@ -1082,7 +1087,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, MIN_ORDER_SIZE); let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], @@ -1098,6 +1103,27 @@ 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, 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_oversized_fee() { let mut sc = full_setup(); @@ -1109,6 +1135,7 @@ fn initialize_market_rejects_oversized_fee() { &sc, over_cap_fee_basis_points, TICK_SIZE, + BASE_LOT_SIZE, MIN_ORDER_SIZE, ); let result = send_transaction_from_instructions( @@ -1264,7 +1291,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); @@ -1339,7 +1366,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); @@ -1409,16 +1436,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] @@ -1586,9 +1613,9 @@ 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. @@ -1767,7 +1794,7 @@ fn taker_bid_gets_price_improvement_from_resting_ask() { // price-improvement rebate. let expected_rebate: u64 = (TAKER_BID_PRICE - MAKER_ASK_PRICE) * QUANTITY; let (buyer_base, buyer_quote) = read_user_unsettled(&sc.svm, &sc.buyer_market_user); - assert_eq!(buyer_base, QUANTITY); + assert_eq!(buyer_base, QUANTITY * BASE_LOT_SIZE); assert_eq!(buyer_quote, expected_rebate); } From 21ff523d5d893002622fb7e7169ea6436b237fb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:40:33 +0000 Subject: [PATCH 3/4] feat: add quote_lot_size for full decimal generality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the Serum/Openbook two-lot model. Previously the program was only correct when d_base >= d_quote (e.g. NVDAx/USDC), because quote amounts were computed as price × quantity raw quote tokens with no scaling on the quote side. With quote_lot_size, all raw-quote amounts become: raw_quote = price × quantity × quote_lot_size Choosing base_lot_size = 10^max(d_base - d_quote, 0) and quote_lot_size = 10^max(d_quote - d_base, 0) makes price the human-readable quote/base rate and tick_size=1 one atomic price increment regardless of which mint has more decimals. NVDAx (8 dec) / USDC (6 dec): base_lot_size=100, quote_lot_size=1 (values unchanged) WBTC (8 dec) / HD-USDC (18 dec): base_lot_size=1, quote_lot_size=10^10 Changes: InvalidQuoteLotSize error; quote_lot_size field on Market; new param in initialize_market; bid lock, gross_quote, and locked_for_this_fill in place_order all gain x quote_lot_size; bid cancel refund in cancel_order gains x quote_lot_size; settle_funds untouched (already operates on raw amounts in unsettled_*). Tests add QUOTE_LOT_SIZE=1, update all call sites and assertions, add rejection test for zero quote_lot_size. https://claude.ai/code/session_01G6iaAjzg8aoFwe8ZWWG9VR --- .../anchor/programs/order-book/src/errors.rs | 3 + .../src/instructions/cancel_order.rs | 9 +- .../src/instructions/initialize_market.rs | 3 + .../src/instructions/place_order.rs | 6 ++ .../anchor/programs/order-book/src/lib.rs | 2 + .../programs/order-book/src/state/market.rs | 36 +++++--- .../order-book/tests/test_order_book.rs | 88 ++++++++++++------- 7 files changed, 99 insertions(+), 48 deletions(-) diff --git a/defi/order-book/anchor/programs/order-book/src/errors.rs b/defi/order-book/anchor/programs/order-book/src/errors.rs index f362524e..b3738c77 100644 --- a/defi/order-book/anchor/programs/order-book/src/errors.rs +++ b/defi/order-book/anchor/programs/order-book/src/errors.rs @@ -26,6 +26,9 @@ pub enum ErrorCode { #[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/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs index ddf4caa3..984a10b7 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs @@ -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 diff --git a/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs index 90fc1699..a2d925bd 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -13,10 +13,12 @@ pub fn handle_initialize_market( 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, @@ -34,6 +36,7 @@ pub fn handle_initialize_market( 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/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs index b203752e..13a227be 100644 --- a/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs +++ b/defi/order-book/anchor/programs/order-book/src/instructions/place_order.rs @@ -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(), @@ -194,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))?; @@ -239,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 diff --git a/defi/order-book/anchor/programs/order-book/src/lib.rs b/defi/order-book/anchor/programs/order-book/src/lib.rs index 2589acb3..d0ca768a 100644 --- a/defi/order-book/anchor/programs/order-book/src/lib.rs +++ b/defi/order-book/anchor/programs/order-book/src/lib.rs @@ -20,6 +20,7 @@ pub mod order_book { 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( @@ -27,6 +28,7 @@ pub mod order_book { fee_basis_points, tick_size, base_lot_size, + quote_lot_size, min_order_size, ) } diff --git a/defi/order-book/anchor/programs/order-book/src/state/market.rs b/defi/order-book/anchor/programs/order-book/src/state/market.rs index 2cb87efd..f8db5579 100644 --- a/defi/order-book/anchor/programs/order-book/src/state/market.rs +++ b/defi/order-book/anchor/programs/order-book/src/state/market.rs @@ -31,19 +31,35 @@ pub struct Market { pub tick_size: u64, - // Number of raw base-token units per lot. Quantities throughout the - // program are in lots; this factor converts them to raw token units for - // SPL transfers. For a base mint with d_base decimals and quote with - // d_quote, set base_lot_size = 10^(d_base - d_quote) so that one raw - // quote unit buys exactly one lot of base at price = 1, making `price` - // equal to the human-readable USDC-per-token rate. + // 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. // - // Example — NVDAx (8 dec) / USDC (6 dec): - // base_lot_size = 10^(8-6) = 100 raw NVDAx per lot - // price = 130 → $130.00 per NVDAx share - // tick_size = 1 → $1.00 minimum price increment + // 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/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs index e33c8e2a..e3b0a252 100644 --- a/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/defi/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -43,20 +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; -// NVDAx has 8 decimals on-chain; USDC has 6. Because the program stores -// price as raw_quote per raw_base, one tick = 10^(base_dec - quote_dec) = 100 -// USDC/share — the minimum representable price step with these two mints. +// 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 -// Market parameters used across every test. `tick_size = 1` and -// `base_lot_size = 100` match the NVDAx/USDC decimal configuration -// (BASE_DECIMALS=8, QUOTE_DECIMALS=6): price = human USDC/share, -// tick = $1.00, 1 lot = 100 raw NVDAx. A dedicated test overrides -// tick_size to verify the tick check fires. +// 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 @@ -254,6 +254,7 @@ fn build_initialize_market_ix( fee_basis_points: u16, tick_size: u64, base_lot_size: u64, + quote_lot_size: u64, min_order_size: u64, ) -> Instruction { Instruction::new_with_bytes( @@ -262,6 +263,7 @@ fn build_initialize_market_ix( fee_basis_points, tick_size, base_lot_size, + quote_lot_size, min_order_size, } .data(), @@ -454,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, BASE_LOT_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], @@ -497,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, BASE_LOT_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], @@ -545,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, BASE_LOT_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], @@ -597,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 @@ -693,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, BASE_LOT_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], @@ -750,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, BASE_LOT_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], @@ -1087,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, BASE_LOT_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], @@ -1108,7 +1110,7 @@ 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, MIN_ORDER_SIZE); + 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], @@ -1124,6 +1126,27 @@ fn initialize_market_rejects_zero_base_lot_size() { 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(); @@ -1136,6 +1159,7 @@ fn initialize_market_rejects_oversized_fee() { over_cap_fee_basis_points, TICK_SIZE, BASE_LOT_SIZE, + QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); let result = send_transaction_from_instructions( @@ -1238,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; @@ -1316,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; @@ -1618,14 +1642,13 @@ fn taker_crosses_multiple_resting_orders_best_price_first() { 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); @@ -1783,16 +1806,15 @@ 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 * BASE_LOT_SIZE); assert_eq!(buyer_quote, expected_rebate); @@ -1808,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( @@ -1865,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( @@ -1934,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; From 4647f0eaa558bab84fcdf7656831c078acb16868 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:51:46 +0000 Subject: [PATCH 4/4] docs: clarify critbit is depth-bounded, not self-balancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Balanced-by-construction" lumped critbit in with red-black/AVL trees, which it isn't — critbit never rotates or recolours. Reframe the §8 heading and prose around the accurate property: a radix trie whose depth is bounded by the key's bit width (<=128), so it cannot degenerate under adversarial insert order. The security conclusion is unchanged. https://claude.ai/code/session_01G6iaAjzg8aoFwe8ZWWG9VR --- defi/order-book/anchor/README.md | 49 ++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/defi/order-book/anchor/README.md b/defi/order-book/anchor/README.md index 2265ae72..ed0bdc39 100644 --- a/defi/order-book/anchor/README.md +++ b/defi/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