From fb46fdeab61017a30c00c4d2079ec1e3c2edc572 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 02:34:52 +0000 Subject: [PATCH 1/2] finance/order-book: test the deepest critbit path the key allows A critbit tree does not rebalance, so asks at doubling prices stretch the path to the best ask by one level each. The price fills the top 64 bits of the 128-bit key, so prices can add at most 64 levels. Both Anchor copies gain two tests: one builds that 64-level path and reads its depth from the account, the other checks that insert, fill, and cancel at the bottom of it each cost under 15,000 compute units more than on a shallow book and fit the default 200,000-unit budget. Measured, Anchor v2: insert 13,715 -> 24,253 CU, fill 24,380 -> 35,444, cancel 2,816 -> 10,844. Anchor v1: insert 26,612 -> 38,754, fill 31,283 -> 41,941, cancel 8,925 -> 16,871. The README no longer claims the tree stays shallow whatever order keys arrive in, or that Phoenix uses the same structure (it uses a red-black tree), and its expected test output is brought up to date. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RQ5RZgoQjVK36qSjqUSq6X --- CHANGELOG.md | 14 + finance/order-book/anchor-v1/CHANGELOG.md | 11 + finance/order-book/anchor-v1/README.md | 35 ++- .../anchor-v1/programs/order-book/Cargo.toml | 4 + .../order-book/tests/test_order_book.rs | 249 +++++++++++++++++ finance/order-book/anchor/CHANGELOG.md | 16 ++ finance/order-book/anchor/README.md | 35 ++- .../order-book/tests/test_order_book.rs | 253 ++++++++++++++++++ 8 files changed, 601 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e9e880..2ad4acc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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] - Order book tests cover the deepest critbit path + +A critbit tree does not rebalance, so asks at doubling prices stretch the path +to the best ask by one level each, up to 64 levels from the price half of the +128-bit key. Both Anchor copies of the order book gain two tests: +`doubling_prices_build_the_deepest_path_prices_allow` builds that path and reads +its depth from the account, and +`deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that +inserting, filling, and cancelling at the bottom of it each cost less than 15,000 +compute units more than on a shallow book, and stay inside the default +200,000-unit instruction budget. The README no longer says the tree stays +shallow whatever order keys arrive in, or that Phoenix uses the same structure +(it uses a red-black tree). No program behavior changes. + ## [2026-09-23] - Finance examples point their production oracle path at Pyth The oracle network that the lending, perpetual futures and prop AMM examples diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index cae3632e..c98c2dd0 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -2,8 +2,19 @@ ## 2026-09-23 +### Added + +- `doubling_prices_build_the_deepest_path_prices_allow` and + `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: asks at 63 + doubling prices build a 64-level path to the best ask, and inserting, + filling, and cancelling at the bottom of it each cost less than 15,000 + compute units more than on a shallow book. + ### Changed +- The README states the critbit tree's real depth bound (64 levels from + prices, 128 at most) instead of saying it stays shallow whatever order keys + arrive in, and says Phoenix uses a red-black tree. - Ported from the Anchor v2 copy: the base, quote, and fee vaults are PDAs of the market, at seeds `["base_vault", market]`, `["quote_vault", market]` and `["fee_vault", market]`, instead of token accounts at public keys the client diff --git a/finance/order-book/anchor-v1/README.md b/finance/order-book/anchor-v1/README.md index 79bb1ba7..7ef54283 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -883,13 +883,21 @@ 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. 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 +never rotates or recolours nodes, so it does not stay balanced: asks at +prices 2, 4, 8, ... each set a new highest price bit and add a level to +the path to the cheapest ask. Its depth is bounded instead by the *bit +width of the key*: the price fills the top 64 of the key's 128 bits, so +prices alone can lengthen a path by at most 64 levels, and no path can +exceed 128. `doubling_prices_build_the_deepest_path_prices_allow` builds +that 64-level path, and +`deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that +inserting, filling, and cancelling at the bottom of it each cost less than +15,000 compute units more than on a shallow book, and stay inside the +default 200,000-unit instruction budget. 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 -example. +[Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1) keeps its book in a +red-black tree, the self-balancing alternative. Both are production Solana +CLOBs worth reading alongside this example. ### 4.1 The plan @@ -1425,16 +1433,21 @@ anchor test --skip-local-validator Expected: ``` -running 23 tests +running 29 tests test authority_can_withdraw_fees_after_match ... ok test cancel_and_settle_bid_refunds_full_quote ... ok test cancel_ask_credits_unsettled_base ... ok test cancel_order_rejects_non_owner ... ok -test initialize_market_user_tracks_market_and_owner ... ok +test deepest_path_adds_little_compute_to_insert_fill_and_cancel ... ok +test doubling_prices_build_the_deepest_path_prices_allow ... ok +test fee_rounds_up_when_gross_is_not_a_bps_multiple ... ok test fee_vault_receives_exactly_bps_of_taker_gross ... ok test initialize_market_rejects_oversized_fee ... ok +test initialize_market_rejects_zero_base_lot_size ... ok +test initialize_market_rejects_zero_quote_lot_size ... ok test initialize_market_rejects_zero_tick_size ... ok test initialize_market_sets_market_and_order_book ... ok +test initialize_market_user_tracks_market_and_owner ... ok test place_ask_locks_base_in_vault ... ok test place_bid_locks_quote_in_vault ... ok test place_order_rejects_below_min_order_size ... ok @@ -1443,6 +1456,7 @@ test place_order_rejects_zero_price ... ok test resting_orders_at_same_price_fill_by_time_priority ... ok test settle_funds_after_match_pays_out_both_unsettled_balances ... ok test settle_funds_moves_unsettled_base_to_user ... ok +test settle_funds_rejects_fee_vault_substituted_for_quote_vault ... ok test taker_ask_fully_crosses_best_bid ... ok test taker_bid_fully_crosses_best_ask ... ok test taker_bid_gets_price_improvement_from_resting_ask ... ok @@ -1488,6 +1502,11 @@ test taker_partially_fills_resting_order_rest_stays_on_book ... ok - `authority_can_withdraw_fees_after_match`: Fee drain after fills, authority-gated - `settle_funds_after_match_pays_out_both_unsettled_balances`: Both legs paid in one call +**Tree depth:** + +- `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 + ### 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/Cargo.toml b/finance/order-book/anchor-v1/programs/order-book/Cargo.toml index 74e8dd8c..41b45283 100644 --- a/finance/order-book/anchor-v1/programs/order-book/Cargo.toml +++ b/finance/order-book/anchor-v1/programs/order-book/Cargo.toml @@ -36,6 +36,10 @@ litesvm = "0.16.0" solana-signer = "3.0.0" solana-keypair = "3.0.1" solana-kite = "0.5.0" +# The depth tests send transactions themselves to read the compute units +# each one used, which solana-kite's helper does not return. Same versions +# solana-kite builds against. +solana-transaction = "4.1.5" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } 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 c1c81392..360d27d2 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 @@ -2053,3 +2053,252 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { ); } + +// --------------------------------------------------------------------------- +// Worst-case tree depth +// +// A critbit tree does not rebalance. Asks at prices 2, 4, 8, ..., 2^63 each +// set a new highest price bit, so each one adds an inner node above all the +// cheaper asks and the path to the best ask becomes a chain. The tree key is +// 128 bits with the price in the top 64, so prices alone can stretch a path +// to at most 64 inner nodes, and no path can ever exceed 128. These tests +// build that chain and check that inserting, matching, and cancelling at the +// bottom of it still costs little compared with a shallow book. +// --------------------------------------------------------------------------- + +// Each extra seller can hold MAX_OPEN_ORDERS_PER_USER resting orders. +const MAX_OPEN_ORDERS_PER_USER: usize = 20; + +// Doubling prices 2^1 ..= 2^63. Together with the probe asks at price 1 +// placed underneath them, that is every power of two a u64 price can hold. +const CHAIN_PRICE_EXPONENTS: std::ops::RangeInclusive = 1..=63; + +// The probe orders sit at the bottom of the chain, at the lowest price. +const PROBE_PRICE: u64 = 1; + +// The most compute a worst-case path may add to any one instruction, +// compared with the same instruction on a book holding only the probes. +const MAX_EXTRA_COMPUTE_UNITS_FROM_DEPTH: u64 = 15_000; + +// What the runtime grants an instruction that does not request more. +const DEFAULT_INSTRUCTION_COMPUTE_UNITS: u64 = 200_000; + +// Inner nodes between the root and a leaf, as read from the account bytes. +// Offsets come from the program's own types, so a layout change moves them +// with it. +fn best_ask_depth(svm: &LiteSVM, order_book: &Pubkey) -> usize { + use order_book::state::{slab::NodeTag, OrderBook}; + + const DISCRIMINATOR_LEN: usize = 8; + // OrderTreeNodes: order_tree_type (1) + padding (3) + bump_index (4) + // + free_list_len (4) + free_list_head (4), then the node array. + const NODES_OFFSET_IN_TREE: usize = 16; + // InnerNode: tag (1) + padding (3) + prefix_len (4) + key (16), then + // children[0], the left (lower-key) child. + const LEFT_CHILD_OFFSET_IN_NODE: usize = 24; + + let data = svm.get_account(order_book).unwrap().data; + let asks_root = DISCRIMINATOR_LEN + std::mem::offset_of!(OrderBook, asks_root); + let asks_nodes = + DISCRIMINATOR_LEN + std::mem::offset_of!(OrderBook, asks) + NODES_OFFSET_IN_TREE; + let read_u32 = |offset: usize| u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()); + + let mut handle = read_u32(asks_root) as usize; + let mut depth = 0; + loop { + let node = asks_nodes + handle * order_book::state::slab::NODE_SIZE; + if data[node] == NodeTag::LeafNode as u8 { + return depth; + } + assert_eq!(data[node], NodeTag::InnerNode as u8); + // Asks sort lowest key first, so the best ask is always leftmost. + handle = read_u32(node + LEFT_CHILD_OFFSET_IN_NODE) as usize; + depth += 1; + } +} + +// Send one instruction and return the compute units it used. +fn send_and_measure(svm: &mut LiteSVM, instruction: Instruction, signer: &Keypair) -> u64 { + let transaction = solana_transaction::Transaction::new_signed_with_payer( + &[instruction], + Some(&signer.pubkey()), + &[signer], + svm.latest_blockhash(), + ); + svm.send_transaction(transaction) + .unwrap() + .compute_units_consumed +} + +// A fresh seller with base tokens and a market user account. +fn add_funded_seller(sc: &mut Scenario) -> (Keypair, Pubkey, Pubkey, Pubkey) { + let seller = create_wallet(&mut sc.svm, 10_000_000_000).unwrap(); + let base_ata = + create_associated_token_account(&mut sc.svm, &seller.pubkey(), &sc.base_mint, &sc.payer) + .unwrap(); + let quote_ata = + create_associated_token_account(&mut sc.svm, &seller.pubkey(), &sc.quote_mint, &sc.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut sc.svm, + &sc.base_mint, + &base_ata, + TRADER_STARTING_BALANCE, + &sc.authority, + ) + .unwrap(); + let market_user = market_user_pda(&sc.program_id, &sc.market, &seller.pubkey()); + let instruction = build_initialize_market_user_ix(sc, &seller.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![instruction], + &[&seller], + &seller.pubkey(), + ) + .unwrap(); + (seller, base_ata, quote_ata, market_user) +} + +struct ProbeCosts { + // Inner nodes above the second probe ask once it rests. + depth: usize, + insert: u64, + fill: u64, + cancel: u64, +} + +// Optionally build the doubling-price chain, then run the same three probes +// at the bottom of it: rest an ask at PROBE_PRICE, rest a second one behind +// it, fill the first with a taker bid, and cancel the second. +fn run_probes_at_bottom_of_book(build_chain: bool) -> ProbeCosts { + use order_book::state::OrderSide; + + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let mut next_order_id: u64 = 1; + + if build_chain { + let chain_prices: Vec = CHAIN_PRICE_EXPONENTS + .map(|exponent| 1u64 << exponent) + .collect(); + for sellers_orders in chain_prices.chunks(MAX_OPEN_ORDERS_PER_USER) { + let (seller, base_ata, quote_ata, market_user) = add_funded_seller(&mut sc); + for &price in sellers_orders { + let instruction = build_place_order_ix( + &sc, + &seller, + market_user, + base_ata, + quote_ata, + OrderSide::Ask, + next_order_id, + price, + MIN_ORDER_SIZE, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![instruction], + &[&seller], + &seller.pubkey(), + ) + .unwrap(); + next_order_id += 1; + } + } + } + + let first_probe_id = next_order_id; + let instruction = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + OrderSide::Ask, + first_probe_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + ); + let insert = send_and_measure(&mut sc.svm, instruction, &sc.seller); + + let second_probe_id = first_probe_id + 1; + let instruction = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + OrderSide::Ask, + second_probe_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + ); + send_and_measure(&mut sc.svm, instruction, &sc.seller); + let depth = best_ask_depth(&sc.svm, &sc.order_book.pubkey()); + + let taker_bid_id = second_probe_id + 1; + let instruction = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + OrderSide::Bid, + taker_bid_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + &[(first_probe_id, sc.seller_market_user)], + ); + let fill = send_and_measure(&mut sc.svm, instruction, &sc.buyer); + let first_probe = order_pda(&sc.program_id, &sc.market, first_probe_id); + assert_eq!( + read_order_fill_and_status(&sc.svm, &first_probe).1, + ORDER_STATUS_FILLED + ); + + let instruction = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + second_probe_id, + ); + let cancel = send_and_measure(&mut sc.svm, instruction, &sc.seller); + + ProbeCosts { + depth, + insert, + fill, + cancel, + } +} + +#[test] +fn doubling_prices_build_the_deepest_path_prices_allow() { + // 63 chain asks plus two probes at price 1: the chain gives 63 inner + // nodes above price 1, and the second probe splits from the first on a + // sequence-number bit, adding one more. + let deep = run_probes_at_bottom_of_book(true); + assert_eq!(deep.depth, 64); + + let shallow = run_probes_at_bottom_of_book(false); + assert_eq!(shallow.depth, 1); +} + +#[test] +fn deepest_path_adds_little_compute_to_insert_fill_and_cancel() { + let deep = run_probes_at_bottom_of_book(true); + let shallow = run_probes_at_bottom_of_book(false); + + for (operation, deep_units, shallow_units) in [ + ("insert", deep.insert, shallow.insert), + ("fill", deep.fill, shallow.fill), + ("cancel", deep.cancel, shallow.cancel), + ] { + println!("{operation}: {shallow_units} CU on a shallow book, {deep_units} CU at depth 64"); + assert!( + deep_units - shallow_units < MAX_EXTRA_COMPUTE_UNITS_FROM_DEPTH, + "{operation} costs {deep_units} CU at depth 64 against {shallow_units} CU on a shallow book" + ); + assert!(deep_units < DEFAULT_INSTRUCTION_COMPUTE_UNITS); + } +} diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index ea33d9c8..23caaded 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2026-09-23 + +### Added + +- `doubling_prices_build_the_deepest_path_prices_allow` and + `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: asks at 63 + doubling prices build a 64-level path to the best ask, and inserting, + filling, and cancelling at the bottom of it each cost less than 15,000 + compute units more than on a shallow book. + +### Changed + +- The README states the critbit tree's real depth bound (64 levels from + prices, 128 at most) instead of saying it stays shallow whatever order keys + arrive in, and says Phoenix uses a red-black tree. + ## 2026-09-22 ### Changed diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index 45c51ca2..779dd788 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -892,13 +892,21 @@ 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. 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 +never rotates or recolours nodes, so it does not stay balanced: asks at +prices 2, 4, 8, ... each set a new highest price bit and add a level to +the path to the cheapest ask. Its depth is bounded instead by the *bit +width of the key*: the price fills the top 64 of the key's 128 bits, so +prices alone can lengthen a path by at most 64 levels, and no path can +exceed 128. `doubling_prices_build_the_deepest_path_prices_allow` builds +that 64-level path, and +`deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that +inserting, filling, and cancelling at the bottom of it each cost less than +15,000 compute units more than on a shallow book, and stay inside the +default 200,000-unit instruction budget. 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 -example. +[Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1) keeps its book in a +red-black tree, the self-balancing alternative. Both are production Solana +CLOBs worth reading alongside this example. ### 4.1 The plan @@ -1434,16 +1442,21 @@ anchor test --skip-local-validator Expected: ``` -running 23 tests +running 29 tests test authority_can_withdraw_fees_after_match ... ok test cancel_and_settle_bid_refunds_full_quote ... ok test cancel_ask_credits_unsettled_base ... ok test cancel_order_rejects_non_owner ... ok -test initialize_market_user_tracks_market_and_owner ... ok +test deepest_path_adds_little_compute_to_insert_fill_and_cancel ... ok +test doubling_prices_build_the_deepest_path_prices_allow ... ok +test fee_rounds_up_when_gross_is_not_a_bps_multiple ... ok test fee_vault_receives_exactly_bps_of_taker_gross ... ok test initialize_market_rejects_oversized_fee ... ok +test initialize_market_rejects_zero_base_lot_size ... ok +test initialize_market_rejects_zero_quote_lot_size ... ok test initialize_market_rejects_zero_tick_size ... ok test initialize_market_sets_market_and_order_book ... ok +test initialize_market_user_tracks_market_and_owner ... ok test place_ask_locks_base_in_vault ... ok test place_bid_locks_quote_in_vault ... ok test place_order_rejects_below_min_order_size ... ok @@ -1452,6 +1465,7 @@ test place_order_rejects_zero_price ... ok test resting_orders_at_same_price_fill_by_time_priority ... ok test settle_funds_after_match_pays_out_both_unsettled_balances ... ok test settle_funds_moves_unsettled_base_to_user ... ok +test settle_funds_rejects_fee_vault_substituted_for_quote_vault ... ok test taker_ask_fully_crosses_best_bid ... ok test taker_bid_fully_crosses_best_ask ... ok test taker_bid_gets_price_improvement_from_resting_ask ... ok @@ -1497,6 +1511,11 @@ test taker_partially_fills_resting_order_rest_stays_on_book ... ok - `authority_can_withdraw_fees_after_match`: Fee drain after fills, authority-gated - `settle_funds_after_match_pays_out_both_unsettled_balances`: Both legs paid in one call +**Tree depth:** + +- `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 + ### CI note The repo's `.github/workflows/anchor.yml` runs `anchor build` before 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 ae1778bb..553dee2d 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 @@ -2140,3 +2140,256 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { EXPECTED_NET_QUOTE_TO_SELLER ); } + +// --------------------------------------------------------------------------- +// Worst-case tree depth +// +// A critbit tree does not rebalance. Asks at prices 2, 4, 8, ..., 2^63 each +// set a new highest price bit, so each one adds an inner node above all the +// cheaper asks and the path to the best ask becomes a chain. The tree key is +// 128 bits with the price in the top 64, so prices alone can stretch a path +// to at most 64 inner nodes, and no path can ever exceed 128. These tests +// build that chain and check that inserting, matching, and cancelling at the +// bottom of it still costs little compared with a shallow book. +// --------------------------------------------------------------------------- + +// Each extra seller can hold MAX_OPEN_ORDERS_PER_USER resting orders. +const MAX_OPEN_ORDERS_PER_USER: usize = 20; + +// Doubling prices 2^1 ..= 2^63. Together with the probe asks at price 1 +// placed underneath them, that is every power of two a u64 price can hold. +const CHAIN_PRICE_EXPONENTS: std::ops::RangeInclusive = 1..=63; + +// The probe orders sit at the bottom of the chain, at the lowest price. +const PROBE_PRICE: u64 = 1; + +// The most compute a worst-case path may add to any one instruction, +// compared with the same instruction on a book holding only the probes. +const MAX_EXTRA_COMPUTE_UNITS_FROM_DEPTH: u64 = 15_000; + +// What the runtime grants an instruction that does not request more. +const DEFAULT_INSTRUCTION_COMPUTE_UNITS: u64 = 200_000; + +// Inner nodes between the root and a leaf, as read from the account bytes. +// Offsets come from the program's own types, so a layout change moves them +// with it. +fn best_ask_depth(svm: &LiteSVM, order_book: &Address) -> usize { + use order_book::state::{slab::NodeTag, OrderBook}; + + const DISCRIMINATOR_LEN: usize = 8; + // OrderTreeNodes: order_tree_type (1) + padding (3) + bump_index (4) + // + free_list_len (4) + free_list_head (4), then the node array. + const NODES_OFFSET_IN_TREE: usize = 16; + // InnerNode: tag (1) + padding (3) + prefix_len (4) + key (16), then + // children[0], the left (lower-key) child. + const LEFT_CHILD_OFFSET_IN_NODE: usize = 24; + + let data = svm.get_account(order_book).unwrap().data; + let asks_root = DISCRIMINATOR_LEN + std::mem::offset_of!(OrderBook, asks_root); + let asks_nodes = + DISCRIMINATOR_LEN + std::mem::offset_of!(OrderBook, asks) + NODES_OFFSET_IN_TREE; + let read_u32 = |offset: usize| u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()); + + let mut handle = read_u32(asks_root) as usize; + let mut depth = 0; + loop { + let node = asks_nodes + handle * order_book::state::slab::NODE_SIZE; + if data[node] == NodeTag::LeafNode as u8 { + return depth; + } + assert_eq!(data[node], NodeTag::InnerNode as u8); + // Asks sort lowest key first, so the best ask is always leftmost. + handle = read_u32(node + LEFT_CHILD_OFFSET_IN_NODE) as usize; + depth += 1; + } +} + +// Send one instruction and return the compute units it used. +fn send_and_measure(svm: &mut LiteSVM, instruction: Instruction, signer: &Keypair) -> u64 { + let message = anchor_v2_testing::Message::new_with_blockhash( + &[instruction], + Some(&signer.pubkey()), + &svm.latest_blockhash(), + ); + let transaction = anchor_v2_testing::VersionedTransaction::try_new( + anchor_v2_testing::VersionedMessage::Legacy(message), + &[signer], + ) + .unwrap(); + svm.send_transaction(transaction) + .unwrap() + .compute_units_consumed +} + +// A fresh seller with base tokens and a market user account. +fn add_funded_seller(sc: &mut Scenario) -> (Keypair, Address, Address, Address) { + let seller = create_wallet(&mut sc.svm, 10_000_000_000).unwrap(); + let base_ata = + create_associated_token_account(&mut sc.svm, &seller.pubkey(), &sc.base_mint, &sc.payer) + .unwrap(); + let quote_ata = + create_associated_token_account(&mut sc.svm, &seller.pubkey(), &sc.quote_mint, &sc.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut sc.svm, + &sc.base_mint, + &base_ata, + TRADER_STARTING_BALANCE, + &sc.authority, + ) + .unwrap(); + let market_user = market_user_pda(&sc.program_id, &sc.market, &seller.pubkey()); + let instruction = build_initialize_market_user_ix(sc, &seller.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![instruction], + &[&seller], + &seller.pubkey(), + ) + .unwrap(); + (seller, base_ata, quote_ata, market_user) +} + +struct ProbeCosts { + // Inner nodes above the second probe ask once it rests. + depth: usize, + insert: u64, + fill: u64, + cancel: u64, +} + +// Optionally build the doubling-price chain, then run the same three probes +// at the bottom of it: rest an ask at PROBE_PRICE, rest a second one behind +// it, fill the first with a taker bid, and cancel the second. +fn run_probes_at_bottom_of_book(build_chain: bool) -> ProbeCosts { + use order_book::state::OrderSide; + + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let mut next_order_id: u64 = 1; + + if build_chain { + let chain_prices: Vec = CHAIN_PRICE_EXPONENTS + .map(|exponent| 1u64 << exponent) + .collect(); + for sellers_orders in chain_prices.chunks(MAX_OPEN_ORDERS_PER_USER) { + let (seller, base_ata, quote_ata, market_user) = add_funded_seller(&mut sc); + for &price in sellers_orders { + let instruction = build_place_order_ix( + &sc, + &seller, + market_user, + base_ata, + quote_ata, + OrderSide::Ask, + next_order_id, + price, + MIN_ORDER_SIZE, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![instruction], + &[&seller], + &seller.pubkey(), + ) + .unwrap(); + next_order_id += 1; + } + } + } + + let first_probe_id = next_order_id; + let instruction = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + OrderSide::Ask, + first_probe_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + ); + let insert = send_and_measure(&mut sc.svm, instruction, &sc.seller); + + let second_probe_id = first_probe_id + 1; + let instruction = build_place_order_ix( + &sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + OrderSide::Ask, + second_probe_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + ); + send_and_measure(&mut sc.svm, instruction, &sc.seller); + let depth = best_ask_depth(&sc.svm, &sc.order_book.pubkey()); + + let taker_bid_id = second_probe_id + 1; + let instruction = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + OrderSide::Bid, + taker_bid_id, + PROBE_PRICE, + MIN_ORDER_SIZE, + &[(first_probe_id, sc.seller_market_user)], + ); + let fill = send_and_measure(&mut sc.svm, instruction, &sc.buyer); + let first_probe = order_pda(&sc.program_id, &sc.market, first_probe_id); + assert_eq!( + read_order_fill_and_status(&sc.svm, &first_probe).1, + ORDER_STATUS_FILLED + ); + + let instruction = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + second_probe_id, + ); + let cancel = send_and_measure(&mut sc.svm, instruction, &sc.seller); + + ProbeCosts { + depth, + insert, + fill, + cancel, + } +} + +#[test] +fn doubling_prices_build_the_deepest_path_prices_allow() { + // 63 chain asks plus two probes at price 1: the chain gives 63 inner + // nodes above price 1, and the second probe splits from the first on a + // sequence-number bit, adding one more. + let deep = run_probes_at_bottom_of_book(true); + assert_eq!(deep.depth, 64); + + let shallow = run_probes_at_bottom_of_book(false); + assert_eq!(shallow.depth, 1); +} + +#[test] +fn deepest_path_adds_little_compute_to_insert_fill_and_cancel() { + let deep = run_probes_at_bottom_of_book(true); + let shallow = run_probes_at_bottom_of_book(false); + + for (operation, deep_units, shallow_units) in [ + ("insert", deep.insert, shallow.insert), + ("fill", deep.fill, shallow.fill), + ("cancel", deep.cancel, shallow.cancel), + ] { + println!("{operation}: {shallow_units} CU on a shallow book, {deep_units} CU at depth 64"); + assert!( + deep_units - shallow_units < MAX_EXTRA_COMPUTE_UNITS_FROM_DEPTH, + "{operation} costs {deep_units} CU at depth 64 against {shallow_units} CU on a shallow book" + ); + assert!(deep_units < DEFAULT_INSTRUCTION_COMPUTE_UNITS); + } +} From e839010d83d651616e129f3b981fdb68aec77589 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 23 Sep 2026 02:35:33 +0000 Subject: [PATCH 2/2] finance/order-book: spell canceling the American way in the depth-test notes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RQ5RZgoQjVK36qSjqUSq6X --- CHANGELOG.md | 2 +- finance/order-book/anchor-v1/CHANGELOG.md | 2 +- finance/order-book/anchor-v1/README.md | 2 +- .../anchor-v1/programs/order-book/tests/test_order_book.rs | 2 +- finance/order-book/anchor/CHANGELOG.md | 2 +- finance/order-book/anchor/README.md | 2 +- .../anchor/programs/order-book/tests/test_order_book.rs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad4acc1..236e8a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ to the best ask by one level each, up to 64 levels from the price half of the `doubling_prices_build_the_deepest_path_prices_allow` builds that path and reads its depth from the account, and `deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that -inserting, filling, and cancelling at the bottom of it each cost less than 15,000 +inserting, filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book, and stay inside the default 200,000-unit instruction budget. The README no longer says the tree stays shallow whatever order keys arrive in, or that Phoenix uses the same structure diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index c98c2dd0..18b76d09 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -7,7 +7,7 @@ - `doubling_prices_build_the_deepest_path_prices_allow` and `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: asks at 63 doubling prices build a 64-level path to the best ask, and inserting, - filling, and cancelling at the bottom of it each cost less than 15,000 + filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book. ### Changed diff --git a/finance/order-book/anchor-v1/README.md b/finance/order-book/anchor-v1/README.md index 7ef54283..29b411d0 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -891,7 +891,7 @@ prices alone can lengthen a path by at most 64 levels, and no path can exceed 128. `doubling_prices_build_the_deepest_path_prices_allow` builds that 64-level path, and `deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that -inserting, filling, and cancelling at the bottom of it each cost less than +inserting, filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book, and stay inside the default 200,000-unit instruction budget. This implementation is ported from [Openbook v2](https://github.com/openbook-dex/openbook-v2); 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 360d27d2..3d9d53ef 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 @@ -2062,7 +2062,7 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { // cheaper asks and the path to the best ask becomes a chain. The tree key is // 128 bits with the price in the top 64, so prices alone can stretch a path // to at most 64 inner nodes, and no path can ever exceed 128. These tests -// build that chain and check that inserting, matching, and cancelling at the +// build that chain and check that inserting, matching, and canceling at the // bottom of it still costs little compared with a shallow book. // --------------------------------------------------------------------------- diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index 23caaded..8fdd4af3 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -7,7 +7,7 @@ - `doubling_prices_build_the_deepest_path_prices_allow` and `deepest_path_adds_little_compute_to_insert_fill_and_cancel`: asks at 63 doubling prices build a 64-level path to the best ask, and inserting, - filling, and cancelling at the bottom of it each cost less than 15,000 + filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book. ### Changed diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index 779dd788..e03cd21d 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -900,7 +900,7 @@ prices alone can lengthen a path by at most 64 levels, and no path can exceed 128. `doubling_prices_build_the_deepest_path_prices_allow` builds that 64-level path, and `deepest_path_adds_little_compute_to_insert_fill_and_cancel` checks that -inserting, filling, and cancelling at the bottom of it each cost less than +inserting, filling, and canceling at the bottom of it each cost less than 15,000 compute units more than on a shallow book, and stay inside the default 200,000-unit instruction budget. This implementation is ported from [Openbook v2](https://github.com/openbook-dex/openbook-v2); 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 553dee2d..4db4d97e 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 @@ -2149,7 +2149,7 @@ fn settle_funds_after_match_pays_out_both_unsettled_balances() { // cheaper asks and the path to the best ask becomes a chain. The tree key is // 128 bits with the price in the top 64, so prices alone can stretch a path // to at most 64 inner nodes, and no path can ever exceed 128. These tests -// build that chain and check that inserting, matching, and cancelling at the +// build that chain and check that inserting, matching, and canceling at the // bottom of it still costs little compared with a shallow book. // ---------------------------------------------------------------------------