Skip to content

Commit c846179

Browse files
authored
Merge pull request #96 from quicknode/claude/quasar-solana-orderbook-port-4mcr6j
Add Quasar port of the order-book (CLOB) example
2 parents bfc2df5 + c527eb4 commit c846179

25 files changed

Lines changed: 3318 additions & 0 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
[package]
2+
name = "quasar-order-book"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# Standalone workspace - not part of the root program-examples workspace.
7+
# Quasar uses a different resolver and dependency tree.
8+
[workspace]
9+
10+
[lints.rust.unexpected_cfgs]
11+
level = "warn"
12+
check-cfg = [
13+
'cfg(target_os, values("solana"))',
14+
]
15+
16+
[lib]
17+
crate-type = ["cdylib", "lib"]
18+
19+
[features]
20+
alloc = []
21+
client = []
22+
debug = []
23+
24+
[dependencies]
25+
# quasar pin rationale: matches the finance/escrow Quasar example. master HEAD
26+
# currently fails to compile because zeropod 0.3.x auto-generates accessor
27+
# methods that conflict with hand-written ones in quasar-spl. 623bb70 is the
28+
# last working rev on master before that bump. Unpin (back to branch = "master")
29+
# once upstream merges the fix.
30+
quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
31+
quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
32+
solana-address = { version = "2.2.0" }
33+
solana-instruction = { version = "3.2.0" }
34+
# The ported Openbook slab casts a contiguous byte region to fixed-layout node
35+
# structs. `derive` gives `Pod`/`Zeroable`; `min_const_generics` lets the
36+
# 1024-slot `[AnyNode; N]` array derive `Pod` without hitting bytemuck's
37+
# default 32-element array cap.
38+
bytemuck = { version = "1.18", features = ["derive", "min_const_generics"] }
39+
# Compile-time slab-layout asserts (node size, alignment), matching upstream.
40+
static_assertions = "1.1"
41+
42+
[dev-dependencies]
43+
quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" }
44+
spl-token-interface = { version = "2.0.0" }
45+
solana-program-pack = { version = "3.1.0" }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[project]
2+
name = "quasar_order_book"
3+
4+
[toolchain]
5+
type = "solana"
6+
7+
[testing]
8+
language = "rust"
9+
10+
[testing.rust]
11+
framework = "quasar-svm"
12+
13+
[testing.rust.test]
14+
program = "cargo"
15+
args = [
16+
"test",
17+
"tests::",
18+
]
19+
20+
[clients]
21+
path = "target/client"
22+
languages = ["rust"]
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Order Book — Central Limit Order Book (CLOB), Quasar port
2+
3+
A [central limit order book (CLOB)](https://www.investopedia.com/terms/l/limitorderbook.asp) — the market
4+
structure NYSE, NASDAQ, CME, and onchain venues like Phoenix and OpenBook run on. Users post buy or sell
5+
offers at prices they pick; the program matches crossing offers in **price-time priority** and settles the
6+
resulting token movements.
7+
8+
This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor example in
9+
[`../anchor`](../anchor). Quasar is a zero-copy, `no_std`, zero-allocation Solana framework with Anchor-like
10+
syntax (`#[program]`, `#[derive(Accounts)]`, `#[account]`) that compiles to a much smaller binary. Both builds
11+
use the **same program ID** (`C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx`), so offchain tooling and PDA
12+
derivations work against either unchanged. The Anchor README carries the full conceptual walkthrough; this one
13+
focuses on how the program works and what the Quasar port does differently.
14+
15+
## What the program does
16+
17+
Two users want to swap tokens at prices they each chose. **Alice** holds USDC (the *quote* mint — the pricing
18+
unit) and wants to buy NVDAx (the *base* mint — the asset being priced), but only at 900 USDC/share or lower.
19+
**Bob** holds NVDAx and will sell at 900 or higher. They post a **bid** and an **ask**; when the prices cross,
20+
the program fills them.
21+
22+
- The party whose order **rests** on the book first is the **maker**.
23+
- The party whose order arrives and **crosses** the resting order is the **taker**.
24+
25+
A fill always clears at the **maker's** posted price (standard CLOB rule): the taker gets price improvement
26+
versus their own limit. The taker pays a fee (in basis points) routed to a fee vault; the maker's proceeds are
27+
credited net of that fee.
28+
29+
Funds are held in **program-owned vaults** (the market PDA is their token authority) from the moment an order
30+
locks them until settlement. There is no admin escape hatch — the market authority can only withdraw
31+
accumulated **fees**, never user balances. The deployed program bytecode is the only thing that can move vault
32+
funds, and it moves them only along the place / cancel / settle paths below.
33+
34+
## Accounts and PDAs
35+
36+
| Account | Kind | Seeds | Role |
37+
| --- | --- | --- | --- |
38+
| `Market` | PDA | `["market", base_mint, quote_mint]` | One trading pair. Stores config + vault addresses. Its PDA is the vaults' token authority. |
39+
| `OrderBook` | keypair account | — (not a PDA) | Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. |
40+
| `MarketUser` | PDA | `["market_user", market, owner]` | Per-user, per-market. Tracks open order ids and `unsettled_*` balances owed back to the user. |
41+
| `Order` | PDA | `["order", market, order_id]` | One order. `order_id` is the book's monotonic counter at placement time. |
42+
| `base_vault` / `quote_vault` | token accounts || Hold locked funds while orders are open. Market PDA is the authority. |
43+
| `fee_vault` | token account || Accumulates taker fees (quote mint). Kept separate so user balances and fees can't be confused. |
44+
45+
The order book is **not** a PDA. Solana caps inner-CPI account allocations at 10 KB, so a ~180 KB account can't
46+
be created with an `init` constraint — the client calls `system_program::create_account` directly (sizing it to
47+
`ORDER_BOOK_ACCOUNT_SIZE`, program-owned, zeroed) and passes it to `initialize_market`, which verifies and
48+
initializes it in place.
49+
50+
## The two-lot pricing model
51+
52+
Both sides of the book are denominated in **lots**, mirroring Serum/OpenBook, so `price` and `quantity` read
53+
as human numbers regardless of each mint's decimals:
54+
55+
```
56+
raw_base = quantity × base_lot_size
57+
raw_quote = quantity × price × quote_lot_size
58+
```
59+
60+
Choose `base_lot_size = 10^max(d_base − d_quote, 0)` and `quote_lot_size = 10^max(d_quote − d_base, 0)`. For
61+
NVDAx (9 decimals) / USDC (6 decimals): `base_lot_size = 1000`, `quote_lot_size = 1`, so `price = 100` means
62+
100 USDC-units per base lot and `tick_size = 1` is one atomic increment.
63+
64+
## Instruction lifecycle
65+
66+
| Handler | What it does |
67+
| --- | --- |
68+
| `initialize_market` | Create the `Market` PDA, the two vaults, and the fee vault; initialize the pre-created order-book account. |
69+
| `create_market_user` | Create a caller's `MarketUser` for a market. |
70+
| `place_order` | Lock funds, cross the opposing side in price-time priority, credit fills to maker/taker `unsettled_*`, route the taker fee, and rest any remainder. |
71+
| `cancel_order` | Credit an open order's locked remainder back to the owner's `unsettled_*` and remove it from the book. |
72+
| `settle_funds` | Move a user's `unsettled_*` balances out of the vaults into their token accounts. |
73+
| `withdraw_fees` | Authority-only: drain the fee vault to the authority's token account. |
74+
75+
`place_order` takes `side` (`0` = bid, `1` = ask), `price`, `quantity`, and `order_id`. The caller passes the
76+
resting maker orders to cross as **remaining accounts**, in pairs of `(maker_order, maker_market_user)`, in the
77+
book's price-time priority. `order_id` must equal the book's current `next_order_id` (the program verifies it),
78+
so the client derives the `Order` PDA deterministically.
79+
80+
Fills never transfer tokens directly to the counterparty — they credit `unsettled_*` balances that each user
81+
drains later via `settle_funds`. This keeps the per-fill account footprint small (no maker ATAs in the fill
82+
path), as in OpenBook v2.
83+
84+
## The matching engine
85+
86+
Each side of the book is a **critbit tree** (radix trie) ported from
87+
[openbook-v2](https://github.com/openbook-dex/openbook-v2) (MIT; see `src/state/slab/LICENSE-OPENBOOK`). Leaves
88+
carry the resting order's price, remaining quantity, owner, and id; a 128-bit key packs `[price][seq_num]` so an
89+
in-order walk yields best-price-first, and within a price level the sequence number preserves time priority.
90+
Insert, remove, and best-price lookup are all sub-linear, so matching stays cheap as depth grows.
91+
92+
## What the Quasar port does differently
93+
94+
The trading logic, fee model, lot math, and matching engine are identical to the Anchor build. The differences
95+
are all consequences of Quasar being zero-copy, `no_std`, and zero-allocation:
96+
97+
- **The order book is accessed by casting raw account bytes.** Anchor uses `AccountLoader<OrderBook>`; Quasar
98+
has no `AccountLoader`, so `initialize_market` / `place_order` / `cancel_order` cast the account's byte region
99+
to `&mut OrderBook` with `bytemuck` (an 8-byte discriminator guards against a wrong account). The slab code is
100+
otherwise a near-verbatim port.
101+
- **`MarketUser.open_orders` is a fixed `[u8; 160]` (20 packed u64s) plus a length**, not a borsh `Vec<u64>`. A
102+
resizable field would make the account dynamic, which is awkward to mutate on the maker side where the account
103+
arrives as a remaining account. The fixed buffer keeps every mutation an in-place write.
104+
- **The slab is heap-free.** Its transient traversal stack is a fixed array (a critbit tree over 128-bit keys is
105+
at most 128 nodes deep), and two write-only stacks the upstream carried were dropped. A single `place_order`
106+
crosses at most `MAX_FILLS` (16) resting orders; any remainder rests on the book for a later order to cross.
107+
- **`side` and order status/side enums are stored as `u8`** (zero-copy accounts hold POD scalars). The `u8`
108+
wire encoding of `side` matches the Anchor build's borsh enum discriminant.
109+
110+
## Safety and custody
111+
112+
- Every vault transfer out is signed by the **market PDA** via `invoke_signed`; only the deployed program can
113+
move locked funds.
114+
- `settle_funds` zeroes a user's `unsettled_*` **before** transferring (checks-effects-interactions), so no
115+
token-hook re-entry could double-withdraw.
116+
- `place_order` binds every market-owned account (`base_vault`, `quote_vault`, `fee_vault`, both mints, the
117+
order book) to the addresses stored on the `Market` PDA with `has_one`, so a caller can't substitute the fee
118+
vault for a user vault and drain fees.
119+
- Taker fees use **ceiling** division, rounding in the protocol's favor so many tiny fills can't leak a minor
120+
unit to the maker.
121+
122+
## Building and testing
123+
124+
Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) (for `cargo build-sbf`) and the
125+
[Quasar CLI](https://github.com/blueshift-gg/quasar):
126+
127+
```sh
128+
cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked
129+
quasar build # compiles the program to target/deploy/quasar_order_book.so
130+
cargo test # QuasarSVM integration tests (they load the compiled .so)
131+
```
132+
133+
`quasar build` must run before `cargo test`: the tests load the compiled `.so` into
134+
[QuasarSVM](https://github.com/blueshift-gg/quasar-svm), an in-process SVM, via `include`/`fs::read`. The suite
135+
in `src/tests.rs` drives the full lifecycle — initialize a market, create users, rest an ask, cross it with a
136+
bid, settle both sides, and withdraw the fee — asserting on-chain state, token balances, and fee accounting at
137+
each step, plus an authorization rejection.
138+
139+
## Extending
140+
141+
- **Self-trade prevention:** reject or cancel-back when a taker would cross their own resting order.
142+
- **Post-only / IOC / FOK** order types by gating the rest-vs-cancel behaviour on a flag.
143+
- **Multiple fee tiers** keyed on the taker's `MarketUser`.
144+
- **Market pause/resume** by flipping `Market.is_active` from an authority-gated handler.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use quasar_lang::prelude::*;
2+
3+
/// Program errors. `#[error_code]` assigns the numeric codes and generates the
4+
/// `From<OrderBookError> for ProgramError` conversion that `?` and `require!`
5+
/// rely on. Codes start at 6000, matching Anchor's custom-error base so the two
6+
/// builds report the same numbers to clients.
7+
#[error_code]
8+
pub enum OrderBookError {
9+
InvalidPrice = 6000,
10+
OrderNotFound,
11+
MarketPaused,
12+
Unauthorized,
13+
OrderBookFull,
14+
TooManyOpenOrders,
15+
InvalidTickSize,
16+
InvalidBaseLotSize,
17+
InvalidQuoteLotSize,
18+
BelowMinOrderSize,
19+
OrderNotCancellable,
20+
NumericalOverflow,
21+
InvalidFeeBasisPoints,
22+
InvalidFeeVault,
23+
InvalidBaseVault,
24+
InvalidQuoteVault,
25+
InvalidBaseMint,
26+
InvalidQuoteMint,
27+
MakerAccountMismatch,
28+
MissingMakerAccounts,
29+
MakerOwnerMismatch,
30+
NotMarketAuthority,
31+
InvalidOrderBook,
32+
InvalidOrderBookOwner,
33+
OrderBookAlreadyInitialized,
34+
OrderIdMismatch,
35+
InvalidSide,
36+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod withdraw_fees;
2+
3+
pub use withdraw_fees::*;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use quasar_lang::prelude::*;
2+
use quasar_spl::prelude::*;
3+
4+
use crate::errors::OrderBookError;
5+
use crate::state::{Market, MARKET_SEED};
6+
7+
/// Drain the market's accumulated taker fees into the authority's token
8+
/// account. Authority-only - arbitrary callers must not be able to siphon the
9+
/// fee vault. Transfers the current balance of the fee vault in full.
10+
#[derive(Accounts)]
11+
pub struct WithdrawFeesAccountConstraints {
12+
#[account(has_one(fee_vault) @ OrderBookError::InvalidFeeVault)]
13+
pub market: Account<Market>,
14+
15+
#[account(mut)]
16+
pub fee_vault: Account<Token>,
17+
18+
#[account(mut)]
19+
pub authority_quote_account: Account<Token>,
20+
21+
pub quote_mint: Account<Mint>,
22+
23+
pub authority: Signer,
24+
25+
pub token_program: Program<TokenProgram>,
26+
}
27+
28+
#[inline(always)]
29+
pub fn handle_withdraw_fees(
30+
accounts: &mut WithdrawFeesAccountConstraints,
31+
) -> Result<(), ProgramError> {
32+
require_keys_eq!(
33+
*accounts.authority.address(),
34+
accounts.market.authority,
35+
OrderBookError::NotMarketAuthority
36+
);
37+
38+
let fee_balance = accounts.fee_vault.amount();
39+
if fee_balance == 0 {
40+
// Nothing to do - exit quietly rather than failing, so this
41+
// instruction is safe to call on a cron/heartbeat even when there
42+
// haven't been any fills since the last run.
43+
return Ok(());
44+
}
45+
46+
let base_mint = accounts.market.base_mint;
47+
let quote_mint = accounts.market.quote_mint;
48+
let bump = [accounts.market.bump];
49+
let seeds = [
50+
Seed::from(MARKET_SEED),
51+
Seed::from(base_mint.as_ref()),
52+
Seed::from(quote_mint.as_ref()),
53+
Seed::from(bump.as_ref()),
54+
];
55+
56+
accounts
57+
.token_program
58+
.transfer_checked(
59+
&accounts.fee_vault,
60+
&accounts.quote_mint,
61+
&accounts.authority_quote_account,
62+
&accounts.market,
63+
fee_balance,
64+
accounts.quote_mint.decimals,
65+
)
66+
.invoke_signed(&seeds)?;
67+
68+
Ok(())
69+
}

0 commit comments

Comments
 (0)