Skip to content

Commit 2b788a4

Browse files
author
Edward (Mike's bot)
committed
refactor(clob): port Openbook v2 critbit slab; rename UserAccount → MarketUser
Replaces the per-side Vec<OrderEntry> order-book backing store with a critbit (binary radix trie) slab ported from openbook-dex/openbook-v2. The slab gives the order book O(log N) inserts/deletes/lookups whose worst case is bounded by the price-key bit width (128 bits), not by insertion order, so an adversary picking monotonically-increasing prices can no longer degenerate the book into a linked list. Why critbit specifically (not red-black): the upstream Openbook v2 slab already uses critbit, both shapes give the bounds we need, and the critbit invariants are simpler to port (no recolouring, no rotations). See the README's new "Why a balanced tree (critbit)?" section for the DoS framing. Companion rename: UserAccount → MarketUser The per-(user, market) state struct was previously named UserAccount, which falsely suggested a per-user record. Renamed throughout so the name matches what it scopes to: - struct UserAccount → struct MarketUser - Accounts context CreateUserAccount → CreateMarketUser - PDA seed b"user" → b"market_user"; constant USER_ACCOUNT_SEED → MARKET_USER_SEED (breaking change — pre-merge, no live PDAs to migrate) - File state/user_account.rs → state/market_user.rs - File instructions/create_user_account.rs → instructions/create_market_user.rs - Handler fn create_user_account → create_market_user - Variable/field names like buyer_user_account, maker_user_account, taker_user_account → buyer_market_user, maker_market_user, etc. - README, tests, error messages all updated. User-owned ATA names (user_base_account, user_quote_account) are NOT renamed — they're token accounts owned by the human user, not program state, so the 'user' prefix is correct there. The 'user' signer arg also stays as 'user'. Matches the standard pattern: Openbook v2 calls this OpenOrdersAccount, Phoenix calls it Trader, Serum called it OpenOrders. We use MarketUser to make the (user, market) scope explicit in the name. Documentation: new README section §2 'Why per-(user, market) and not per-user?' (unsettled balances, open-order indexing, lock-contention isolation). New README section §8 'Why a balanced tree (critbit)?' with the monotonic-attacker example and Solana CU/DoS framing. Terminology rule: new TERMINOLOGY.md documents the project-wide rule to disambiguate overloaded terms in README and code comments — balance (token vs tree), order vs ordering, key, node, position, settle — so future contributors don't reintroduce the ambiguity this PR is removing. Tests: 24/24 pass under cargo test (LiteSVM).
1 parent df6b37f commit 2b788a4

22 files changed

Lines changed: 1767 additions & 540 deletions

defi/clob/anchor/README.md

Lines changed: 132 additions & 56 deletions
Large diffs are not rendered by default.

defi/clob/anchor/TERMINOLOGY.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Terminology Rules — CLOB
2+
3+
Project-wide rules for the README and code comments in this crate. Applies
4+
throughout, not just one section. Audit for these before opening / merging
5+
any PR that touches docs or comments.
6+
7+
## General rule
8+
9+
> If a word has two meanings within ~1 paragraph of context, use the
10+
> explicit phrase even if it's a few chars longer.
11+
12+
A wrong or ambiguous statement is worse than no statement.
13+
14+
## Overloaded terms
15+
16+
### "balance" — most important
17+
18+
`balance` is ambiguous in a CLOB context: it can mean token balance,
19+
account balance, **or** the tree-balancing property (the critbit
20+
slab is balanced-by-construction). Pick one of:
21+
22+
- ✅ "tree balancing"
23+
- ✅ "balanced tree shape"
24+
- ✅ "keeps the tree shape balanced"
25+
- ❌ "the critbit tree maintains balance"
26+
27+
For money, use **"token balance"** or **"account balance"** explicitly
28+
whenever it's near a tree discussion.
29+
30+
### "book"
31+
32+
Usually fine in context ("order book"). Be careful in mixed sentences —
33+
qualify when ambiguity is possible.
34+
35+
### "order" vs "ordering"
36+
37+
`order` = trading order. `ordering` = sort order. When a sentence touches
38+
both:
39+
40+
- ✅ "price-sorted", "sorted by price"
41+
- ❌ "in price order"
42+
43+
### "settle"
44+
45+
Be specific:
46+
47+
-`settle_funds` instruction
48+
- ✅ "transaction settlement"
49+
- ❌ bare "settle" / "settlement" when the meaning isn't obvious
50+
51+
### "key"
52+
53+
Never bare `key`. Use:
54+
55+
- "address" (a Solana public key used as an address)
56+
- "public key" (a cryptographic key)
57+
- "sort key" (the value the tree is sorted on)
58+
59+
### "node"
60+
61+
Qualify:
62+
63+
- "validator node" (Solana cluster)
64+
- "tree node" / "slab node" (data structure)
65+
66+
### "position"
67+
68+
Qualify:
69+
70+
- "trading position" (financial)
71+
- "slot position" / "array index" (slab / array)
72+
73+
## Reviewer checklist
74+
75+
Before approving a doc/comment PR:
76+
77+
- [ ] No bare "balance" when the critbit tree is in scope.
78+
- [ ] No bare "key", "node", "position", "settle" in ambiguous contexts.
79+
- [ ] "Order" / "ordering" disambiguated when both senses appear nearby.

defi/clob/anchor/programs/clob/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ custom-panic = []
2222
[dependencies]
2323
anchor-lang = "1.0.0"
2424
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"
2531

2632
[dev-dependencies]
2733
# Match the test stack used by tokens/escrow, defi/asset-leasing, and the

defi/clob/anchor/programs/clob/src/errors.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub enum ErrorCode {
2020
#[msg("Order book is full")]
2121
OrderBookFull,
2222

23-
#[msg("User account has too many open orders")]
23+
#[msg("MarketUser has too many open orders")]
2424
TooManyOpenOrders,
2525

2626
#[msg("Price does not align with tick size")]
@@ -59,9 +59,12 @@ pub enum ErrorCode {
5959
#[msg("Not enough maker accounts supplied to cross the incoming order")]
6060
MissingMakerAccounts,
6161

62-
#[msg("Maker order and maker user account owner mismatch")]
62+
#[msg("Maker order and maker MarketUser owner mismatch")]
6363
MakerOwnerMismatch,
6464

6565
#[msg("Only the market authority can withdraw fees")]
6666
NotMarketAuthority,
67+
68+
#[msg("Order book account does not match the market's order book")]
69+
InvalidOrderBook,
6770
}

defi/clob/anchor/programs/clob/src/instructions/cancel_order.rs

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use anchor_lang::prelude::*;
22

33
use crate::errors::ErrorCode;
44
use crate::state::{
5-
remaining_quantity, remove_open_order, remove_order, Market, Order, OrderBook, OrderSide,
6-
OrderStatus, UserAccount, ORDER_BOOK_SEED, ORDER_SEED, USER_ACCOUNT_SEED,
5+
remaining_quantity, remove_open_order, Market, Order, OrderBook, OrderSide, OrderStatus,
6+
MarketUser, ORDER_SEED, MARKET_USER_SEED,
77
};
88

99
pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
@@ -24,33 +24,37 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
2424
// those funds from the vault to the owner's token account.
2525
let remaining = remaining_quantity(order);
2626
if remaining > 0 {
27-
let user_account = &mut context.accounts.user_account;
27+
let market_user = &mut context.accounts.market_user;
2828
match order.side {
2929
OrderSide::Bid => {
3030
let quote_amount = order
3131
.price
3232
.checked_mul(remaining)
3333
.ok_or(ErrorCode::NumericalOverflow)?;
34-
user_account.unsettled_quote = user_account
34+
market_user.unsettled_quote = market_user
3535
.unsettled_quote
3636
.checked_add(quote_amount)
3737
.ok_or(ErrorCode::NumericalOverflow)?;
3838
}
3939
OrderSide::Ask => {
40-
user_account.unsettled_base = user_account
40+
market_user.unsettled_base = market_user
4141
.unsettled_base
4242
.checked_add(remaining)
4343
.ok_or(ErrorCode::NumericalOverflow)?;
4444
}
4545
}
4646
}
4747

48-
let order_book = &mut context.accounts.order_book;
49-
let removed = remove_order(order_book, order.order_id);
48+
// Remove the leaf from the slab. The current cancel API doesn't tell us
49+
// which side the order is on without reading the Order PDA — which we
50+
// already have, so use it.
51+
let mut order_book = context.accounts.order_book.load_mut()?;
52+
let removed = order_book.remove_from(order.side, order.order_id).is_some();
5053
require!(removed, ErrorCode::OrderNotFound);
54+
drop(order_book);
5155

52-
let user_account = &mut context.accounts.user_account;
53-
remove_open_order(user_account, order.order_id);
56+
let market_user = &mut context.accounts.market_user;
57+
remove_open_order(market_user, order.order_id);
5458

5559
order.status = OrderStatus::Cancelled;
5660

@@ -59,14 +63,12 @@ pub fn handle_cancel_order(context: Context<CancelOrder>) -> Result<()> {
5963

6064
#[derive(Accounts)]
6165
pub struct CancelOrder<'info> {
66+
#[account(has_one = order_book @ ErrorCode::InvalidOrderBook)]
6267
pub market: Account<'info, Market>,
6368

64-
#[account(
65-
mut,
66-
seeds = [ORDER_BOOK_SEED, market.key().as_ref()],
67-
bump = order_book.bump
68-
)]
69-
pub order_book: Account<'info, OrderBook>,
69+
// Not a PDA (see initialize_market.rs); bound to `market` via has_one.
70+
#[account(mut)]
71+
pub order_book: AccountLoader<'info, OrderBook>,
7072

7173
#[account(
7274
mut,
@@ -77,10 +79,10 @@ pub struct CancelOrder<'info> {
7779

7880
#[account(
7981
mut,
80-
seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()],
81-
bump = user_account.bump
82+
seeds = [MARKET_USER_SEED, market.key().as_ref(), owner.key().as_ref()],
83+
bump = market_user.bump
8284
)]
83-
pub user_account: Account<'info, UserAccount>,
85+
pub market_user: Account<'info, MarketUser>,
8486

8587
pub owner: Signer<'info>,
8688
}
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+
}

defi/clob/anchor/programs/clob/src/instructions/create_user_account.rs

Lines changed: 0 additions & 34 deletions
This file was deleted.

defi/clob/anchor/programs/clob/src/instructions/initialize_market.rs

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use anchor_lang::prelude::*;
22
use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface};
33

44
use crate::errors::ErrorCode;
5-
use crate::state::{Market, OrderBook, MARKET_SEED, ORDER_BOOK_SEED};
5+
use crate::state::{Market, OrderBook, MARKET_SEED};
66

77
// Basis points are hundredths of a percent; 10000 bps == 100%. Fees above 100%
88
// would be nonsensical, so we cap here.
@@ -35,13 +35,12 @@ pub fn handle_initialize_market(
3535
market.is_active = true;
3636
market.bump = context.bumps.market;
3737

38-
let order_book = &mut context.accounts.order_book;
39-
order_book.market = context.accounts.market.key();
40-
order_book.bids = Vec::new();
41-
order_book.asks = Vec::new();
42-
// Start at 1 so order_id == 0 can stand for "no order" in clients if needed.
43-
order_book.next_order_id = 1;
44-
order_book.bump = context.bumps.order_book;
38+
// Zero-copy account: initialize the slab in place. `load_init` is the
39+
// first-write path — every subsequent handler uses `load` / `load_mut`.
40+
// The order book is not a PDA (see the comment on the `order_book`
41+
// account below), so `bump` is unused and stored as 0.
42+
let mut order_book = context.accounts.order_book.load_init()?;
43+
order_book.initialize(context.accounts.market.key(), 0);
4544

4645
Ok(())
4746
}
@@ -57,14 +56,24 @@ pub struct InitializeMarket<'info> {
5756
)]
5857
pub market: Account<'info, Market>,
5958

