Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions finance/order-book/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 27 additions & 8 deletions finance/order-book/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions finance/order-book/anchor-v1/programs/order-book/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"))'] }
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> = 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<u64> = 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);
}
}
16 changes: 16 additions & 0 deletions finance/order-book/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading