2 · The OrderBook & PDA Storage
The question everyone asks: "How does a single Solana PDA hold a ~612 KB order book?" This doc answers it byte by byte.
2.1 What a PDA is (quick grounding)
A Program Derived Address (PDA) is a Solana account whose address is derived
deterministically from a set of seeds plus the program ID, and which has no
private key. Because no key exists, only the owning program can sign for it
(via invoke_signed). PDAs are how Solana programs own and mutate persistent
state.
The OrderBook is a PDA derived from a seed like ["order_book", market_index].
There is exactly one per market. It is owned by the Slipstream program, which is
the only thing that can write to it — except that it is delegated to the ER,
so during a session the ER writes to it instead (doc 3).
2.2 The size problem
A Solana account can, in principle, be up to 10 MB. So 612 KB is allowed to exist. The problem is creating and growing it:
A single instruction may grow an account by at most
MAX_PERMITTED_DATA_INCREASE= 10,240 bytes (10 KiB) per CPI.
So you cannot CreateAccount a 612 KB account in one shot, and you cannot
realloc it to full size in one call. You must grow it ~61 chunks at a time.
This same 10,240-byte cap reappears as the villain in doc 4: it's why the giant book can never be re-delegated or undelegated, which is why settlement needs a separate small account.
2.3 Exact layout (the 612 KB, accounted for)
The account is one contiguous byte buffer: a fixed header followed by four
fixed-capacity arrays and a free list. From state/order_book.rs:
compute_account_size =
OrderBookHeader::LEN // 48 bytes
+ max_order_slots * OrderSlot::LEN // order slot pool
+ max_price_levels * PriceLevel::LEN // BID levels
+ max_price_levels * PriceLevel::LEN // ASK levels
+ max_fill_events * FillEvent::LEN // fill-event ring
+ max_order_slots * 2 // free list (u16 per slot)
With the deployed defaults:
| Constant | Value |
|---|---|
DEFAULT_MAX_ORDER_SLOTS |
2048 |
DEFAULT_MAX_PRICE_LEVELS |
512 (per side) |
DEFAULT_MAX_FILL_EVENTS |
4096 |
DEFAULT_ORDERS_PER_USER |
20 |
The arithmetic, exactly:
| Section | Count × size | Bytes |
|---|---|---|
| Header | 48 | 48 |
| Order-slot pool | 2048 × 88 | 180,224 |
| Bid price levels | 512 × 16 | 8,192 |
| Ask price levels | 512 × 16 | 8,192 |
| Fill-event ring | 4096 × 104 | 425,984 |
| Free list | 2048 × 2 | 4,096 |
| Total | 626,736 |
626,736 bytes ÷ 1024 = ~612 KB. The fill ring alone (425 KB) is two-thirds of it — see §2.7 on why it's that big.
2.4 Chunked allocation (how it's actually built)
Because of the 10,240-byte cap, the bootstrap builds the account incrementally (this is real deploy plumbing, documented in the root README):
initialize_market (0x00)
└─ creates the OrderBook PDA with an initial chunk,
pre-funded with enough lamports for the FULL 612 KB rent.
grow_orderbook (0x17) ← called ~61 times in a loop
└─ reallocs the account up by ≤10,240 bytes each call,
until data.len() == compute_account_size(...).
On the final grow (account now full size):
└─ init_free_list(): chain every slot 0→1→2→…→SENTINEL,
set free_list_head = 0, free_slot_count = max_order_slots.
Pre-funding full rent up front matters: an account must stay rent-exempt, so the lamports for the final 612 KB are deposited at creation even though the bytes arrive later.
626,736 / 10,240 ≈ 61.2, so it takes ~61–62 grow_orderbook calls to reach
full size. Only once full is the free list initialized — before that the slot
pool isn't fully addressable.
2.5 Zero-copy access (why it's not deserialized)
You cannot afford to deserialize 612 KB into a heap struct inside a BPF program (4 KB stack frame, tight compute budget). Slipstream uses zero-copy: it reinterprets the raw account bytes in place as typed slices, copying nothing.
OrderBookView::from_account_data (in state/order_book.rs):
- Validates
data.len()≥ expected size anddata[0]== the OrderBook discriminator. - Reads the header's capacity fields.
- Carves the one byte buffer into typed mutable slices with pointer arithmetic:
let ptr = data.as_mut_ptr();
let header = &mut *(ptr as *mut OrderBookHeader);
let order_slots = slice::from_raw_parts_mut(ptr.add(off) as *mut OrderSlot, max_slots);
let bid_levels = slice::from_raw_parts_mut(...);
let ask_levels = slice::from_raw_parts_mut(...);
let fill_events = slice::from_raw_parts_mut(...);
let free_list = slice::from_raw_parts_mut(...);
This is sound because every struct is Pod (plain-old-data) and
#[repr(C)] — a fixed, predictable, padding-explicit layout with no pointers
and no enums. bytemuck enforces the Pod/Zeroable bounds. The cost of
"loading" the book is therefore O(1): a handful of pointer casts, regardless of
the 612 KB. Mutations write straight into the account buffer.
Why explicit padding? Notice fields like
_pad1: [u8; 3]and_pad2: [u8; 2]in the header.#[repr(C)]requires fields to be naturally aligned; the program spells out the padding so the Rust layout is byte-identical across platforms and matches what the TypeScript decoder inclient/src/accounts.tsexpects. A mismatch here would silently corrupt every read.
2.6 The data structures inside
Order-slot pool + free list (object pool)
OrderSlot (88 bytes) is one resting/working order: side, type, order_id,
owner, price, size, remaining_size, expiry_ts, and margin_reserved
(the margin this order holds against the user's TradingCredit).
Slots are managed as an object pool with an intrusive free list:
free_list[i]stores the index of the next free slot, forming a singly linked chain ending inSENTINEL.alloc_slot()popsfree_list_headin O(1).free_slot()clears the slot and pushes it back in O(1).
No heap allocation, no fragmentation, deterministic cost — exactly what an on-chain matching engine needs.
Price levels (sorted ladders)
Each side keeps an array of PriceLevel (16 bytes: price, head_slot,
tail_slot, order_count). Bids are sorted descending, asks ascending, so
the best bid/ask is always index 0 — O(1) to read the top of book. Within a level,
orders form a linked list through the slots (next_at_level/prev_at_level),
preserving time priority. Inserting a new price shifts the array to keep it
sorted (insert_bid_level/insert_ask_level); lookups use binary search
(find_bid_level/find_ask_level).
So matching is: read level 0 (best price), walk its slot linked-list oldest-first
(price-time priority), fill, drain margin, emit a FillEvent.
Fill-event ring (the audit trail)
Matches don't immediately touch L1 — they can't, the book is in the ER. Instead
each fill is pushed onto a ring buffer of FillEvents (the push_fill_event
/ pop_fill_event / peek_fill_event methods, indices fill_event_head /
fill_event_tail / fill_event_count, wrapping modulo max_fill_events). This
ring is the source of truth that the settlement pipeline later mirrors and
settles (doc 4). It's also what the frontend's "Recent Trades" reads — from
fill_event_head, the oldest live fill.
2.7 Why the fill ring is 4096 entries (425 KB)
The fill ring is deliberately huge because of the settlement model. Fills accumulate in the book until a keeper mirrors and settles them (doc 4). The ring must hold enough history that no live fill is overwritten before it's been mirrored to L1. At 4096 entries the book can absorb a large burst of trading between keeper passes without losing a fill. The tradeoff is size — but a Solana account can be up to 10 MB, so 612 KB is comfortably within budget; the binding constraint was never total size, it was the per-CPI 10 KB growth cap (§2.2), which §2.4 works around.
2.8 Settlement progress without mutating the book
One subtlety: on L1, the book is delegated to the ER, so L1 code can read the
committed fill ring but must not mutate its head/count (that's the ER's job).
So "which fills have I already settled?" can't be tracked by popping the ring on
L1. Instead the Market account stores a last_settled_sequence cursor
(tucked into spare padding bytes as a little-endian u32, so the Market layout
didn't change). Settlement advances this cursor; fills are settled exactly once
across repeated keeper calls. Details in doc 4.
2.9 Takeaways
- A PDA can hold 612 KB; the challenge is the 10,240-byte per-CPI growth cap,
solved by chunked
grow_orderbookallocation (~61 calls). - The book is one flat
#[repr(C)]/Podbyte buffer read via zero-copy slices — O(1) to "load" regardless of size. - Inside: an object-pooled slot array + free list, sorted price-level ladders (best price at index 0, time priority within a level), and a giant fill-event ring that feeds settlement.
- The same growth cap that complicates creation makes the book un-undelegatable, which is the whole reason settlement is decoupled — straight to doc 3 and doc 4.
