Skip to content

Commit 5ac9d2d

Browse files
authored
Merge pull request #66 from quicknode/claude/order-book-skill-audit-U8lX3
fix(finance/order-book): decimal-agnostic pricing with base_lot_size + quote_lot_size
2 parents 907197a + ac8b9b1 commit 5ac9d2d

8 files changed

Lines changed: 204 additions & 79 deletions

File tree

finance/order-book/anchor/README.md

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ call `settle_funds` to pull their balances out.
7070
that can withdraw accumulated fees.
7171
- An **OrderBook** account — two stores: bids sorted highest-first,
7272
asks sorted lowest-first, each holding up to 1024 entries. Rather
73-
than a plain list of orders, each side uses a balanced tree for fast
74-
lookup — see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance).
73+
than a plain list of orders, each side uses a depth-bounded tree (a
74+
critbit trie) for fast lookup — see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance).
7575
Each entry stores enough to drive matching (price, quantity,
7676
`order_id`); the full `Order` PDA holds the authoritative state.
7777
- A **MarketUser** PDA — one per `(market, wallet)` pair. Tracks the
@@ -895,9 +895,10 @@ instead of 1 024.
895895
The specific data structure used here is a
896896
[critbit tree](https://cr.yp.to/critbit.html) (short for *critical-bit
897897
tree*) — a compact binary radix trie where each internal node splits on
898-
the first bit where two keys disagree. It has the same O(log n) bounds
899-
as other balanced trees but operates on fixed-width integer keys, so no
900-
rebalancing rotations are needed. This implementation is ported from
898+
the first bit where two keys disagree. Unlike a self-balancing BST it
899+
never rotates or recolours nodes; its depth is instead bounded by the
900+
*bit width of the key* rather than the number of orders, so it stays
901+
shallow no matter what order keys arrive in. This implementation is ported from
901902
[Openbook v2](https://github.com/openbook-dex/openbook-v2);
902903
[Phoenix](https://github.com/Ellipsis-Labs/phoenix-v1) uses the same
903904
approach. Both are production Solana CLOBs worth reading alongside this
@@ -1550,15 +1551,18 @@ Ordered by difficulty.
15501551
`place_order`, skip resting entries whose `expires_at` is past;
15511552
add a permissionless `sweep_expired` instruction.
15521553

1553-
### Why a balanced tree (critbit)?
1554+
### Why a depth-bounded tree (critbit)?
15541555

1555-
**Tree balancing must be guaranteed, not assumed.** A plain binary
1556+
**Worst-case depth must be bounded, not assumed.** A plain binary
15561557
search tree only keeps a roughly-balanced shape when its inputs arrive
15571558
in random order. In an order book an attacker chooses the inputs — the
15581559
prices of their orders — so nothing they choose can be allowed to
1559-
determine the tree's shape. A *balanced-by-construction* tree
1560-
(red-black, critbit, AVL, …) enforces a bounded shape via invariants
1561-
maintained on every insert and delete, regardless of input order.
1560+
inflate the tree's depth. Two families of structure defend against
1561+
this: *self-balancing* BSTs (red-black, AVL, …) that restore a bounded
1562+
height with rotations on every insert and delete, and *radix tries*
1563+
like critbit whose depth is capped by the key's bit width no matter
1564+
which keys are present. Both keep every operation cheap regardless of
1565+
input order; this example uses the second.
15621566

15631567
**Concrete attack on a plain BST.** An attacker posts orders at
15641568
monotonically increasing prices ($100, $101, $102, $103, …). Each new
@@ -1568,20 +1572,21 @@ degenerated into a linked list of length N. Lookups, inserts, and
15681572
matches all walk O(N) instead of O(log N).
15691573

15701574
**Why this matters on Solana specifically.** Solana transactions have
1571-
a ~1.4M compute-unit budget. If `place_order` walks an unbalanced book
1575+
a ~1.4M compute-unit budget. If `place_order` walks a degenerate book
15721576
and exceeds the CU limit mid-match, the transaction aborts and the
15731577
placer pays fees for nothing. Worse, *legitimate users' orders fail
1574-
because an adversary skewed the tree shape*. A balanced-by-construction
1575-
tree bounds every operation at O(log N) regardless of input, so the
1576-
attack is structurally impossible.
1577-
1578-
**Why critbit specifically.** Critbit (a binary radix trie keyed on
1579-
the price bits) is balanced-by-construction in a different way from a
1580-
red-black tree: tree depth is bounded by the *bit width of the sort
1581-
key* (128 bits here — price in the high 64, sequence number in the
1582-
low 64), not by insertion order. Inserts and deletes don't need
1583-
rotations or recolouring; the trie shape is a deterministic function
1584-
of which keys are present. This example uses the critbit slab from
1578+
because an adversary skewed the tree shape*. A depth-bounded tree keeps
1579+
every operation cheap regardless of input, so the attack is
1580+
structurally impossible.
1581+
1582+
**Why critbit specifically.** Critbit is a binary radix trie keyed on
1583+
the order's sort bits — *not* a self-balancing BST, so it never rotates
1584+
or recolours nodes. Its shape is a deterministic function of which keys
1585+
are present, and its depth can never exceed the *bit width of the sort
1586+
key* (128 bits here — price in the high 64, sequence number in the low
1587+
64), so it cannot degenerate into a long chain under any insert order.
1588+
An insert splits exactly one leaf and adds exactly one inner node; a
1589+
delete splices one out. This example uses the critbit slab from
15851590
Openbook v2 (`src/state/slab/`).
15861591

15871592
### Harder

finance/order-book/anchor/programs/order-book/src/errors.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ pub enum ErrorCode {
2323
#[msg("Price does not align with tick size")]
2424
InvalidTickSize,
2525

26+
#[msg("Base lot size must be greater than zero")]
27+
InvalidBaseLotSize,
28+
29+
#[msg("Quote lot size must be greater than zero")]
30+
InvalidQuoteLotSize,
31+
2632
#[msg("Quantity is below minimum order size")]
2733
BelowMinOrderSize,
2834

finance/order-book/anchor/programs/order-book/src/instructions/cancel_order.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,13 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
2727
let market_user = &mut context.accounts.market_user;
2828
match order.side {
2929
OrderSide::Bid => {
30-
// u128 intermediate: the lock was originally taken on a
31-
// u64 quote balance, so price * remaining must fit u64
32-
// — but the multiplication itself can transiently exceed
33-
// u64. Mirror the same pattern as place_order: widen,
34-
// multiply, narrow.
30+
// u128 intermediates mirror the bid-lock formula in place_order:
31+
// raw_quote = price × remaining × quote_lot_size
3532
let quote_amount: u64 = (order.price as u128)
3633
.checked_mul(remaining as u128)
3734
.ok_or(ErrorCode::NumericalOverflow)?
35+
.checked_mul(context.accounts.market.quote_lot_size as u128)
36+
.ok_or(ErrorCode::NumericalOverflow)?
3837
.try_into()
3938
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
4039
market_user.unsettled_quote = market_user
@@ -43,9 +42,14 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
4342
.ok_or(ErrorCode::NumericalOverflow)?;
4443
}
4544
OrderSide::Ask => {
45+
let base_amount: u64 = (remaining as u128)
46+
.checked_mul(context.accounts.market.base_lot_size as u128)
47+
.ok_or(ErrorCode::NumericalOverflow)?
48+
.try_into()
49+
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
4650
market_user.unsettled_base = market_user
4751
.unsettled_base
48-
.checked_add(remaining)
52+
.checked_add(base_amount)
4953
.ok_or(ErrorCode::NumericalOverflow)?;
5054
}
5155
}

finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ pub fn handle_initialize_market(
1212
context: Context<InitializeMarket>,
1313
fee_basis_points: u16,
1414
tick_size: u64,
15+
base_lot_size: u64,
16+
quote_lot_size: u64,
1517
min_order_size: u64,
1618
) -> Result<()> {
1719
require!(tick_size > 0, ErrorCode::InvalidTickSize);
20+
require!(base_lot_size > 0, ErrorCode::InvalidBaseLotSize);
21+
require!(quote_lot_size > 0, ErrorCode::InvalidQuoteLotSize);
1822
require!(min_order_size > 0, ErrorCode::BelowMinOrderSize);
1923
require!(
2024
fee_basis_points <= MAX_FEE_BASIS_POINTS,
@@ -31,6 +35,8 @@ pub fn handle_initialize_market(
3135
market.order_book = context.accounts.order_book.key();
3236
market.fee_basis_points = fee_basis_points;
3337
market.tick_size = tick_size;
38+
market.base_lot_size = base_lot_size;
39+
market.quote_lot_size = quote_lot_size;
3440
market.min_order_size = min_order_size;
3541
market.is_active = true;
3642
market.bump = context.bumps.market;

finance/order-book/anchor/programs/order-book/src/instructions/place_order.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub fn handle_place_order<'info>(
6767
(price as u128)
6868
.checked_mul(quantity as u128)
6969
.ok_or(ErrorCode::NumericalOverflow)?
70+
.checked_mul(market.quote_lot_size as u128)
71+
.ok_or(ErrorCode::NumericalOverflow)?
7072
.try_into()
7173
.map_err(|_| error!(ErrorCode::NumericalOverflow))?,
7274
context.accounts.quote_vault.to_account_info(),
@@ -75,7 +77,11 @@ pub fn handle_place_order<'info>(
7577
context.accounts.user_base_account.to_account_info(),
7678
context.accounts.base_mint.to_account_info(),
7779
context.accounts.base_mint.decimals,
78-
quantity,
80+
(quantity as u128)
81+
.checked_mul(market.base_lot_size as u128)
82+
.ok_or(ErrorCode::NumericalOverflow)?
83+
.try_into()
84+
.map_err(|_| error!(ErrorCode::NumericalOverflow))?,
7985
context.accounts.base_vault.to_account_info(),
8086
),
8187
};
@@ -190,6 +196,8 @@ pub fn handle_place_order<'info>(
190196
let gross_quote: u64 = (fill.fill_price as u128)
191197
.checked_mul(fill.fill_quantity as u128)
192198
.ok_or(ErrorCode::NumericalOverflow)?
199+
.checked_mul(market.quote_lot_size as u128)
200+
.ok_or(ErrorCode::NumericalOverflow)?
193201
.try_into()
194202
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
195203

@@ -218,8 +226,13 @@ pub fn handle_place_order<'info>(
218226
.checked_add(net_quote_to_maker)
219227
.ok_or(ErrorCode::NumericalOverflow)?;
220228

229+
let base_from_fill: u64 = (fill.fill_quantity as u128)
230+
.checked_mul(market.base_lot_size as u128)
231+
.ok_or(ErrorCode::NumericalOverflow)?
232+
.try_into()
233+
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
221234
taker_base_received = taker_base_received
222-
.checked_add(fill.fill_quantity)
235+
.checked_add(base_from_fill)
223236
.ok_or(ErrorCode::NumericalOverflow)?;
224237

225238
// Price improvement: taker locked (price * quantity) but
@@ -230,6 +243,8 @@ pub fn handle_place_order<'info>(
230243
let locked_for_this_fill: u64 = (price as u128)
231244
.checked_mul(fill.fill_quantity as u128)
232245
.ok_or(ErrorCode::NumericalOverflow)?
246+
.checked_mul(market.quote_lot_size as u128)
247+
.ok_or(ErrorCode::NumericalOverflow)?
233248
.try_into()
234249
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
235250
let rebate: u64 = locked_for_this_fill
@@ -241,9 +256,14 @@ pub fn handle_place_order<'info>(
241256
}
242257
// Taker Ask, resting Bid. Taker gives base, gets quote.
243258
OrderSide::Ask => {
259+
let base_from_fill: u64 = (fill.fill_quantity as u128)
260+
.checked_mul(market.base_lot_size as u128)
261+
.ok_or(ErrorCode::NumericalOverflow)?
262+
.try_into()
263+
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
244264
maker_market_user.unsettled_base = maker_market_user
245265
.unsettled_base
246-
.checked_add(fill.fill_quantity)
266+
.checked_add(base_from_fill)
247267
.ok_or(ErrorCode::NumericalOverflow)?;
248268

249269
let net_quote_to_taker = gross_quote

finance/order-book/anchor/programs/order-book/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ pub mod order_book {
1919
context: Context<InitializeMarket>,
2020
fee_basis_points: u16,
2121
tick_size: u64,
22+
base_lot_size: u64,
23+
quote_lot_size: u64,
2224
min_order_size: u64,
2325
) -> Result<()> {
2426
instructions::initialize_market::handle_initialize_market(
2527
context,
2628
fee_basis_points,
2729
tick_size,
30+
base_lot_size,
31+
quote_lot_size,
2832
min_order_size,
2933
)
3034
}

finance/order-book/anchor/programs/order-book/src/state/market.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,35 @@ pub struct Market {
3131

3232
pub tick_size: u64,
3333

34+
// Two-lot model (mirrors Serum/Openbook): both sides of the book are
35+
// denominated in their respective lots rather than raw token units.
36+
// This makes `price` and `quantity` human-readable regardless of the
37+
// individual mints' decimal counts.
38+
//
39+
// raw_base = quantity × base_lot_size
40+
// raw_quote = quantity × price × quote_lot_size
41+
//
42+
// Choose:
43+
// base_lot_size = 10^max(d_base − d_quote, 0)
44+
// quote_lot_size = 10^max(d_quote − d_base, 0)
45+
//
46+
// so that exactly one of the two is > 1 (or both are 1 when d_base == d_quote).
47+
// With those values `price` equals the human-readable quote/base rate and
48+
// `tick_size = 1` is a single atomic increment.
49+
//
50+
// Examples:
51+
// NVDAx (8 dec) / USDC (6 dec): base_lot_size=100, quote_lot_size=1
52+
// price=130, qty=1 lot → 130 × 1 × 1 = 130 raw USDC per 100 raw NVDAx
53+
// = $130.00 per NVDAx share ✓
54+
//
55+
// WBTC (8 dec) / HD-USDC (18 dec): base_lot_size=1, quote_lot_size=10^10
56+
// price=60_000, qty=1 satoshi-lot → 60_000 × 1 × 10^10 = 6×10^14 raw HD-USDC
57+
// = $60,000 per BTC ✓
58+
pub base_lot_size: u64,
59+
60+
// Raw quote-token units per quote lot. See base_lot_size comment above.
61+
pub quote_lot_size: u64,
62+
3463
pub min_order_size: u64,
3564

3665
pub is_active: bool,

0 commit comments

Comments
 (0)