Skip to content

Commit ee1c84f

Browse files
feat(defi): add CLOB example ported from mikemaccana/anchor-decentralized-exchange-clob
Adds a teaching-grade central limit order book under defi/clob/anchor. The port brings the source program from Anchor 0.32.1 to Anchor 1.0.0 and conforms it to the solana-anchor-claude-skill ruleset so it can sit alongside the other Financial Software examples. Why this example belongs in program-examples: - The existing DeFi corpus covers constant-product AMMs and peer-to-peer escrow; a CLOB rounds out the set so readers can see how limit-order exchanges work on Solana without having to read Openbook/Phoenix's much larger zero-copy codebases. - It demonstrates several patterns the simpler examples don't: PDAs authoring token vaults, per-user per-market state, and an unsettled- balance + settle step that mirrors how real exchanges decouple matching from fund movement. Program side (Anchor 1.0 migration + skill rules): - declare_id! kept; every handler uses `context` rather than `ctx`. - `context.bumps.x` direct field access, no `.get("x").unwrap()`. - All #[account] structs derive InitSpace and store `pub bump: u8`, saved in the init handler. - Every `space = ...` uses `T::DISCRIMINATOR.len() + T::INIT_SPACE` — no magic 8, no hand-sized byte math, no custom discriminator consts. - Clock::get()? instead of the anchor_lang::solana_program path. - Token accounts use anchor_spl::token_interface for Token-2022 support. - PlaceOrderAccountConstraints and SettleFundsAccountConstraints box their InterfaceAccount fields — without boxing the BPF frame exceeds the 4 KB stack-offset limit. A comment on each documents the reason. - CpiContext::new / new_with_signer now take `token_program.key()`, matching the Anchor 1.0 signature change. - MAX_ORDERS_PER_SIDE and MAX_OPEN_ORDERS_PER_USER are named constants with rationale, instead of the magic 100 / 20 in the source. - Dead matching-engine helpers from the upstream `utils/matching.rs` are removed: they were never invoked by place_order and contained an obvious quantity-accounting bug. The README's "Scope note" flags that a real matching engine is the natural next extension — better to be honest about the limit than to ship broken code. Test side (Mike-canonical pattern): - node:test via `npx tsx --test --test-reporter=spec`. - solana-kite `connect()` / `createWallets` / `createTokenMint` / `sendTransactionFromInstructions` — no @coral-xyz/anchor, no web3.js v1, no ts-mocha, no chai, no bs58. - @solana/kit types (TransactionSigner, Address, lamports). - Codama-generated TS client under dist/clob-client (built by the Anchor.toml `test` script via `npx create-codama-clients`). - TOKEN_EXTENSIONS_PROGRAM from solana-kite, PDAs via `connection.getPDAAndBump`, token accounts via `connection.getTokenAccountAddress`. - 9 tests cover the full happy path (initialize, create user, bid, ask, cancel, settle, cancel+settle buyer) plus two failure cases (invalid price, non-owner cancel). Known limitation called out in the README: surfpool (Anchor 1.0's default local validator) does not accept the websocket RPC methods Kit uses for transaction confirmation; `anchor test --validator legacy` is required until surfpool catches up.
1 parent 1f398ed commit ee1c84f

22 files changed

Lines changed: 1406 additions & 0 deletions

README.md

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

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

25+
### Central Limit Order Book
26+
27+
Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders can be cancelled and funds settled back. A minimal teaching example of the mechanics behind Openbook and Phoenix.
28+
29+
[⚓ Anchor](./defi/clob/anchor)
30+
2531
### Escrow
2632

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

defi/clob/anchor/.gitignore

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

defi/clob/anchor/Anchor.toml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[toolchain]
2+
anchor_version = "1.0.0"
3+
solana_version = "3.1.8"
4+
# pnpm matches the program-examples root package manager.
5+
package_manager = "pnpm"
6+
7+
[features]
8+
resolution = true
9+
skip-lint = false
10+
11+
[programs.localnet]
12+
clob = "C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx"
13+
14+
[provider]
15+
cluster = "localnet"
16+
wallet = "~/.config/solana/id.json"
17+
18+
[scripts]
19+
# Generate the Codama TS client from the built IDL, then run node:test tests
20+
# via tsx. Run with: `anchor test --validator legacy`
21+
#
22+
# The `legacy` flag selects `solana-test-validator`, which Kit can talk to.
23+
# Anchor 1.0's default "surfpool" validator does not accept the websocket RPC
24+
# methods Kit uses for confirmation, so tests time out waiting for their
25+
# first transaction. Revisit when surfpool adds full Kit support.
26+
test = "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts"
27+
28+
[hooks]
29+
30+
[test]
31+
# Give the local validator enough time to become reachable before tests start.
32+
startup_wait = 10000
33+
shutdown_wait = 2000
34+
upgradeable = false

defi/clob/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/clob/anchor/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Anchor CLOB
2+
3+
A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price; their tokens sit in a program-owned vault until the order is cancelled. Cancellation credits the refund to an internal balance and a later `settle_funds` call moves those tokens back to the user.
4+
5+
This is a teaching example. It is deliberately small — the real CLOBs on Solana (Openbook, Phoenix) use zero-copy slab data structures and much more sophisticated matching and fee logic.
6+
7+
## Concepts
8+
9+
- **Market** — one trading pair, e.g. `BASE/QUOTE`. Stored at a PDA seeded by the two mints. The market account is the signer of its two token vaults.
10+
- **Order Book** — a PDA per market holding two `Vec<OrderEntry>`s: bids (sorted descending by price) and asks (sorted ascending). Price-time priority is implicit in the order they are inserted.
11+
- **User Account** — one per user per market. Tracks the user's open order ids and two "unsettled" balances (base and quote) representing tokens the program owes the user but has not yet transferred back.
12+
- **Order** — a PDA per placed order, seeded by `(market, order_id)`. Stores price, original and filled quantity, status (`Open`, `PartiallyFilled`, `Filled`, `Cancelled`) and the owner.
13+
14+
## Instructions
15+
16+
| Name | What it does |
17+
|-----------------------|--------------|
18+
| `initialize_market` | Create the market, order book and two token vaults for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. |
19+
| `create_user_account` | Initialise the caller's per-market user account. |
20+
| `place_order` | Add a limit order to the book and lock the funds it would need if filled: bids lock `price × quantity` of quote; asks lock `quantity` of base. |
21+
| `cancel_order` | Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. |
22+
| `settle_funds` | Move all unsettled base and quote from the market's vaults back to the owner's token accounts. Signs with the market PDA. |
23+
24+
### Scope note
25+
26+
The program stores the book and locks funds on placement, but does **not** currently run a matching engine inside `place_order`. Crossed orders (a bid at or above the best ask) will sit side-by-side in the book rather than trade. Adding matching requires passing the opposing orders (and their owners' user accounts and token accounts) as remaining accounts and clearing the filled amounts across both sides; it's a natural next extension.
27+
28+
## Build
29+
30+
```shell
31+
anchor build
32+
```
33+
34+
## Test
35+
36+
```shell
37+
anchor test --validator legacy
38+
```
39+
40+
The `--validator legacy` flag is required: Anchor 1.0's default "surfpool" validator does not yet accept the websocket RPC methods Solana Kit uses for transaction confirmation, so tests hang waiting for their first transaction. `solana-test-validator` works.
41+
42+
The test script (defined in `Anchor.toml`) first runs `npx create-codama-clients` to generate a TypeScript client from the built IDL into `dist/clob-client/`, then executes the `node:test` suite with `tsx`.
43+
44+
## Credit
45+
46+
Ported and modernised from [anchor-decentralized-exchange-clob](https://github.com/mikemaccana/anchor-decentralized-exchange-clob). Migrated from Anchor 0.32.1 to Anchor 1.0.0 and conformed to the [Solana Anchor coding skill](https://github.com/mikemaccana/solana-anchor-claude-skill) (Kit + Kite + Codama, `node:test`, no `@coral-xyz/anchor`, no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget).

defi/clob/anchor/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "clob",
3+
"version": "0.1.0",
4+
"license": "ISC",
5+
"type": "module",
6+
"scripts": {
7+
"build": "anchor build",
8+
"codama": "npx create-codama-clients",
9+
"test": "npx create-codama-clients && npx tsx --test --test-reporter=spec tests/*.ts"
10+
},
11+
"dependencies": {
12+
"@solana/kit": "^6.1.0"
13+
},
14+
"devDependencies": {
15+
"@codama/nodes-from-anchor": "^1.3.8",
16+
"@codama/renderers": "^1.0.34",
17+
"@codama/renderers-js": "^1.7.0",
18+
"@types/node": "^20.11.0",
19+
"codama": "^1.5.0",
20+
"solana-kite": "^3.2.1",
21+
"tsx": "^4.7.0",
22+
"typescript": "^5.7.3"
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "clob"
3+
version = "0.1.0"
4+
description = "Central limit order book on Solana"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "clob"
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+
26+
[lints.rust]
27+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum ErrorCode {
5+
#[msg("Invalid price provided")]
6+
InvalidPrice,
7+
8+
#[msg("Invalid quantity provided")]
9+
InvalidQuantity,
10+
11+
#[msg("Order not found")]
12+
OrderNotFound,
13+
14+
#[msg("Market is currently paused")]
15+
MarketPaused,
16+
17+
#[msg("Unauthorized action")]
18+
Unauthorized,
19+
20+
#[msg("Order book is full")]
21+
OrderBookFull,
22+
23+
#[msg("User account has too many open orders")]
24+
TooManyOpenOrders,
25+
26+
#[msg("Price does not align with tick size")]
27+
InvalidTickSize,
28+
29+
#[msg("Quantity is below minimum order size")]
30+
BelowMinOrderSize,
31+
32+
#[msg("Order is not cancellable in current status")]
33+
OrderNotCancellable,
34+
35+
#[msg("Numerical overflow occurred")]
36+
NumericalOverflow,
37+
38+
#[msg("Fee basis points out of range")]
39+
InvalidFeeBasisPoints,
40+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::errors::ErrorCode;
4+
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,
7+
};
8+
9+
pub fn cancel_order(context: Context<CancelOrderAccountConstraints>) -> 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 user_account = &mut context.accounts.user_account;
28+
match order.side {
29+
OrderSide::Bid => {
30+
let quote_amount = order
31+
.price
32+
.checked_mul(remaining)
33+
.ok_or(ErrorCode::NumericalOverflow)?;
34+
user_account.unsettled_quote = user_account
35+
.unsettled_quote
36+
.checked_add(quote_amount)
37+
.ok_or(ErrorCode::NumericalOverflow)?;
38+
}
39+
OrderSide::Ask => {
40+
user_account.unsettled_base = user_account
41+
.unsettled_base
42+
.checked_add(remaining)
43+
.ok_or(ErrorCode::NumericalOverflow)?;
44+
}
45+
}
46+
}
47+
48+
let order_book = &mut context.accounts.order_book;
49+
let removed = remove_order(order_book, order.order_id);
50+
require!(removed, ErrorCode::OrderNotFound);
51+
52+
let user_account = &mut context.accounts.user_account;
53+
remove_open_order(user_account, order.order_id);
54+
55+
order.status = OrderStatus::Cancelled;
56+
57+
Ok(())
58+
}
59+
60+
#[derive(Accounts)]
61+
pub struct CancelOrderAccountConstraints<'info> {
62+
pub market: Account<'info, Market>,
63+
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>,
70+
71+
#[account(
72+
mut,
73+
seeds = [ORDER_SEED, market.key().as_ref(), order.order_id.to_le_bytes().as_ref()],
74+
bump = order.bump
75+
)]
76+
pub order: Account<'info, Order>,
77+
78+
#[account(
79+
mut,
80+
seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()],
81+
bump = user_account.bump
82+
)]
83+
pub user_account: Account<'info, UserAccount>,
84+
85+
pub owner: Signer<'info>,
86+
}
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, UserAccount, USER_ACCOUNT_SEED};
4+
5+
pub fn create_user_account(context: Context<CreateUserAccountAccountConstraints>) -> Result<()> {
6+
let user_account = &mut context.accounts.user_account;
7+
user_account.market = context.accounts.market.key();
8+
user_account.owner = context.accounts.owner.key();
9+
user_account.unsettled_base = 0;
10+
user_account.unsettled_quote = 0;
11+
user_account.open_orders = Vec::new();
12+
user_account.bump = context.bumps.user_account;
13+
14+
Ok(())
15+
}
16+
17+
#[derive(Accounts)]
18+
pub struct CreateUserAccountAccountConstraints<'info> {
19+
#[account(
20+
init,
21+
payer = owner,
22+
space = UserAccount::DISCRIMINATOR.len() + UserAccount::INIT_SPACE,
23+
seeds = [USER_ACCOUNT_SEED, market.key().as_ref(), owner.key().as_ref()],
24+
bump
25+
)]
26+
pub user_account: Account<'info, UserAccount>,
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)