Skip to content

feat(defi/order-book): add order-book example with critbit slab matching engine#34

Closed
mikemaccana-edwardbot wants to merge 13 commits into
quicknode:mainfrom
mikemaccana:order-book-slab-critbit
Closed

feat(defi/order-book): add order-book example with critbit slab matching engine#34
mikemaccana-edwardbot wants to merge 13 commits into
quicknode:mainfrom
mikemaccana:order-book-slab-critbit

Conversation

@mikemaccana-edwardbot

@mikemaccana-edwardbot mikemaccana-edwardbot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an order book — specifically, a central limit order book (CLOB) — example program at defi/order-book/anchor/. Complete Anchor 1.0 implementation with a critbit-slab order book ported from Openbook v2, a price-time priority matching engine, 24 LiteSVM Rust tests, and a README pitched at the same level as the rest of this repo.

What's in it

Program (defi/order-book/anchor/programs/order-book/)

Six instruction handlers:

  • initialize_market — creates a market for a base/quote token pair, with market-owned base, quote, and fee vaults.
  • create_market_user — per-(user, market) PDA tracking open orders and unsettled balances on that market.
  • place_order — limit order with price-time priority matching. Walks maker orders via remaining_accounts to fill against the existing book; the residual rests on the order book.
  • cancel_order — owner-only cancel, returns locked funds to unsettled balance.
  • settle_funds — withdraws unsettled base/quote from the vault to the user's token accounts.
  • withdraw_fees — market authority sweeps accumulated taker fees from the fee vault.

Market carries has_one constraints binding the base/quote/fee vaults and mints to their stored addresses, blocking account-substitution drains (e.g. passing fee_vault as quote_vault on settle).

Money math

  • All balance arithmetic is checked_* — no raw + - * / in money paths.
  • Every product of two u64 money values is computed in u128 and narrowed back to u64 with try_into, so a multiplication that transiently exceeds u64 doesn't get refused at the math step when the final value fits a u64 balance.
  • Per-fill fee calculation enforces fee_quote <= gross_quote as a defence-in-depth invariant after the math.
  • All token movements use transfer_checked (decimals enforced in the CPI).
  • settle_funds follows checks-effects-interactions: unsettled counters are zeroed before the transfer CPIs.

Order book

The order book is a per-side critbit (binary radix trie) sitting in a zero-copy slab account. Ported from openbook-dex/openbook-v2 — see programs/order-book/src/state/slab/LICENSE-OPENBOOK for attribution. The slab is a fixed-capacity array of nodes with a free-list, so inserts and removals are constant-cost and book depth is bounded.

A critbit is balanced by construction — its depth is bounded by the bit width of the sort key (128 bits here, combining price and sequence number), not by insertion order. This matters because an attacker who could choose the tree's shape via the prices of their orders could otherwise degrade matching to O(N) and trigger place_order transactions to abort mid-match against Solana's compute budget. The README's §8 "Why a balanced tree" walks through this with the concrete monotonic-price-attack example.

MarketUser (per-user, per-market account)

Per-(user, market) rather than per-user because:

  1. Unsettled balances are per-market by definition — different mints per pair, can't share scalar fields.
  2. Open-order indexing is local to one book.
  3. Per-market lock-contention isolation lets a user trade multiple pairs in parallel without serialising on a shared account.

Matches Openbook v2's OpenOrdersAccount, Phoenix's Trader, Serum's OpenOrders.

Tests (programs/order-book/tests/test_order_book.rs)

24 LiteSVM integration tests covering order locking, matching, fee accrual, cancellation, settlement, fee withdrawal, and the critbit slab's invariants under random op sequences.

test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

README (defi/order-book/anchor/README.md)

Written to the repo-wide standard:

  • Opens with finance context — an order book is standard market infrastructure (NYSE, NASDAQ, LSE, CME, every major crypto venue). Openbook v2 and Phoenix linked as Solana-native references.
  • Explains the program flow end-to-end: who calls what, what PDAs and vaults get created, where tokens move.
  • "Why a balanced tree (critbit)?" section explains the monotonic-attacker DoS and why critbit's bit-bounded depth makes it structurally impossible.
  • "Why per-(user, market)?" section explains the MarketUser scoping.
  • Solana terms explained on their own terms (no Ethereum analogies).
  • "Token" not "SPL Token". "Address" not "Pubkey" for non-keypair things. "Instruction handler" when referring to code, "instruction" when referring to the call.
  • onchain / offchain to match the repo-wide convention.

Terminology rule (defi/order-book/anchor/TERMINOLOGY.md)

