diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e9e880..236e8a17 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 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 +(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..18b76d09 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 canceling 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..29b411d0 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 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); -[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..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 @@ -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 canceling 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..8fdd4af3 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 canceling 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..e03cd21d 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 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); -[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..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 @@ -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 canceling 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); + } +}