You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -166,7 +166,7 @@ Transfer tokens between accounts.
166
166
167
167
### Central Limit Order Book
168
168
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.
Copy file name to clipboardExpand all lines: defi/clob/anchor/README.md
+43-9Lines changed: 43 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,29 +1,63 @@
1
1
# Anchor CLOB
2
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.
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.
4
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.
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.
6
6
7
7
## Concepts
8
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.
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.
11
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
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
+
-**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.
13
14
14
15
## Instructions
15
16
16
17
| Name | What it does |
17
18
|-----------------------|--------------|
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. |
19
20
|`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. |
21
22
|`cancel_order`| Close an open (or partially filled) order. Credits the still-locked amount to the owner's `unsettled_base` / `unsettled_quote`. |
22
23
|`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. |
23
25
24
-
### Scope note
26
+
### Matching semantics
25
27
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.
27
61
28
62
## Build
29
63
@@ -41,4 +75,4 @@ Tests are pure Rust, running against [LiteSVM](https://github.com/LiteSVM/litesv
41
75
42
76
## Credit
43
77
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.
0 commit comments