Project-local style note that disambiguates overloaded terms throughout the README and code comments:

  • balance: token/account balance vs tree balancing — use "tree balancing" / "balanced tree shape" when the tree is in scope.
  • order/ordering: trading order vs sort order — prefer "sorted by price" over "in price order".
  • settle: be specific (settle_funds instruction vs transaction settlement).
  • key: use "address" / "public key" / "sort key", never bare "key".
  • node: qualify (validator node vs tree/slab node).
  • position: qualify (trading position vs slot position / array index).

Verification

Locally, from defi/order-book/anchor/:

anchor keys sync && anchor build && cargo test --manifest-path programs/order-book/Cargo.toml

All 24 tests pass. cargo check is clean modulo two pre-existing lifetime-elision warnings inherited from the upstream Openbook v2 slab.

mikemaccana-edwardbot and others added 13 commits May 19, 2026 17:54
…ized-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.
…conventions

Bring the CLOB example in line with the repo's Anchor 1.0 + LiteSVM Rust
test convention established by tokens/escrow and defi/asset-leasing.
Previously CLOB shipped a TypeScript suite backed by Codama-generated
clients and @solana/kit, which was a one-off in the current examples
set.

Why:
- Every other defi/*/anchor and the tokens/escrow example now uses
  LiteSVM-driven Rust tests that `include_bytes!` the built .so,
  share a common test-stack (litesvm + solana-kite + solana-signer),
  and run under a plain `cargo test`. Contributors moving between
  examples had to relearn the CLOB harness (Codama client generation,
  surfpool-vs-legacy validator selection, tsx + node:test runner).
- The JS suite also required `anchor test --validator legacy` because
  Anchor 1.0's default surfpool validator does not expose the
  websocket RPC methods Kit uses for confirmation. Switching to
  LiteSVM Rust sidesteps that entirely — no validator at all.

Changes:
- Anchor.toml: drop anchor_version, package_manager, [hooks], [test]
  blocks. Set `[scripts] test = "cargo test"` matching asset-leasing.
  Keep solana_version = 3.1.8 pinned so BPF toolchain stays in lock-step.
- programs/clob/Cargo.toml: add [dev-dependencies] for litesvm 0.11,
  solana-signer 3.0, solana-keypair 3.0.1, solana-kite 0.3. Versions
  match the rest of the repo to avoid drift.
- programs/clob/tests/test_clob.rs: 13 LiteSVM tests covering
  initialize_market (happy path + zero-tick + oversized-fee rejection),
  create_user_account, place_order (bid locks quote, ask locks base,
  zero-price / unaligned-tick / below-min rejections), cancel_order
  (owner refund credited to unsettled, non-owner rejected), and
  settle_funds (drains unsettled balance from vault to user ATA for
  both an ask cancel and a bid cancel + full refund round-trip).
- programs/clob/src/lib.rs and instructions/*.rs: rename the verbose
  `*AccountConstraints` struct suffix to the plain `InitializeMarket`,
  `PlaceOrder`, etc. naming that every other Anchor example in the
  repo uses. Rename handler functions from bare `initialize_market` to
  `handle_initialize_market` to match escrow and asset-leasing.
- README.md: replace the TS/Codama test instructions with the Rust
  LiteSVM flow; drop the surfpool caveat (no longer relevant).
- Remove tests/clob.test.ts, package.json, tsconfig.json: no JS/TS
  scaffolding needed now that testing is pure Rust.
- .gitignore: drop the now-unused `dist` entry (the Codama client
  output directory).

Scope note carried into the tests: the program does not run a
matching engine, it only keeps the book and escrow the funds. The
test file's header comment calls this out so the reader does not
look for cross-order settlement tests that cannot exist yet.

Test result: `anchor build && cargo test` → 13 passed, 0 failed.
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.
The previous README was a ~78-line stub. This version describes the
program as it actually exists today, including the matching engine
landed in ea960844.

Structure matches the rest of the overhaul:

  1. What does this program do? (onchain mechanics first, with
     tradfi terms — limit order, order book, maker/taker, price-time
     priority — briefly explained in plain English before they get
     used)
  2. Glossary (account, PDA, CPI, bps, bid/ask, tick size,
     unsettled balance, price improvement, remaining_accounts, etc.)
  3. Accounts and PDAs (four program PDAs + three vaults; full
     field lists; note the vaults are not PDAs, they are regular
     token accounts whose authority is the Market PDA)
  4. Instruction lifecycle walkthrough (six instructions, in the
     order a user encounters them; per-ix signers / accounts / PDAs
     created / token-flow diagrams / state changes / checks)
  5. The matching engine — step by step (the critical section:
     how place_order uses remaining_accounts; the plan/apply/clean/
     fee/rest five-step structure; fee math; price improvement;
     worked fill walkthroughs including a multi-maker sweep)
  6. Full-lifecycle worked examples (clean match + settle,
     partial fill + remainder, cancel + settle round trip)
  7. Safety and edge cases (full error table; guarded design
     choices; what the example does NOT do)
  8. Running the tests (all 23 tests listed and categorised; CI
     note confirming anchor build runs before anchor test)
  9. Extending the program (easy / moderate / harder)

1433 lines. No code changes.
Five consistent lessons from earlier reviews, applied to the CLOB program.

1. 'Token' not 'SPL Token' — tokens are the default on Solana, no qualifier
   is needed unless specifically contrasting with native SOL. Replaced 'SPL
   Token' / 'SPL token' throughout README, state/market.rs, and tests.
   In tests, 'classic SPL Token vs Token-2022' becomes 'Classic Token
   Program vs Token Extensions Program' — more precise and drops the SPL
   prefix that's noise.

2. Glossary removed. The old section enumerated every Solana term (Account,
   Lamport, Signer, PDA, Bump, CPI, ...) which duplicates what
   https://solana.com/docs/terminology already covers. Replaced with a
   one-line pointer there, plus a short inline 'Terms' block that defines
   only genuinely CLOB-specific vocabulary (base/quote, tick size,
   unsettled balance, fee vault, price improvement, remaining accounts).

3. No Ethereum references. Old README described an SPL token as 'Solana's
   ERC-20 equivalent'. Removed — explain Solana on its own terms.

4. Accurate finance framing. Added a sentence at the top noting that a
   CLOB is the same matching mechanism every major equity / futures / FX
   / crypto exchange (NYSE, NASDAQ, LSE, CME, Binance, Coinbase, Openbook,
   Phoenix) uses. Previously the README framed the program as 'two users
   who want to swap tokens' — accurate but understated. A CLOB is real
   finance infrastructure and the README now says so. Renamed the
   'Tradfi background' subsection to 'Finance background'.

5. 'Instruction handler' not 'instruction' when referring to the code.
   An instruction is the call data submitted in a transaction; the
   instruction handler is the Rust function that processes it. Updated
   'the program has six instructions', 'later instructions can validate',
   'no instruction flips this', 'close in the same instruction', etc.
   Phrasing like 'a user calls the place_order instruction' is left
   alone because it genuinely refers to the call.

Additional cleanups bundled in:
- 'on-chain' / 'off-chain' → 'onchain' / 'offchain' in README, matching
  the repo-wide normalisation in commit fa93ce0.
- 'SPL Token program' → 'Token program' in CPI descriptions.
- 'SPL token accounts' → 'token accounts' in the vaults section.
- Code comment in place_order.rs describes basis points as 'the universal
  rate convention on every major exchange' instead of the vague
  'TradFi and CEXes'.
- Code comment in market.rs: 'program instructions can drain it' →
  'program instruction handlers can drain it'.
- initialize_market.rs: 'Basis-points' → 'Basis points' (punctuation).

Section numbers in the README renumbered (2/3→2, 4→3, 5→4, ...) after
the Glossary removal; all cross-references updated.

Quasar port: NOT included in this change. Quasar's account macros
require fixed-layout structs (it does not support Vec<T> fields on
#[account] structs — see basics/favorites/quasar/src/state.rs for the
explicit call-out). The CLOB's OrderBook is a Vec<OrderEntry> pair and
UserAccount has a Vec<u64> open_orders, both of which rely on dynamic
insertion and removal. On top of that, place_order iterates through
remaining_accounts to deserialize and mutate multiple maker Order /
UserAccount PDAs per call — a pattern none of the existing Quasar
examples (escrow, token-swap, counter) demonstrate. Porting would need
a fresh fixed-capacity OrderBook design and a new cross-account
mutation pattern. Left as a follow-up so the terminology and finance
framing fixes ship first; a separate PR can tackle the Quasar port
once the architectural approach is agreed.

Tests: all 23 existing LiteSVM tests in programs/clob/tests/test_clob.rs
still pass locally after the sweep.
…PlaceOrder

Before this fix, SettleFunds and PlaceOrder only constrained the market's
fee_vault via has_one; they did not bind base_vault, quote_vault,
base_mint, or quote_mint to the addresses recorded on the Market PDA at
initialize_market time.

Because the fee_vault is a token account on the same mint and with the
same authority (market PDA) as the quote_vault, a caller with a positive
unsettled_quote balance could call SettleFunds and pass market.fee_vault
where quote_vault was expected. transfer_checked only verifies mint and
authority on the source account, not its identity, so the CPI would
succeed and drain accumulated taker fees to the attacker's own quote
ATA. The same gap existed on PlaceOrder for base_vault / quote_vault /
base_mint / quote_mint.

Fix: add has_one constraints on the market field in both SettleFunds and
PlaceOrder so Anchor rejects any mismatched address with ConstraintHasOne
(anchor error 2001) before any transfer runs. New error variants
(InvalidBaseVault, InvalidQuoteVault, InvalidBaseMint, InvalidQuoteMint)
mirror the existing InvalidFeeVault style so the failure is
self-describing.

Adds a LiteSVM regression test
(settle_funds_rejects_fee_vault_substituted_for_quote_vault) that earns
a buyer some unsettled_quote, then tries the attack and asserts the
transaction fails.
Canonical URL is solana.com/docs/references/terminology, not
solana.com/docs/terminology (which redirects).
…arketUser

Replaces the per-side Vec<OrderEntry> order-book backing store with a
critbit (binary radix trie) slab ported from openbook-dex/openbook-v2.
The slab gives the order book O(log N) inserts/deletes/lookups whose
worst case is bounded by the price-key bit width (128 bits), not by
insertion order, so an adversary picking monotonically-increasing prices
can no longer degenerate the book into a linked list.

Why critbit specifically (not red-black): the upstream Openbook v2 slab
already uses critbit, both shapes give the bounds we need, and the
critbit invariants are simpler to port (no recolouring, no rotations).
See the README's new "Why a balanced tree (critbit)?" section for the
DoS framing.

Companion rename: UserAccount → MarketUser

The per-(user, market) state struct was previously named UserAccount,
which falsely suggested a per-user record. Renamed throughout so the
name matches what it scopes to:

  - struct UserAccount → struct MarketUser
  - Accounts context CreateUserAccount → CreateMarketUser
  - PDA seed b"user" → b"market_user"; constant USER_ACCOUNT_SEED →
    MARKET_USER_SEED (breaking change — pre-merge, no live PDAs to
    migrate)
  - File state/user_account.rs → state/market_user.rs
  - File instructions/create_user_account.rs →
    instructions/create_market_user.rs
  - Handler fn create_user_account → create_market_user
  - Variable/field names like buyer_user_account, maker_user_account,
    taker_user_account → buyer_market_user, maker_market_user, etc.
  - README, tests, error messages all updated.

User-owned ATA names (user_base_account, user_quote_account) are NOT
renamed — they're token accounts owned by the human user, not program
state, so the 'user' prefix is correct there. The 'user' signer arg
also stays as 'user'.

Matches the standard pattern: Openbook v2 calls this OpenOrdersAccount,
Phoenix calls it Trader, Serum called it OpenOrders. We use MarketUser
to make the (user, market) scope explicit in the name.

Documentation: new README section §2 'Why per-(user, market) and not
per-user?' (unsettled balances, open-order indexing, lock-contention
isolation). New README section §8 'Why a balanced tree (critbit)?'
with the monotonic-attacker example and Solana CU/DoS framing.

Terminology rule: new TERMINOLOGY.md documents the project-wide rule
to disambiguate overloaded terms in README and code comments — balance
(token vs tree), order vs ordering, key, node, position, settle — so
future contributors don't reintroduce the ambiguity this PR is
removing.

Tests: 24/24 pass under cargo test (LiteSVM).
Every other example in defi/ uses plain words (amm, escrow, token-swap).
"clob" was the only opaque acronym. Renamed to "order-book" so the
directory listing reads cleanly; "CLOB" stays as the term inside the
README and code comments where the explanation makes it meaningful.

Renames applied:

  - Directory  defi/clob/                 → defi/order-book/
  - Directory  programs/clob/             → programs/order-book/
  - Cargo      name = "clob"              → name = "order_book"
  - Cargo lib  name = "clob"              → name = "order_book"
  - Anchor.toml [programs.localnet] clob = → order_book =
  - Rust mod   pub mod clob {…}           → pub mod order_book {…}
  - Test file  tests/test_clob.rs         → tests/test_order_book.rs
  - SBF .so    target/deploy/clob.so      → target/deploy/order_book.so
  - Keypair    clob-keypair.json          → order_book-keypair.json
                (same key bytes — program ID is unchanged)
  - Top-level repo README link to ./defi/clob/anchor → ./defi/order-book/anchor

README first-mention pattern updated: now leads with "**order book** —
specifically a **central limit order book (CLOB)**, …", subsequent
uses of "CLOB" as a term are preserved. Path references and code-block
commands in the README updated to the new layout.

Program ID stays at C69UJ8irfmHq5ysyLek7FKApHR86FBeupiz4JnoyPzzx — only
the names around it changed.

Tests: 24/24 pass under cargo test (LiteSVM).
Audit pass to align with the project rule: use "order book" / "order-book"
by default; reserve "CLOB" for the introductory acronym definition, the
H1 heading (searchability for finance-literate readers), the Cargo
package description first-mention, and ecosystem-mapping references
to other CLOBs (Phoenix, Openbook v2).

Replaced "CLOB" with "order book" / "order-book" in:

  - README §1 "What does this program do?" passing reference ("runs an
    onchain order book for a single pair of token mints")
  - README §6.2 "This is the standard order-book rule"
  - README §6.2 "...of an order book."
  - README §6.3 "A production order book would add:"
  - README §8 "For a production order book it's wrong"
  - README §8 "In an order book an attacker chooses the inputs"
  - test_order_book.rs doc-comment header, scenario comments, and
    create_market_user / matching engine comments
  - matching.rs "standard order-book rule"
  - slab/nodes.rs ("this order book is fixed-price only")
  - slab/ordertree.rs ("Callers in this order book embed a...")
  - TERMINOLOGY.md title and the "balance" example phrasing

Kept as "CLOB":

  - README H1 "Order Book \u2014 Central Limit Order Book (CLOB)" (searchability)
  - README §1 first-mention teaching pattern
  - README §1 "production Solana CLOBs to read alongside it" (ecosystem)
  - README §1 "Real Solana CLOBs (Openbook v2, Phoenix)" (ecosystem)
  - place_order.rs comment "Real CLOBs (Openbook v2, Phoenix) use a similar" (ecosystem)
  - Cargo.toml package description (one mention for crates.io searchability)

Tests: 24/24 still pass (comment-only changes).
…u64 overflow

place_order and cancel_order both do price * quantity in u64. u64 * u64
overflows at ~1.8e19 base units \u2014 perfectly reachable once you scale by
token decimals (an 18-decimal quote mint hits it at modest mid-cap prices
and quantities). u64::checked_mul would then refuse a legitimate order
with NumericalOverflow even though the final lock amount fits a u64
balance fine.

Promote both operands to u128 before the multiply, then narrow back to
u64 with try_into. Same pattern the gross_quote-fee-quote chain was
already using \u2014 now used consistently for:

- bid lock (price * quantity)
- per-fill gross_quote (fill_price * fill_quantity)
- per-fill locked_for_this_fill rebate base (price * fill_quantity)
- cancel_order Bid refund (order.price * remaining)

Also: replace order.filled_quantity = quantity.saturating_sub(taker_remaining)
with checked_sub. saturating_* on the matching engine's bookkeeping is a
silent-clamp hazard; if taker_remaining > quantity that's a real bug and
the program should abort, not write a misleading filled_quantity.

Also: add require!(fee_quote <= gross_quote) after the fee calculation
as a defence-in-depth invariant. fee_basis_points is bounded at init,
so the require is unreachable in normal operation \u2014 but a stale bound
assumption would otherwise let a misconfigured market overdraw the
maker's net payout silently.
…nds (CEI)

settle_funds was setting market_user.unsettled_base = 0 and unsettled_quote = 0
*after* the transfer_checked CPIs. Solana CPIs are not reentrant in the
EVM sense, but checks-effects-interactions is still the right discipline:
if either transfer ever gained a path that called back into this program
(custom token hooks, transfer-fee extension with a side effect, a future
ext we don't anticipate), having the unsettled_* counters still readable
mid-transfer would let a re-entry double-withdraw the balance.

Snapshot the amounts into locals, zero the counters, then issue the
transfers. The instruction stays atomic via Solana's tx semantics \u2014 if a
transfer fails the whole tx unwinds, counters and all.

No semantic change in the success path. Tests still pass 24/24.
…er in settle_funds

Two README updates to keep prose in sync with the code:

1. Section on integer math:
   The old line claimed only the fee division uses u128. After the
   widening commit, *every* product of two u64 money values is computed
   in u128 and narrowed back via try_into. Also mention the per-fill
   fee_quote <= gross_quote invariant.

2. Section 3.5 (settle_funds):
   Document that unsettled_* are zeroed before the transfer CPIs, not
   after, and explain why (checks-effects-interactions defence). This
   is the README counterpart to the settle_funds CEI commit.
@mikemaccana

mikemaccana commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Closed, now in #41 . Claude API -> Claude app move.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants