Skip to content

Commit d084fa3

Browse files
committed
feat(defi/order-book): add order book (CLOB) example with critbit slab matching engine
Central Limit Order Book (CLOB) Anchor program for a single base/quote token pair. Matches resting limit orders in price-time priority using a critbit binary radix trie ported from Openbook v2, stored as a ~180 KB zero-copy account. Instruction handlers: - initialize_market — create Market PDA, three vaults, and OrderBook account - create_market_user — per-(user, market) PDA tracking open orders and unsettled balances - place_order — lock funds, match against caller-supplied maker pairs, rest remainder - cancel_order — credit unsettled balance, remove from book - settle_funds — transfer unsettled balances from vaults to user ATAs - withdraw_fees — authority-gated drain of the fee vault Key design points: - Funds lock into program-owned vaults on place_order; matching only updates unsettled_* counters; settle_funds is the single payout step - Caller supplies maker pairs as remaining_accounts (Openbook v2 style); program enforces price-time order and ownership on what is passed - OrderBook uses a critbit slab (balanced-by-construction binary radix trie) for O(log n) insert/lookup/delete, preventing degenerate-input attacks that would exhaust the compute budget - Zero-copy AccountLoader for the ~180 KB OrderBook; two 1024-slot slabs back to back, well under Solana's per-account ceiling - 23 LiteSVM integration tests covering matching, partial fills, price-time priority, cancel/settle round trips, and fee accounting https://claude.ai/code/session_01G6iaAjzg8aoFwe8ZWWG9VR
1 parent 5540080 commit d084fa3

28 files changed

Lines changed: 6099 additions & 1 deletion

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ node_modules/
2222

2323
/target
2424
deploy
25-
.claude
25+
.claude/*
26+
# Exception: skills are shared team resources
27+
!.claude/skills/
28+
!.claude/skills/**

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ Constant product AMM (x·y=k) — create liquidity pools, deposit and withdraw l
2323

2424
[⚓ Anchor](./tokens/token-swap/anchor) [💫 Quasar](./tokens/token-swap/quasar)
2525

26+
### Central Limit Order Book
27+
28+
Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders cross against the opposing side using price-time priority. Fees route to a dedicated fee vault, maker/taker proceeds land in unsettled balances, and funds are withdrawn via `settle_funds`. A minimal teaching example of the mechanics behind Openbook and Phoenix.
29+
30+
[⚓ Anchor](./defi/order-book/anchor)
31+
2632
### Escrow
2733

2834
Peer-to-peer OTC trade — one user deposits token A and specifies how much token B they want. A counterparty fulfills the offer and both sides receive their tokens atomically.

defi/order-book/anchor/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.anchor
2+
.DS_Store
3+
target
4+
**/*.rs.bk
5+
node_modules
6+
test-ledger
7+
.yarn

defi/order-book/anchor/Anchor.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[toolchain]
2+
# Pin Solana to the version used across the repo's Anchor 1.0 examples so the
3+
# bundled test validator and BPF toolchain stay in lock-step.
4+
solana_version = "3.1.8"
5+
6+
[features]
7+
resolution = true
8+
skip-lint = false
9+
10+
[programs.localnet]
11+
order_book = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"
12+
13+
[provider]
14+
cluster = "Localnet"
15+
wallet = "~/.config/solana/id.json"
16+
17+
[scripts]
18+
# LiteSVM Rust tests live under `programs/order-book/tests/` and include the built
19+
# `.so` via `include_bytes!`, so a fresh `anchor build` must run first.
20+
test = "cargo test"

defi/order-book/anchor/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[workspace]
2+
members = ["programs/*"]
3+
resolver = "2"
4+
5+
[profile.release]
6+
overflow-checks = true
7+
lto = "fat"
8+
codegen-units = 1
9+
10+
[profile.release.build-override]
11+
opt-level = 3
12+
incremental = false
13+
codegen-units = 1

defi/order-book/anchor/README.md

Lines changed: 1639 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[package]
2+
name = "order_book"
3+
version = "0.1.0"
4+
description = "Order book example (CLOB) on Solana"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "order_book"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
anchor-debug = []
19+
custom-heap = []
20+
custom-panic = []
21+
22+
[dependencies]
23+
anchor-lang = "1.0.0"
24+
anchor-spl = "1.0.0"
25+
# Used by the ported Openbook slab — `bytemuck::Pod` / `Zeroable` on every node
26+
# variant + `min_const_generics` so `[AnyNode; 1024]` can derive Pod without
27+
# hitting bytemuck's default-32 array cap. `static_assertions` keeps the slab
28+
# layout asserts (node size, alignment) compile-time, matching upstream.
29+
bytemuck = { version = "1.18", features = ["derive", "min_const_generics"] }
30+
static_assertions = "1.1"
31+
32+
[dev-dependencies]
33+
# Match the test stack used by tokens/escrow, defi/asset-leasing, and the
34+
# other LiteSVM-based Anchor examples so contributors can move between them
35+
# without version drift.
36+
litesvm = "0.11.0"
37+
solana-signer = "3.0.0"
38+
solana-keypair = "3.0.1"
39+
solana-kite = "0.3.0"
40+
41+
[lints.rust]
42+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum ErrorCode {
5+
#[msg("Invalid price provided")]
6+
InvalidPrice,
7+
8+
#[msg("Order not found")]
9+
OrderNotFound,
10+
11+
#[msg("Market is currently paused")]
12+
MarketPaused,
13+
14+
#[msg("Unauthorized action")]
15+
Unauthorized,
16+
17+
#[msg("Order book is full")]
18+
OrderBookFull,
19+
20+
#[msg("MarketUser has too many open orders")]
21+
TooManyOpenOrders,
22+
23+
#[msg("Price does not align with tick size")]
24+
InvalidTickSize,
25+
26+
#[msg("Quantity is below minimum order size")]
27+
BelowMinOrderSize,
28+
29+
#[msg("Order is not cancellable in current status")]
30+
OrderNotCancellable,
31+
32+
#[msg("Numerical overflow occurred")]
33+
NumericalOverflow,
34+
35+
#[msg("Fee basis points out of range")]
36+
InvalidFeeBasisPoints,
37+
38+
#[msg("Fee vault does not match the market's fee vault")]
39+
InvalidFeeVault,
40+
41+
#[msg("Base vault does not match the market's base vault")]
42+
InvalidBaseVault,
43+
44+
#[msg("Quote vault does not match the market's quote vault")]
45+
InvalidQuoteVault,
46+
47+
#[msg("Base mint does not match the market's base mint")]
48+
InvalidBaseMint,
49+
50+
#[msg("Quote mint does not match the market's quote mint")]
51+
InvalidQuoteMint,
52+
53+
#[msg("Maker account provided does not correspond to a resting order on the book")]
54+
MakerAccountMismatch,
55+
56+
#[msg("Not enough maker accounts supplied to cross the incoming order")]
57+
MissingMakerAccounts,
58+
59+
#[msg("Maker order and maker MarketUser owner mismatch")]
60+
MakerOwnerMismatch,
61+
62+
#[msg("Only the market authority can withdraw fees")]
63+
NotMarketAuthority,
64+
65+
#[msg("Order book account does not match the market's order book")]
66+
InvalidOrderBook,
67+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::errors::ErrorCode;
4+
use crate::state::{
5+
remaining_quantity, remove_open_order, Market, Order, OrderBook, OrderSide, OrderStatus,
6+
MarketUser, ORDER_SEED, MARKET_USER_SEED,
7+
};
8+
9+
pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
10+
let order = &mut context.accounts.order;
11+
12+
require!(
13+
order.owner == context.accounts.owner.key(),
14+
ErrorCode::Unauthorized
15+
);
16+
17+
require!(
18+
order.status == OrderStatus::Open || order.status == OrderStatus::PartiallyFilled,
19+
ErrorCode::OrderNotCancellable
20+
);
21+
22+
// Funds the order had locked in the vault are now owed back to the
23+
// owner. Credit the appropriate unsettled balance; settle_funds moves
24+
// those funds from the vault to the owner's token account.
25+
let remaining = remaining_quantity(order);
26+
if remaining > 0 {
27+
let market_user = &mut context.accounts.market_user;
28+
match order.side {
29+
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.
35+
let quote_amount: u64 = (order.price as u128)
36+
.checked_mul(remaining as u128)
37+
.ok_or(ErrorCode::NumericalOverflow)?
38+
.try_into()
39+
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
40+
market_user.unsettled_quote = market_user
41+
.unsettled_quote
42+
.checked_add(quote_amount)
43+
.ok_or(ErrorCode::NumericalOverflow)?;
44+
}
45+
OrderSide::Ask => {
46+
market_user.unsettled_base = market_user
47+
.unsettled_base
48+
.checked_add(remaining)
49+
.ok_or(ErrorCode::NumericalOverflow)?;
50+
}
51+
}
52+
}
53+
54+
// Remove the leaf from the slab. The current cancel API doesn't tell us
55+
// which side the order is on without reading the Order PDA — which we
56+
// already have, so use it.
57+
let mut order_book = context.accounts.order_book.load_mut()?;
58+
let removed = order_book.remove_from(order.side, order.order_id).is_some();
59+
require!(removed, ErrorCode::OrderNotFound);
60+
drop(order_book);
61+
62+
let market_user = &mut context.accounts.market_user;
63+
remove_open_order(market_user, order.order_id);
64+
65+
order.status = OrderStatus::Cancelled;
66+
67+
Ok(())
68+
}
69+
70+
#[derive(Accounts)]
71+
pub struct CancelOrder<'info> {
72+
#[account(has_one = order_book @ ErrorCode::InvalidOrderBook)]
73+
pub market: Account<'info, Market>,
74+
75+
// Not a PDA (see initialize_market.rs); bound to `market` via has_one.
76+
#[account(mut)]
77+
pub order_book: AccountLoader<'info, OrderBook>,
78+
79+
#[account(
80+
mut,
81+
seeds = [ORDER_SEED, market.key().as_ref(), order.order_id.to_le_bytes().as_ref()],
82+
bump = order.bump
83+
)]
84+
pub order: Account<'info, Order>,
85+
86+
#[account(
87+
mut,
88+
seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()],
89+
bump = market_user.bump
90+
)]
91+
pub market_user: Account<'info, MarketUser>,
92+
93+
pub owner: Signer<'info>,
94+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::state::{Market, MarketUser, MARKET_USER_SEED};
4+
5+
pub fn handle_create_market_user(context: Context<CreateMarketUser>) -> Result<()> {
6+
let market_user = &mut context.accounts.market_user;
7+
market_user.market = context.accounts.market.key();
8+
market_user.owner = context.accounts.owner.key();
9+
market_user.unsettled_base = 0;
10+
market_user.unsettled_quote = 0;
11+
market_user.open_orders = Vec::new();
12+
market_user.bump = context.bumps.market_user;
13+
14+
Ok(())
15+
}
16+
17+
#[derive(Accounts)]
18+
pub struct CreateMarketUser<'info> {
19+
#[account(
20+
init,
21+
payer = owner,
22+
space = MarketUser::DISCRIMINATOR.len() + MarketUser::INIT_SPACE,
23+
seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()],
24+
bump
25+
)]
26+
pub market_user: Account<'info, MarketUser>,
27+
28+
pub market: Account<'info, Market>,
29+
30+
#[account(mut)]
31+
pub owner: Signer<'info>,
32+
33+
pub system_program: Program<'info, System>,
34+
}

0 commit comments

Comments
 (0)