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

### Why a balanced tree (critbit)?
### Why a depth-bounded tree (critbit)?

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

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

**Why this matters on Solana specifically.** Solana transactions have
a ~1.4M compute-unit budget. If `place_order` walks an unbalanced book
a ~1.4M compute-unit budget. If `place_order` walks a degenerate book
and exceeds the CU limit mid-match, the transaction aborts and the
placer pays fees for nothing. Worse, *legitimate users' orders fail
because an adversary skewed the tree shape*. A balanced-by-construction
tree bounds every operation at O(log N) regardless of input, so the
attack is structurally impossible.

**Why critbit specifically.** Critbit (a binary radix trie keyed on
the price bits) is balanced-by-construction in a different way from a
red-black tree: tree depth is bounded by the *bit width of the sort
key* (128 bits here — price in the high 64, sequence number in the
low 64), not by insertion order. Inserts and deletes don't need
rotations or recolouring; the trie shape is a deterministic function
of which keys are present. This example uses the critbit slab from
because an adversary skewed the tree shape*. A depth-bounded tree keeps
every operation cheap regardless of input, so the attack is
structurally impossible.

**Why critbit specifically.** Critbit is a binary radix trie keyed on
the order's sort bits — *not* a self-balancing BST, so it never rotates
or recolours nodes. Its shape is a deterministic function of which keys
are present, and its depth can never exceed the *bit width of the sort
key* (128 bits here — price in the high 64, sequence number in the low
64), so it cannot degenerate into a long chain under any insert order.
An insert splits exactly one leaf and adds exactly one inner node; a
delete splices one out. This example uses the critbit slab from
Openbook v2 (`src/state/slab/`).

### Harder
Expand Down
6 changes: 6 additions & 0 deletions finance/order-book/anchor/programs/order-book/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ pub enum ErrorCode {
#[msg("Price does not align with tick size")]
InvalidTickSize,

#[msg("Base lot size must be greater than zero")]
InvalidBaseLotSize,

#[msg("Quote lot size must be greater than zero")]
InvalidQuoteLotSize,

#[msg("Quantity is below minimum order size")]
BelowMinOrderSize,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
let market_user = &mut context.accounts.market_user;
match order.side {
OrderSide::Bid => {
// u128 intermediate: the lock was originally taken on a
// u64 quote balance, so price * remaining must fit u64
// — but the multiplication itself can transiently exceed
// u64. Mirror the same pattern as place_order: widen,
// multiply, narrow.
// u128 intermediates mirror the bid-lock formula in place_order:
// raw_quote = price × remaining × quote_lot_size
let quote_amount: u64 = (order.price as u128)
.checked_mul(remaining as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.checked_mul(context.accounts.market.quote_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
market_user.unsettled_quote = market_user
Expand All @@ -43,9 +42,14 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
.ok_or(ErrorCode::NumericalOverflow)?;
}
OrderSide::Ask => {
let base_amount: u64 = (remaining as u128)
.checked_mul(context.accounts.market.base_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
market_user.unsettled_base = market_user
.unsettled_base
.checked_add(remaining)
.checked_add(base_amount)
.ok_or(ErrorCode::NumericalOverflow)?;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ pub fn handle_initialize_market(
context: Context<InitializeMarket>,
fee_basis_points: u16,
tick_size: u64,
base_lot_size: u64,
quote_lot_size: u64,
min_order_size: u64,
) -> Result<()> {
require!(tick_size > 0, ErrorCode::InvalidTickSize);
require!(base_lot_size > 0, ErrorCode::InvalidBaseLotSize);
require!(quote_lot_size > 0, ErrorCode::InvalidQuoteLotSize);
require!(min_order_size > 0, ErrorCode::BelowMinOrderSize);
require!(
fee_basis_points <= MAX_FEE_BASIS_POINTS,
Expand All @@ -31,6 +35,8 @@ pub fn handle_initialize_market(
market.order_book = context.accounts.order_book.key();
market.fee_basis_points = fee_basis_points;
market.tick_size = tick_size;
market.base_lot_size = base_lot_size;
market.quote_lot_size = quote_lot_size;
market.min_order_size = min_order_size;
market.is_active = true;
market.bump = context.bumps.market;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ pub fn handle_place_order<'info>(
(price as u128)
.checked_mul(quantity as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.checked_mul(market.quote_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?,
context.accounts.quote_vault.to_account_info(),
Expand All @@ -75,7 +77,11 @@ pub fn handle_place_order<'info>(
context.accounts.user_base_account.to_account_info(),
context.accounts.base_mint.to_account_info(),
context.accounts.base_mint.decimals,
quantity,
(quantity as u128)
.checked_mul(market.base_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?,
context.accounts.base_vault.to_account_info(),
),
};
Expand Down Expand Up @@ -190,6 +196,8 @@ pub fn handle_place_order<'info>(
let gross_quote: u64 = (fill.fill_price as u128)
.checked_mul(fill.fill_quantity as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.checked_mul(market.quote_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;

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

let base_from_fill: u64 = (fill.fill_quantity as u128)
.checked_mul(market.base_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
taker_base_received = taker_base_received
.checked_add(fill.fill_quantity)
.checked_add(base_from_fill)
.ok_or(ErrorCode::NumericalOverflow)?;

// Price improvement: taker locked (price * quantity) but
Expand All @@ -230,6 +243,8 @@ pub fn handle_place_order<'info>(
let locked_for_this_fill: u64 = (price as u128)
.checked_mul(fill.fill_quantity as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.checked_mul(market.quote_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
let rebate: u64 = locked_for_this_fill
Expand All @@ -241,9 +256,14 @@ pub fn handle_place_order<'info>(
}
// Taker Ask, resting Bid. Taker gives base, gets quote.
OrderSide::Ask => {
let base_from_fill: u64 = (fill.fill_quantity as u128)
.checked_mul(market.base_lot_size as u128)
.ok_or(ErrorCode::NumericalOverflow)?
.try_into()
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
maker_market_user.unsettled_base = maker_market_user
.unsettled_base
.checked_add(fill.fill_quantity)
.checked_add(base_from_fill)
.ok_or(ErrorCode::NumericalOverflow)?;

let net_quote_to_taker = gross_quote
Expand Down
4 changes: 4 additions & 0 deletions finance/order-book/anchor/programs/order-book/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ pub mod order_book {
context: Context<InitializeMarket>,
fee_basis_points: u16,
tick_size: u64,
base_lot_size: u64,
quote_lot_size: u64,
min_order_size: u64,
) -> Result<()> {
instructions::initialize_market::handle_initialize_market(
context,
fee_basis_points,
tick_size,
base_lot_size,
quote_lot_size,
min_order_size,
)
}
Expand Down
29 changes: 29 additions & 0 deletions finance/order-book/anchor/programs/order-book/src/state/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ pub struct Market {

pub tick_size: u64,

// Two-lot model (mirrors Serum/Openbook): both sides of the book are
// denominated in their respective lots rather than raw token units.
// This makes `price` and `quantity` human-readable regardless of the
// individual mints' decimal counts.
//
// raw_base = quantity × base_lot_size
// raw_quote = quantity × price × quote_lot_size
//
// Choose:
// base_lot_size = 10^max(d_base − d_quote, 0)
// quote_lot_size = 10^max(d_quote − d_base, 0)
//
// so that exactly one of the two is > 1 (or both are 1 when d_base == d_quote).
// With those values `price` equals the human-readable quote/base rate and
// `tick_size = 1` is a single atomic increment.
//
// Examples:
// NVDAx (8 dec) / USDC (6 dec): base_lot_size=100, quote_lot_size=1
// price=130, qty=1 lot → 130 × 1 × 1 = 130 raw USDC per 100 raw NVDAx
// = $130.00 per NVDAx share ✓
//
// WBTC (8 dec) / HD-USDC (18 dec): base_lot_size=1, quote_lot_size=10^10
// price=60_000, qty=1 satoshi-lot → 60_000 × 1 × 10^10 = 6×10^14 raw HD-USDC
// = $60,000 per BTC ✓
pub base_lot_size: u64,

// Raw quote-token units per quote lot. See base_lot_size comment above.
pub quote_lot_size: u64,

pub min_order_size: u64,

pub is_active: bool,
Expand Down
Loading
Loading