60-
#[account(
61-
init,
62-
payer = authority,
63-
space = OrderBook::DISCRIMINATOR.len() + OrderBook::INIT_SPACE,
64-
seeds = [ORDER_BOOK_SEED, market.key().as_ref()],
65-
bump
66-
)]
67-
pub order_book: Account<'info, OrderBook>,
59+
// The order book is a zero-copy account (~180 KB: two 1024-slot critbit
60+
// slabs back to back). Solana's BPF runtime caps inner-CPI account
61+
// allocations at 10 KB, so we can't use Anchor's `init` here — the
62+
// client must call system_program::create_account directly before this
63+
// instruction, sizing the account to ORDER_BOOK_ACCOUNT_SIZE, owned by
64+
// this program, and zero-initialized.
65+
//
66+
// `#[account(zero)]` verifies the account is owned by this program
67+
// and has its discriminator unset, which is exactly what a freshly
68+
// create_account-d account looks like. The handler then stamps the
69+
// discriminator + struct via `load_init()`.
70+
//
71+
// The account is passed in as a regular Signer (created by the client),
72+
// not a PDA. The README documents the (program_id, MARKET_SEED + market)
73+
// PDA derivation that clients should still use for the account address
74+
// — we just have to allocate it ourselves.
75+
#[account(zero)]
76+
pub order_book: AccountLoader<'info, OrderBook>,
6877

6978
pub base_mint: InterfaceAccount<'info, Mint>,
7079

defi/clob/anchor/programs/clob/src/instructions/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
pub mod cancel_order;
2-
pub mod create_user_account;
2+
pub mod create_market_user;
33
pub mod initialize_market;
44
pub mod place_order;
55
pub mod settle_funds;
66
pub mod withdraw_fees;
77

88
pub use cancel_order::*;
9-
pub use create_user_account::*;
9+
pub use create_market_user::*;
1010
pub use initialize_market::*;
1111
pub use place_order::*;
1212
pub use settle_funds::*;

0 commit comments

Comments
 (0)