Skip to content

Commit e6b0850

Browse files
Edwardmikemaccana-edwardbot
authored andcommitted
feat(defi/clob): add price-time priority matching engine
Previously place_order booked orders and escrowed funds but never crossed them — a CLOB with no matching is pointless. This commit completes the job: incoming orders walk the opposite side of the book using price-time priority, match at the resting (maker's) price, credit fills to unsettled_* balances, and route a configurable taker fee to a dedicated fee vault. Matching semantics ------------------ - A taker bid walks asks lowest-first; a taker ask walks bids highest-first. Fills stop when either the taker is exhausted or the next resting order's price fails the limit check. - Fills happen at the MAKER'S price (price improvement for the taker). The taker's locked-up-front quote that isn't spent is refunded to their unsettled_quote. - Time priority is implicit in the OrderBook's sorted Vecs: at the same price, the earliest insertion is at the lower index and fills first. - Any unmatched remainder rests on the book as a new maker order with the original limit price. Fee model --------- Single taker_fee (basis points) deducted from the gross quote of each fill and routed to a new market-owned fee_vault (one CPI per place_order, aggregated across fills). Makers never pay an explicit maker fee. See programs/clob/src/instructions/place_order.rs for the trade-offs vs a taker-funded (extra-transfer) model. New instruction --------------- withdraw_fees: authority-gated drain of the fee vault into the authority's quote token account. No-ops on an empty vault so it is safe to call on a schedule. Remaining accounts pattern -------------------------- Maker Order PDAs and their owners' UserAccount PDAs are passed as remaining_accounts in pairs, in book-walk order. The program re-verifies each pair against the live book and rejects mismatches. Tests ----- 13 existing LiteSVM tests untouched and still pass; 10 new tests cover: fully-crossing bid, fully-crossing ask, partial-fill of resting order, partial-fill of taker, multi-level crossing with price priority, time priority at a tie, price-improvement rebate, fee maths, withdraw_fees drain, and settle_funds after matching.
1 parent 83894c6 commit e6b0850

12 files changed

Lines changed: 1575 additions & 65 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ Transfer tokens between accounts.
166166

167167
### Central Limit Order Book
168168

169-
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.
169+
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.
170170

171171
[⚓ Anchor](./defi/clob/anchor)
172172

defi/clob/anchor/README.md

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,63 @@
11
# Anchor CLOB
22

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.
3+
A minimal **Central Limit Order Book** (CLOB) on Solana. Users place limit buy (bid) or sell (ask) orders at a chosen price. Incoming orders cross against resting orders on the opposite side of the book using **price-time priority** — taker proceeds land in the user's `unsettled_*` balance and are withdrawn later via `settle_funds`. Unmatched remainders rest on the book as new maker orders.
44

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.
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, cancellation, and fee logic.
66

77
## Concepts
88

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.
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 three token vaults (base, quote, fee).
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 Vec order: best price is index 0, and within a price level the earliest insertion is first.
1111
- **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.
1212
- **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+
- **Fee vault** — a separate token account (quote mint) that accumulates taker fees. The market PDA is its authority; only `withdraw_fees` can drain it, and only the market's stored `authority` may call that.
1314

1415
## Instructions
1516

1617
| Name | What it does |
1718
|-----------------------|--------------|
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+
| `initialize_market` | Create the market, order book, base vault, quote vault, and fee vault for a `base/quote` pair. Sets fee (bps), tick size and minimum order size. |
1920
| `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+
| `place_order` | Lock the required funds (bids lock `price × quantity` of quote; asks lock `quantity` of base), then cross against the opposing side of the book (price-time priority). Taker proceeds land in `unsettled_base`/`unsettled_quote`; any unmatched remainder rests on the book. Callers pass resting-order PDAs and their owners' `UserAccount` PDAs as `remaining_accounts`, in pairs, in book order. |
2122
| `cancel_order` | Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. |
2223
| `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. |
24+
| `withdraw_fees` | Authority-only. Drains the fee vault into the authority's quote token account. Safe to call with an empty fee vault — it no-ops rather than reverting. |
2325

24-
### Scope note
26+
### Matching semantics
2527

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.
28+
`place_order` walks the opposite side of the book in price-time priority order:
29+
30+
- A **taker bid** walks asks lowest-first. For each ask whose price `<=` the bid's limit, a fill occurs at the ask's (maker's) price, for `min(taker_remaining, maker_remaining)` quantity. Stops when the bid is filled or the next ask's price exceeds the bid's limit.
31+
- A **taker ask** mirrors: walk bids highest-first, fill at the bid's price while the bid's price `>=` the ask's limit.
32+
- **Price improvement** — a bid at 1000 crossing an ask at 900 fills at 900. The taker locked `1000 × qty` of quote up front; the `100 × qty` they didn't need is refunded to their `unsettled_quote`.
33+
- **Time priority** — two orders at the same price fill in the order they were inserted. The oldest resting order wins.
34+
35+
### Fee model
36+
37+
A single `fee_basis_points` value (0–10_000) applies to the taker fee on the quote side of each fill:
38+
39+
```
40+
gross = fill_price * fill_quantity
41+
fee = gross * fee_basis_points / 10_000 # rounded down
42+
```
43+
44+
- The fee is deducted from the gross quote flowing between the two traders, and transferred to the market's `fee_vault` via one CPI per `place_order` call (aggregated across fills to keep CU cost down).
45+
- In this example the fee is effectively maker-funded (the maker receives `gross − fee`) rather than taker-funded (where the taker would bring extra quote to cover the fee on top of the gross). This keeps the instruction simple — no per-fill CPI from the taker's ATA — and matches how Openbook v2 and Phoenix tend to operate. If you need strictly maker-neutral fees, add a second `transfer_checked` from the taker's ATA to the `fee_vault` for each fill.
46+
- Makers never pay an explicit maker fee in this example.
47+
48+
### Remaining accounts
49+
50+
`place_order`'s matching needs to mutate each resting maker's `Order` (to bump `filled_quantity` and flip `status`) and their `UserAccount` (to credit `unsettled_*` and drop filled orders from `open_orders`). Those accounts are passed as `remaining_accounts` in pairs:
51+
52+
```
53+
remaining_accounts = [
54+
maker_1_order, maker_1_user_account,
55+
maker_2_order, maker_2_user_account,
56+
...
57+
]
58+
```
59+
60+
Ordered the way the book will walk them: lowest-priced ask first for a taker bid, highest-priced bid first for a taker ask. The program re-verifies the pairs against the live order book (rejecting out-of-order or unknown order ids) before applying any fills.
2761

2862
## Build
2963

@@ -41,4 +75,4 @@ Tests are pure Rust, running against [LiteSVM](https://github.com/LiteSVM/litesv
4175

4276
## Credit
4377

44-
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 repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget).
78+
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 repo's LiteSVM-Rust-tests convention (no magic numbers, `Box`-ed interface accounts to keep BPF stack size within budget). Matching engine added in a subsequent pass.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,19 @@ pub enum ErrorCode {
3737

3838
#[msg("Fee basis points out of range")]
3939
InvalidFeeBasisPoints,
40+
41+
#[msg("Fee vault does not match the market's fee vault")]
42+
InvalidFeeVault,
43+
44+
#[msg("Maker account provided does not correspond to a resting order on the book")]
45+
MakerAccountMismatch,
46+
47+
#[msg("Not enough maker accounts supplied to cross the incoming order")]
48+
MissingMakerAccounts,
49+
50+
#[msg("Maker order and maker user account owner mismatch")]
51+
MakerOwnerMismatch,
52+
53+
#[msg("Only the market authority can withdraw fees")]
54+
NotMarketAuthority,
4055
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub fn handle_initialize_market(
2727
market.quote_mint = context.accounts.quote_mint.key();
2828
market.base_vault = context.accounts.base_vault.key();
2929
market.quote_vault = context.accounts.quote_vault.key();
30+
market.fee_vault = context.accounts.fee_vault.key();
3031
market.order_book = context.accounts.order_book.key();
3132
market.fee_basis_points = fee_basis_points;
3233
market.tick_size = tick_size;
@@ -87,6 +88,17 @@ pub struct InitializeMarket<'info> {
8788
)]
8889
pub quote_vault: InterfaceAccount<'info, TokenAccount>,
8990

91+
// Taker fees accumulate here (quote mint). Separate from quote_vault so
92+
// maker-owed balances and market-earned fees can't be confused.
93+
#[account(
94+
init,
95+
payer = authority,
96+
token::mint = quote_mint,
97+
token::authority = market,
98+
token::token_program = token_program
99+
)]
100+
pub fee_vault: InterfaceAccount<'info, TokenAccount>,
101+
90102
#[account(mut)]
91103
pub authority: Signer<'info>,
92104

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ pub mod create_user_account;
33
pub mod initialize_market;
44
pub mod place_order;
55
pub mod settle_funds;
6+
pub mod withdraw_fees;
67

78
pub use cancel_order::*;
89
pub use create_user_account::*;
910
pub use initialize_market::*;
1011
pub use place_order::*;
1112
pub use settle_funds::*;
13+
pub use withdraw_fees::*;

0 commit comments

Comments
 (0)