Skip to content

Commit baeed30

Browse files
committed
Order book (Anchor v1): port eviction from the Anchor v2 copy
CONTRIBUTING.md now says an Anchor v1 copy tracks its v2 counterpart, so eviction goes into all three variants rather than only Anchor v2 and Quasar. The port matches the v2 copy: worst_leaf and OrderBook::worst, the 512-order capacity fix, credit_unfilled_lock shared with cancel_order, the MissingEvictedAccounts and EvictedAccountMismatch errors, the eviction step in place_order, the README and CHANGELOG, and the same five tests. When the worst order is the caller's own, only the order is passed and the caller's market_user is credited, as in v2. Anchor v1 would accept the caller's MarketUser a second time, but a second loaded copy would be overwritten when the instruction writes its accounts back, so the rule matters here too. This copy is not rustfmt-clean on main and is outside the workspace CI formats, so the change is left unformatted to keep the diff to the port. Claude-Session: https://claude.ai/code/session_01RBfNjrQd2J6muoi3thCUin
1 parent e781bc4 commit baeed30

9 files changed

Lines changed: 632 additions & 59 deletions

File tree

‎finance/order-book/anchor-v1/CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
## 2026-09-23
44

5+
### Added
6+
7+
- Ported from the Anchor v2 copy: a full side of the book evicts instead of
8+
refusing. When a side already holds its 512 orders, an order that beats the
9+
side's worst price removes that worst order and rests in its place. The
10+
evicted order is refunded through its owner's unsettled balance, as a cancel
11+
is, and stamped Cancelled. An order that does not beat the worst price still
12+
gets `OrderBookFull`. The caller passes the worst order and its owner's
13+
`MarketUser` after the maker pairs, or only the order when it is their own.
14+
New errors: `MissingEvictedAccounts`, `EvictedAccountMismatch`. Same five
15+
tests as the v2 copy.
16+
17+
### Fixed
18+
19+
- A side holds 512 orders, not 1024: every order after the first adds a leaf
20+
and an inner node to the side's 1024-node tree. `MAX_ORDERS_PER_SIDE` and
21+
the README now say so.
22+
523
### Changed
624

725
- Ported from the Anchor v2 copy: the base, quote, and fee vaults are PDAs of

‎finance/order-book/anchor-v1/README.md‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ call `settle_funds` to pull their balances out.
7474
(base vault, quote vault, fee vault, order book), and the pubkey
7575
that can withdraw accumulated fees.
7676
- An **OrderBook** account - two stores: bids sorted highest-first,
77-
asks sorted lowest-first, each holding up to 1024 entries. Rather
77+
asks sorted lowest-first, each holding up to 512 orders. Rather
7878
than a plain list of orders, each side uses a depth-bounded tree (a
7979
critbit trie) for fast lookup - see [Ensuring fast order matching performance](#ensuring-fast-order-matching-performance).
8080
Each entry stores enough to drive matching (price, quantity,
@@ -372,7 +372,7 @@ Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) st
372372
### State / data accounts
373373

374374
- `Market`: PDA yes, seeds `["market", base_mint, quote_mint]`, authority program, holds fee rate, tick size, min order size, base/quote mint pubkeys, vault pubkeys, order book pubkey, `authority` wallet (allowed to withdraw fees)
375-
- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `has_one`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id`
375+
- `OrderBook`: PDA no (client-allocated at a public key the client generates), seeds n/a: too large (~180 KB) for an `init`/CPI PDA, so created via `create_account` (which needs a signing key a PDA lacks); tied to its market via `has_one`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 512 orders each: every order after the first adds a leaf and an inner node to a 1024-node tree), `next_order_id`
376376
- `Order`: PDA yes, seeds `["order", market, order_id.to_le_bytes()]`, authority program, holds owner, side, price, original_quantity, filled_quantity, status, timestamp
377377
- `MarketUser`: PDA yes, seeds `["market_user", market, owner]`, authority program, holds `unsettled_base`, `unsettled_quote`, `open_orders: Vec<u64>` (max 20)
378378

@@ -627,6 +627,20 @@ remaining_accounts[2*i] = maker_order_pda (Order account)
627627
remaining_accounts[2*i + 1] = maker_user_account_pda (MarketUser)
628628
```
629629

630+
If the order will rest on a side that is already full, the side's
631+
worst-priced order follows the maker pairs, so the program can evict it
632+
(see the checks before resting, below):
633+
634+
```
635+
remaining_accounts[2*fills] = evicted_order_pda (Order account)
636+
remaining_accounts[2*fills + 1] = evicted_user_account_pda (MarketUser)
637+
```
638+
639+
When the worst order is the caller's own, pass only the `Order`
640+
account: the caller's `MarketUser` is already `market_user`, and the
641+
program credits the refund there. The Anchor v2 copy has the same rule,
642+
because Anchor v2 refuses the same writable account twice.
643+
630644
If the caller doesn't pass any pairs, the order is treated as
631645
pure-maker: whatever part of it is allowed by the book state becomes a
632646
resting order.
@@ -639,7 +653,7 @@ resting order.
639653
- `quantity >= min_order_size` → `BelowMinOrderSize`
640654
- `open_orders.len() < 20` (mirror of the max_len on the struct) →
641655
`TooManyOpenOrders`
642-
- `remaining_accounts.len() % 2 == 0` → `MissingMakerAccounts`
656+
- `remaining_accounts.len() >= 2 * fills` → `MissingMakerAccounts`
643657

644658
**Checks (per maker pair, during planning):**
645659

@@ -658,8 +672,15 @@ resting order.
658672

659673
**Checks (before resting remainder):**
660674

661-
- the taker's side of the book isn't at its 1024-leaf capacity →
662-
`OrderBookFull`
675+
- If the taker's side already holds its 512 orders, the remainder
676+
must beat that side's worst price (a higher bid or a lower ask;
677+
equal is not better, because the resting order was there first) →
678+
`OrderBookFull`. When it does, the worst order is **evicted**:
679+
- The caller passed that order, and its owner's `MarketUser` →
680+
`MissingEvictedAccounts`
681+
- The passed order is the worst order on this market, and the
682+
`MarketUser` belongs to its owner on this market →
683+
`EvictedAccountMismatch`
663684
- Integer math throughout: every multiplication uses
664685
`checked_mul`; every addition on balances uses `checked_add`;
665686
every product of two `u64` money values is computed in `u128`
@@ -737,6 +758,14 @@ On `order_book`:
737758
- Taker's remainder (if any) inserted into the correct side in price
738759
order
739760
761+
On an evicted order (only when the taker's side was full), exactly as
762+
if its owner had called `cancel_order`:
763+
764+
- Its owner's `unsettled_quote += price * remaining_quantity` (bid) or
765+
`unsettled_base += remaining_quantity` (ask)
766+
- Removed from the book and from its owner's `open_orders`
767+
- `status = Cancelled`
768+
740769
On the caller's new `order`:
741770
742771
- All fields populated
@@ -1286,7 +1315,9 @@ From [`errors.rs`](programs/order-book/src/errors.rs):
12861315
- `OrderNotFound`: `cancel_order` failed to locate the order in the book (sanity path)
12871316
- `MarketPaused`: `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there)
12881317
- `Unauthorized`: `cancel_order` by someone other than the order owner
1289-
- `OrderBookFull`: `place_order` remainder would push the taker's side past 1024 leaves
1318+
- `OrderBookFull`: `place_order` remainder would rest on a side holding 512 orders without beating that side's worst price
1319+
- `MissingEvictedAccounts`: A full side, and the worst resting order (with its owner's MarketUser) was not passed after the maker pairs
1320+
- `EvictedAccountMismatch`: The order passed for eviction is not the side's worst, or the MarketUser passed is not its owner's
12901321
- `TooManyOpenOrders`: User already has 20 open orders on this market
12911322
- `InvalidTickSize`: `tick_size == 0` at init, or `price % tick_size != 0` on place
12921323
- `BelowMinOrderSize`: `min_order_size == 0` at init, or `quantity < min_order_size` on place
@@ -1295,7 +1326,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs):
12951326
- `InvalidFeeBasisPoints`: `fee_basis_points > 10_000` at init
12961327
- `InvalidFeeVault`: `market.fee_vault` on the struct does not match the passed `fee_vault` (Anchor `has_one`)
12971328
- `MakerAccountMismatch`: Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order
1298-
- `MissingMakerAccounts`: `remaining_accounts.len()` not a multiple of 2
1329+
- `MissingMakerAccounts`: Fewer remaining accounts than two per planned fill
12991330
- `MakerOwnerMismatch`: Maker Order and MarketUser have different owners
13001331
- `NotMarketAuthority`: `withdraw_fees` called by wrong signer
13011332
@@ -1358,10 +1389,19 @@ From [`errors.rs`](programs/order-book/src/errors.rs):
13581389
`fee_vault` account without re-checking its mint or authority.
13591390
13601391
- **Book capacity check after matching.** The taker's remainder
1361-
check happens at the end. A bid that clears enough asks to free
1362-
up 3 slots can then rest its own 1-slot remainder even on a
1363-
previously-full book - matching the "liquidity-positive" spirit
1364-
of an order book.
1392+
check happens at the end. Matching removes orders from the other
1393+
side only, so a full side stays full; the remainder then rests only
1394+
by evicting that side's worst order.
1395+
1396+
- **A full side evicts rather than refuses.** Each side holds 512
1397+
orders. Without eviction, anyone willing to lock the minimum order
1398+
size and pay rent 512 times could fill a side with orders far from
1399+
the spread and hold it, and every new order on that side would be
1400+
refused for as long as they liked. With eviction, an order that
1401+
beats the side's worst price removes that order and takes its slot,
1402+
so the orders that go are the ones least likely to fill, and the
1403+
market stays open at the prices that trade. The evicted order is
1404+
refunded the same way a cancel is, through `unsettled_*`.
13651405
13661406
### 6.3 Things this example does *not* do
13671407
@@ -1488,6 +1528,14 @@ test taker_partially_fills_resting_order_rest_stays_on_book ... ok
14881528
- `authority_can_withdraw_fees_after_match`: Fee drain after fills, authority-gated
14891529
- `settle_funds_after_match_pays_out_both_unsettled_balances`: Both legs paid in one call
14901530

1531+
**Eviction (a full side, 512 bids):**
1532+
1533+
- `full_side_refuses_an_order_no_better_than_its_worst`: A worse or equal bid gets `OrderBookFull`
1534+
- `better_order_evicts_the_worst_and_rests`: The worst bid is Cancelled and refunded, the new bid rests, the side stays full
1535+
- `evicted_maker_settles_their_refund`: The evicted owner's `settle_funds` pays out the refund
1536+
- `eviction_rejects_missing_or_wrong_evicted_accounts`: No evicted order, a non-worst order, or the wrong owner's `MarketUser`
1537+
- `trader_can_evict_their_own_worst_order`: Only the `Order` account is passed, and the caller's own `MarketUser` is credited
1538+
14911539
### CI note
14921540

14931541
The repo's `.github/workflows/anchor-v1.yml` runs `anchor build` before

‎finance/order-book/anchor-v1/programs/order-book/src/errors.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,10 @@ pub enum ErrorCode {
7070

7171
#[msg("Order book account does not match the market's order book")]
7272
InvalidOrderBook,
73+
74+
#[msg("Book side is full: pass the worst resting order and its owner's MarketUser to evict it")]
75+
MissingEvictedAccounts,
76+
77+
#[msg("Evicted order provided is not the worst resting order on the full side")]
78+
EvictedAccountMismatch,
7379
}

‎finance/order-book/anchor-v1/programs/order-book/src/instructions/cancel_order.rs‎

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use anchor_lang::prelude::*;
22

33
use crate::errors::ErrorCode;
44
use crate::state::{
5-
remaining_quantity, remove_open_order, Market, Order, OrderBook, OrderSide, OrderStatus,
6-
MarketUser, ORDER_SEED, MARKET_USER_SEED,
5+
credit_unfilled_lock, remove_open_order, Market, Order, OrderBook, OrderStatus, MarketUser,
6+
ORDER_SEED, MARKET_USER_SEED,
77
};
88

99
pub fn handle_cancel_order(context: Context<CancelOrderAccountConstraints>) -> Result<()> {
@@ -22,38 +22,11 @@ pub fn handle_cancel_order(context: Context<CancelOrderAccountConstraints>) -> R
2222
// Funds the order had locked in the vault are now owed back to the
2323
// owner. Credit the appropriate unsettled balance; settle_funds moves
2424
// those funds from the vault to the owner's token account.
25-
let remaining = remaining_quantity(order);
26-
if remaining > 0 {
27-
let market_user = &mut context.accounts.market_user;
28-
match order.side {
29-
OrderSide::Bid => {
30-
// u128 intermediates mirror the bid-lock formula in place_order:
31-
// raw_quote = price × remaining × quote_lot_size
32-
let quote_amount: u64 = (order.price as u128)
33-
.checked_mul(remaining as u128)
34-
.ok_or(ErrorCode::NumericalOverflow)?
35-
.checked_mul(context.accounts.market.quote_lot_size as u128)
36-
.ok_or(ErrorCode::NumericalOverflow)?
37-
.try_into()
38-
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
39-
market_user.unsettled_quote = market_user
40-
.unsettled_quote
41-
.checked_add(quote_amount)
42-
.ok_or(ErrorCode::NumericalOverflow)?;
43-
}
44-
OrderSide::Ask => {
45-
let base_amount: u64 = (remaining as u128)
46-
.checked_mul(context.accounts.market.base_lot_size as u128)
47-
.ok_or(ErrorCode::NumericalOverflow)?
48-
.try_into()
49-
.map_err(|_| error!(ErrorCode::NumericalOverflow))?;
50-
market_user.unsettled_base = market_user
51-
.unsettled_base
52-
.checked_add(base_amount)
53-
.ok_or(ErrorCode::NumericalOverflow)?;
54-
}
55-
}
56-
}
25+
credit_unfilled_lock(
26+
&context.accounts.market,
27+
order,
28+
&mut context.accounts.market_user,
29+
)?;
5730

5831
// Remove the leaf from the slab. The current cancel API doesn't tell us
5932
// which side the order is on without reading the Order PDA - which we

‎finance/order-book/anchor-v1/programs/order-book/src/instructions/place_order.rs‎

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use anchor_spl::token_interface::{
55

66
use crate::errors::ErrorCode;
77
use crate::state::{
8-
add_open_order, plan_fills, remove_open_order, Market, Order, OrderBook, OrderSide,
9-
OrderStatus, MarketUser, MARKET_SEED, ORDER_SEED, MARKET_USER_SEED,
8+
add_open_order, credit_unfilled_lock, plan_fills, remove_open_order, Market, Order, OrderBook,
9+
OrderSide, OrderStatus, MarketUser, MARKET_SEED, ORDER_SEED, MARKET_USER_SEED,
1010
};
1111

1212
// Mirror of MarketUser.open_orders max_len. Kept as a constant so the
@@ -23,8 +23,27 @@ const BASIS_POINTS_DENOMINATOR: u128 = 10_000;
2323
// unsettled_* balance - the maker drains them later via settle_funds. This
2424
// mirrors how Openbook v2 works and keeps the per-fill account footprint
2525
// small.
26+
//
27+
// When the order will rest on a side that is already full, the side's
28+
// worst-priced order follows the maker pairs: [evicted_order,
29+
// evicted_market_user]. When the worst order is the caller's own, only
30+
// [evicted_order] is passed and the caller's `market_user` is credited, as in
31+
// the Anchor v2 copy (where Anchor refuses a writable account that appears
32+
// twice). Here that rule also keeps a second loaded copy of the caller's
33+
// MarketUser from being overwritten when the instruction's accounts are
34+
// written back. See the eviction step below.
2635
const ACCOUNTS_PER_MAKER: usize = 2;
2736

37+
/// True when `price` is a better price than `other` for an order on `side`:
38+
/// higher for a bid, lower for an ask. Equal is not better, because at equal
39+
/// price the resting order has time priority.
40+
fn is_better_price(side: OrderSide, price: u64, other: u64) -> bool {
41+
match side {
42+
OrderSide::Bid => price > other,
43+
OrderSide::Ask => price < other,
44+
}
45+
}
46+
2847
pub fn handle_place_order<'info>(
2948
context: Context<'info, PlaceOrderAccountConstraints<'info>>,
3049
side: OrderSide,
@@ -108,11 +127,10 @@ pub fn handle_place_order<'info>(
108127
// transaction's remaining_accounts, in the same price-time-priority
109128
// order the book would walk. We plan fills against the resting tree,
110129
// then verify the caller's account list matches the plan, then apply.
130+
// The list need not be a whole number of pairs: an order evicting the
131+
// caller's own worst order ends with a single account (see
132+
// ACCOUNTS_PER_MAKER).
111133
let maker_accounts = &context.remaining_accounts;
112-
require!(
113-
maker_accounts.len() % ACCOUNTS_PER_MAKER == 0,
114-
ErrorCode::MissingMakerAccounts
115-
);
116134

117135
let order_book_loader = &context.accounts.order_book;
118136

@@ -366,6 +384,73 @@ pub fn handle_place_order<'info>(
366384
.checked_add(taker_quote_received)
367385
.ok_or(ErrorCode::NumericalOverflow)?;
368386

387+
// ---------------------------------------------------------------
388+
// Eviction. A full side would otherwise refuse every new resting order,
389+
// so anyone with the capital could hold all of its slots with orders
390+
// nobody will fill. Instead, an order that beats the side's worst price
391+
// removes that worst order and takes its slot. The evicted order is
392+
// treated exactly like a cancel: its unfilled lock is credited to its
393+
// owner's unsettled balance, and it is stamped Cancelled. An order that
394+
// does not beat the worst price is still refused with OrderBookFull.
395+
// ---------------------------------------------------------------
396+
let order_to_evict = if taker_remaining > 0 {
397+
let order_book = order_book_loader.load()?;
398+
if order_book.is_side_full(side) {
399+
let worst = order_book.worst(side).ok_or(ErrorCode::OrderBookFull)?;
400+
require!(
401+
is_better_price(side, price, worst.price),
402+
ErrorCode::OrderBookFull
403+
);
404+
Some(worst)
405+
} else {
406+
None
407+
}
408+
} else {
409+
None
410+
};
411+
412+
if let Some(worst) = order_to_evict {
413+
let evicted_index = fills.len() * ACCOUNTS_PER_MAKER;
414+
let evicted_order_info = maker_accounts
415+
.get(evicted_index)
416+
.ok_or(ErrorCode::MissingEvictedAccounts)?;
417+
let mut evicted_order = Account::<Order>::try_from(evicted_order_info)?;
418+
require!(
419+
evicted_order.order_id == worst.order_id && evicted_order.market == market.key(),
420+
ErrorCode::EvictedAccountMismatch
421+
);
422+
423+
if evicted_order.owner == context.accounts.owner.key() {
424+
// The worst order is the taker's own: credit their `market_user`,
425+
// which the instruction writes back when it finishes.
426+
credit_unfilled_lock(market, &evicted_order, taker_market_user)?;
427+
remove_open_order(taker_market_user, evicted_order.order_id);
428+
} else {
429+
let evicted_user_info = maker_accounts
430+
.get(evicted_index + 1)
431+
.ok_or(ErrorCode::MissingEvictedAccounts)?;
432+
let mut evicted_market_user = Account::<MarketUser>::try_from(evicted_user_info)?;
433+
require!(
434+
evicted_market_user.owner == evicted_order.owner
435+
&& evicted_market_user.market == market.key(),
436+
ErrorCode::EvictedAccountMismatch
437+
);
438+
credit_unfilled_lock(market, &evicted_order, &mut evicted_market_user)?;
439+
remove_open_order(&mut evicted_market_user, evicted_order.order_id);
440+
evicted_market_user.exit(context.program_id)?;
441+
}
442+
443+
{
444+
let mut order_book = order_book_loader.load_mut()?;
445+
require!(
446+
order_book.remove_from(side, worst.order_id).is_some(),
447+
ErrorCode::OrderNotFound
448+
);
449+
}
450+
evicted_order.status = OrderStatus::Cancelled;
451+
evicted_order.exit(context.program_id)?;
452+
}
453+
369454
// ---------------------------------------------------------------
370455
// Stamp the taker's Order PDA. Either Filled (no remainder) or rested.
371456
//

0 commit comments

Comments
 (0)