From e0a9498c0ca15b98ce89098bbaa0e5385f936a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:17:18 +0000 Subject: [PATCH 1/2] finance/order-book: make the base, quote, and fee vaults market PDAs initialize_market created the three vaults at public keys the client generated, so opening a market meant generating and signing with three extra keys, and nothing but the market's record said where its tokens were. The vaults are now PDAs of the market, at ["base_vault", market], ["quote_vault", market] and ["fee_vault", market], with the market as their authority: the shape the options and prop AMM vaults already have. The market still records each vault address, and every other handler still checks the vaults it is passed against that record, so the fee-vault-for-quote-vault substitution stays rejected. The order book itself stays client-allocated, since at about 180 KB it is too large for the program to create. Applied to the Anchor v2 and Quasar ports, with the tests deriving the vaults instead of generating keys for them, and the READMEs and changelogs following. The Anchor v1 port is a frozen snapshot and does not change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011VUvZT5384GaSBhhgJwFp3 --- CHANGELOG.md | 15 ++ finance/order-book/anchor/CHANGELOG.md | 14 ++ finance/order-book/anchor/README.md | 42 ++--- .../src/instructions/initialize_market.rs | 10 +- .../programs/order-book/src/state/market.rs | 9 + .../order-book/tests/test_order_book.rs | 155 +++++++----------- finance/order-book/quasar/CHANGELOG.md | 14 ++ finance/order-book/quasar/README.md | 6 +- .../src/instructions/initialize_market.rs | 7 +- finance/order-book/quasar/src/state/market.rs | 21 +++ finance/order-book/quasar/src/tests.rs | 61 ++++--- 11 files changed, 209 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2097ad368..791ff4bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-22] - The order book's vaults are market PDAs + +The order book created its base, quote, and fee vaults at public keys the client +generated, so a client had to generate and sign with three extra keys to open +a market, and nothing but the market's record said where its tokens were. + +- The order book's Anchor v2 and Quasar ports create all three vaults as PDAs + of the market, at `["base_vault", market]`, `["quote_vault", market]` and + `["fee_vault", market]`, with the market as their authority, the same shape + the options and prop AMM vaults already have. The stored-address checks + that stop the fee vault being passed as the quote vault are unchanged. The + order book itself stays client-allocated, since at about 180 KB it is too + large for the program to create. Tests, READMEs, and changelogs follow. The + Anchor v1 port is a frozen snapshot and does not change. + ## [2026-09-14] - Token fundraiser contributors can close their accounts A successful raise exits through `check_contributions`, which closes the vault diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index 4f1163881..ea33d9c8f 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-09-22 + +### Changed + +- The base, quote, and fee vaults are PDAs of the market, at seeds + `["base_vault", market]`, `["quote_vault", market]` and + `["fee_vault", market]`, instead of token accounts at public keys the client + generated. Clients derive the vault addresses instead of generating and + signing with three extra keys. The market still records each address and + every handler still checks the vaults it is passed against that record. + The order book stays a client-allocated account: at about 180 KB it is too + large for the program to create. Tests derive the vaults instead of + generating them. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index 2029f130d..45c51ca24 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -215,9 +215,9 @@ Maria's wallet signs. Five accounts are created: - `Market` PDA: type Program data, seeds `["market", NVDAx_mint, USDC_mint]`, state after `fee_bps=25`, `tick_size=1`, `is_active=true`; vault addresses recorded - `OrderBook`: type Zero-copy slab (~180 KB), seeds Client-allocated (not a PDA), state after Both critbit trees empty -- `base_vault`: type Token account (NVDAx), seeds Authority = Market PDA, state after 0 NVDAx -- `quote_vault`: type Token account (USDC), seeds Authority = Market PDA, state after 0 USDC -- `fee_vault`: type Token account (USDC), seeds Authority = Market PDA, state after 0 USDC +- `base_vault`: type Token account (NVDAx), seeds `["base_vault", market]`, authority Market PDA, state after 0 NVDAx +- `quote_vault`: type Token account (USDC), seeds `["quote_vault", market]`, authority Market PDA, state after 0 USDC +- `fee_vault`: type Token account (USDC), seeds `["fee_vault", market]`, authority Market PDA, state after 0 USDC **No tokens move.** Maria pays the SOL rent for all five accounts. @@ -374,23 +374,25 @@ Alice's remaining 2-NVDAx [bid](https://www.investopedia.com/terms/b/bid.asp) st ### State / data accounts - `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) -- `OrderBook`: PDA no (client-allocated keypair), 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 `address = market.order_book`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id` +- `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 `address = market.order_book`; authority program, holds two critbit trees (bids highest-first, asks lowest-first, 1024 leaves each), `next_order_id` - `Order`: PDA yes, seeds `["order", market, order_id.to_le_bytes()]`, authority program, holds owner, side, price, original_quantity, filled_quantity, status, timestamp - `MarketUser`: PDA yes, seeds `["market_user", market, owner]`, authority program, holds `unsettled_base`, `unsettled_quote`, `open_orders: Vec` (max 20) ### Token accounts (owned by the Token Program, authority = Market PDA) -- `base_vault`: PDA no (regular token account), authority Market PDA, mint base, holds bids' locked base IS NOT STORED HERE - only asks' locked base sits here pre-match, plus base owed to bid-takers waiting for `settle_funds` -- `quote_vault`: PDA no, authority Market PDA, mint quote, holds bids' locked quote pre-match, plus quote owed to ask-takers and bid-makers waiting for settlement -- `fee_vault`: PDA no, authority Market PDA, mint quote, holds taker fees accumulated across all fills; drained by `withdraw_fees` +- `base_vault`: PDA yes, seeds `["base_vault", market]`, authority Market PDA, mint base, holds bids' locked base IS NOT STORED HERE - only asks' locked base sits here pre-match, plus base owed to bid-takers waiting for `settle_funds` +- `quote_vault`: PDA yes, seeds `["quote_vault", market]`, authority Market PDA, mint quote, holds bids' locked quote pre-match, plus quote owed to ask-takers and bid-makers waiting for settlement +- `fee_vault`: PDA yes, seeds `["fee_vault", market]`, authority Market PDA, mint quote, holds taker fees accumulated across all fills; drained by `withdraw_fees` -Note: the **token vaults are not PDAs**. They are regular token -accounts created with `init` in `initialize_market.rs`; their -*authority* is the Market PDA, so only the program can move funds out. -Their addresses are computed by the caller (e.g. generated Keypairs in -the tests) and then written to `market.base_vault` / `quote_vault` / -`fee_vault` for the program to validate them on later calls via -`address = market.fee_vault` etc. +Note: the **token vaults are PDAs of the market**, created with `init` +in `initialize_market.rs` at seeds `["base_vault", market]`, +`["quote_vault", market]` and `["fee_vault", market]`. Their *authority* +is the Market PDA, so only the program can move funds out. Any client can +derive them from the market's address. The market also records each +address, and later instruction handlers validate the vaults they are +passed with `address = market.fee_vault` etc., which is what stops the +fee vault being passed where the quote vault belongs. The order book is +the one market account that is not a PDA. ### Leaf layout in the `OrderBook` slab @@ -550,7 +552,9 @@ pub fn initialize_market( `#[account(zero)]`) - `base_mint`, `quote_mint` (read-only) - `base_vault`, `quote_vault`, `fee_vault` (all **init** as - `TokenAccount`s, authority = `market`) + `TokenAccount`s at seeds `["base_vault", market]`, + `["quote_vault", market]` and `["fee_vault", market]`, + authority = `market`) - `token_program`, `system_program` **Checks:** @@ -568,10 +572,10 @@ the supplied parameters plus all the derived fields (`market.authority`, the vault pubkeys, `is_active = true`, `next_order_id = 1`). -The vaults are regular token accounts, *not* PDAs - their -addresses are chosen by the caller (typically fresh keypairs) and -captured on the market's state so later instruction handlers can -validate them. +The vaults are PDAs of the market, so the caller derives their +addresses rather than choosing them. The market's state records them +too, so later instruction handlers can validate the vaults they are +passed. ### 3.2 `initialize_market_user` diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs index 59e193a84..5050ab1bb 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/initialize_market.rs @@ -3,7 +3,9 @@ use anchor_spl::token; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::errors::ErrorCode; -use crate::state::{Market, OrderBook, MARKET_SEED}; +use crate::state::{ + Market, OrderBook, BASE_VAULT_SEED, FEE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED, +}; // Basis points are hundredths of a percent; 10000 bps == 100%. Fees above 100% // would be nonsensical, so we cap here. @@ -90,6 +92,8 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + seeds = [BASE_VAULT_SEED, market.address().as_ref()], + bump, token::mint = base_mint, token::authority = market, token::token_program = token_program @@ -99,6 +103,8 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, token::mint = quote_mint, token::authority = market, token::token_program = token_program @@ -110,6 +116,8 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + seeds = [FEE_VAULT_SEED, market.address().as_ref()], + bump, token::mint = quote_mint, token::authority = market, token::token_program = token_program diff --git a/finance/order-book/anchor/programs/order-book/src/state/market.rs b/finance/order-book/anchor/programs/order-book/src/state/market.rs index 9198a8d66..a98a54552 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/market.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/market.rs @@ -2,6 +2,15 @@ use anchor_lang::prelude::*; pub const MARKET_SEED: &[u8] = b"market"; +// The three vaults are PDAs of the market: each is found from its own seed +// and the market's address, so any client can derive where the market keeps +// its tokens without reading the market first. The market also records each +// address, and every handler that touches a vault checks it against that +// record, which is what stops the fee vault being passed as the quote vault. +pub const BASE_VAULT_SEED: &[u8] = b"base_vault"; +pub const QUOTE_VAULT_SEED: &[u8] = b"quote_vault"; +pub const FEE_VAULT_SEED: &[u8] = b"fee_vault"; + // A Market is one trading pair (base/quote) with its own vaults and order book. // The market PDA itself is the authority of the token vaults, so funds can only // move out via program-signed CPIs (place/cancel/settle). diff --git a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs index 8ae090130..ae1778bb1 100644 --- a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -37,6 +37,9 @@ use { const MARKET_SEED: &[u8] = b"market"; const ORDER_SEED: &[u8] = b"order"; const MARKET_USER_SEED: &[u8] = b"market_user"; +const BASE_VAULT_SEED: &[u8] = b"base_vault"; +const QUOTE_VAULT_SEED: &[u8] = b"quote_vault"; +const FEE_VAULT_SEED: &[u8] = b"fee_vault"; // Size of the zero-copy OrderBook account, including Anchor's 8-byte // discriminator. Mirrors `order_book::state::ORDER_BOOK_ACCOUNT_SIZE` - duplicated @@ -97,6 +100,12 @@ fn market_pda(program_id: &Address, base_mint: &Address, quote_mint: &Address) - market } +/// The market's vaults are PDAs of the market, one seed each. +fn vault_pda(program_id: &Address, seed: &[u8], market: &Address) -> Address { + let (vault, _) = Address::find_program_address(&[seed, market.as_ref()], program_id); + vault +} + fn market_user_pda(program_id: &Address, market: &Address, owner: &Address) -> Address { let (market_user, _) = Address::find_program_address( &[MARKET_USER_SEED, market.as_ref(), owner.as_ref()], @@ -129,11 +138,11 @@ struct Scenario { seller: Keypair, base_mint: Address, quote_mint: Address, - base_vault: Keypair, - quote_vault: Keypair, - // Fees accumulate here (quote mint). Created fresh per Scenario; the - // market PDA is the signer, same as the other two vaults. - fee_vault: Keypair, + base_vault: Address, + quote_vault: Address, + // Fees accumulate here (quote mint). The market PDA signs transfers out of + // it, same as the other two vaults. + fee_vault: Address, market: Address, // The order book is a ~180 KB zero-copy account owned by the program. // It's NOT a PDA - the BPF runtime caps inner-CPI allocations at 10 KB, @@ -197,11 +206,11 @@ fn full_setup() -> Scenario { let buyer_market_user = market_user_pda(&program_id, &market, &buyer.pubkey()); let seller_market_user = market_user_pda(&program_id, &market, &seller.pubkey()); - // Vaults are plain token accounts created in-line by initialize_market - // (not PDAs). Tests generate fresh keypairs to serve as their addresses. - let base_vault = Keypair::new(); - let quote_vault = Keypair::new(); - let fee_vault = Keypair::new(); + // Vaults are PDAs of the market, created by initialize_market. A client + // derives their addresses; it never chooses them. + let base_vault = vault_pda(&program_id, BASE_VAULT_SEED, &market); + let quote_vault = vault_pda(&program_id, QUOTE_VAULT_SEED, &market); + let fee_vault = vault_pda(&program_id, FEE_VAULT_SEED, &market); let order_book = Keypair::new(); Scenario { @@ -278,9 +287,9 @@ fn build_initialize_market_ix( order_book: sc.order_book.pubkey(), base_mint: sc.base_mint, quote_mint: sc.quote_mint, - base_vault: sc.base_vault.pubkey(), - quote_vault: sc.quote_vault.pubkey(), - fee_vault: sc.fee_vault.pubkey(), + base_vault: sc.base_vault, + quote_vault: sc.quote_vault, + fee_vault: sc.fee_vault, authority: sc.authority.pubkey(), token_program: token_program_id(), system_program: system_program::ID, @@ -330,9 +339,9 @@ fn build_place_order_ix( order_book: sc.order_book.pubkey(), order, market_user, - base_vault: sc.base_vault.pubkey(), - quote_vault: sc.quote_vault.pubkey(), - fee_vault: sc.fee_vault.pubkey(), + base_vault: sc.base_vault, + quote_vault: sc.quote_vault, + fee_vault: sc.fee_vault, user_base_account, user_quote_account, base_mint: sc.base_mint, @@ -393,7 +402,7 @@ fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Address) -> In &order_book::instruction::WithdrawFees {}.data(), order_book::accounts::WithdrawFeesAccountConstraints { market: sc.market, - fee_vault: sc.fee_vault.pubkey(), + fee_vault: sc.fee_vault, authority_quote_account, quote_mint: sc.quote_mint, authority: sc.authority.pubkey(), @@ -437,8 +446,8 @@ fn build_settle_funds_ix( order_book::accounts::SettleFundsAccountConstraints { market: sc.market, market_user, - base_vault: sc.base_vault.pubkey(), - quote_vault: sc.quote_vault.pubkey(), + base_vault: sc.base_vault, + quote_vault: sc.quote_vault, user_base_account, user_quote_account, base_mint: sc.base_mint, @@ -469,13 +478,7 @@ fn initialize_market_and_users(sc: &mut Scenario) { send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ) .unwrap(); @@ -519,13 +522,7 @@ fn initialize_market_sets_market_and_order_book() { send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ) .unwrap(); @@ -549,11 +546,11 @@ fn initialize_market_sets_market_and_order_book() { // Vaults were created with the market as authority; easiest check is // simply that they exist with a zero balance. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), 0 ); assert_eq!( - get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.quote_vault).unwrap(), 0 ); } @@ -574,13 +571,7 @@ fn initialize_market_user_tracks_market_and_owner() { send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ) .unwrap(); @@ -625,7 +616,7 @@ fn place_bid_locks_quote_in_vault() { // A bid locks price * quantity * quote_lot_size raw quote tokens. let locked_quote = BID_PRICE * BID_QUANTITY * QUOTE_LOT_SIZE; assert_eq!( - get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.quote_vault).unwrap(), locked_quote ); // Buyer's quote ATA dropped by exactly that. @@ -635,7 +626,7 @@ fn place_bid_locks_quote_in_vault() { ); // Base vault untouched - bids never move base tokens. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), 0 ); @@ -669,7 +660,7 @@ fn place_ask_locks_base_in_vault() { // An ask locks quantity * base_lot_size raw base tokens in the base vault. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), ASK_QUANTITY * BASE_LOT_SIZE ); assert_eq!( @@ -677,7 +668,7 @@ fn place_ask_locks_base_in_vault() { TRADER_STARTING_BALANCE - ASK_QUANTITY * BASE_LOT_SIZE ); assert_eq!( - get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.quote_vault).unwrap(), 0 ); } @@ -724,13 +715,7 @@ fn place_order_rejects_unaligned_tick() { send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ) .unwrap(); @@ -783,13 +768,7 @@ fn place_order_rejects_below_min_order_size() { send_transaction_from_instructions( &mut sc.svm, vec![create_ix, init_ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ) .unwrap(); @@ -871,7 +850,7 @@ fn cancel_ask_credits_unsettled_base() { // Funds are still in the vault - cancel does not move tokens, it only // updates the unsettled balance. Settlement is a separate step. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), ASK_QUANTITY * BASE_LOT_SIZE ); // Seller's ATA hasn't received anything back yet. @@ -975,7 +954,7 @@ fn settle_funds_moves_unsettled_base_to_user() { // Vault drained, seller got their base tokens back in full. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), 0 ); assert_eq!( @@ -1020,7 +999,7 @@ fn cancel_and_settle_bid_refunds_full_quote() { // Vault drained, buyer got the full price*quantity of quote back. assert_eq!( - get_token_account_balance(&sc.svm, &sc.quote_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.quote_vault).unwrap(), 0 ); assert_eq!( @@ -1077,9 +1056,9 @@ fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { order_book::accounts::SettleFundsAccountConstraints { market: sc.market, market_user: sc.buyer_market_user, - base_vault: sc.base_vault.pubkey(), + base_vault: sc.base_vault, // Attack: route the quote-side transfer at the fee_vault. - quote_vault: sc.fee_vault.pubkey(), + quote_vault: sc.fee_vault, user_base_account: sc.buyer_base_ata, user_quote_account: sc.buyer_quote_ata, base_mint: sc.base_mint, @@ -1119,13 +1098,7 @@ fn initialize_market_rejects_zero_tick_size() { let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ); assert!(result.is_err(), "tick_size == 0 must be rejected"); @@ -1147,13 +1120,7 @@ fn initialize_market_rejects_zero_base_lot_size() { let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ); assert!(result.is_err(), "base_lot_size == 0 must be rejected"); @@ -1175,13 +1142,7 @@ fn initialize_market_rejects_zero_quote_lot_size() { let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ); assert!(result.is_err(), "quote_lot_size == 0 must be rejected"); @@ -1205,13 +1166,7 @@ fn initialize_market_rejects_oversized_fee() { let result = send_transaction_from_instructions( &mut sc.svm, vec![create_ix, ix], - &[ - &sc.authority, - &sc.order_book, - &sc.base_vault, - &sc.quote_vault, - &sc.fee_vault, - ], + &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), ); assert!( @@ -1350,7 +1305,7 @@ fn taker_bid_fully_crosses_best_ask() { // Fee vault received exactly fee_bps of the gross. assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), EXPECTED_FEE ); @@ -1425,7 +1380,7 @@ fn taker_ask_fully_crosses_best_bid() { .unwrap(); assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), EXPECTED_FEE ); // Maker (buyer) received the base tokens they paid for. @@ -1498,7 +1453,7 @@ fn taker_partially_fills_resting_order_rest_stays_on_book() { // Total base in vault stays == MAKER_ASK_QUANTITY * BASE_LOT_SIZE, because // fills are bucket-accounting inside the single vault. assert_eq!( - get_token_account_balance(&sc.svm, &sc.base_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), MAKER_ASK_QUANTITY * BASE_LOT_SIZE ); @@ -1943,7 +1898,7 @@ fn fee_rounds_up_when_gross_is_not_a_bps_multiple() { .unwrap(); assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), EXPECTED_FEE ); // Maker's unsettled quote is gross minus the rounded-up fee. @@ -2000,7 +1955,7 @@ fn fee_vault_receives_exactly_bps_of_taker_gross() { .unwrap(); assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), EXPECTED_FEE ); } @@ -2062,7 +2017,7 @@ fn authority_can_withdraw_fees_after_match() { .unwrap(); assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), EXPECTED_FEE ); @@ -2077,7 +2032,7 @@ fn authority_can_withdraw_fees_after_match() { // Fee vault drained, authority received the fees. assert_eq!( - get_token_account_balance(&sc.svm, &sc.fee_vault.pubkey()).unwrap(), + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), 0 ); assert_eq!( diff --git a/finance/order-book/quasar/CHANGELOG.md b/finance/order-book/quasar/CHANGELOG.md index d7ef14160..8a940e024 100644 --- a/finance/order-book/quasar/CHANGELOG.md +++ b/finance/order-book/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-09-22 + +### Changed + +- The base, quote, and fee vaults are PDAs of the market, at seeds + `["base_vault", market]`, `["quote_vault", market]` and + `["fee_vault", market]`, instead of token accounts at public keys the client + generated. Clients derive the vault addresses instead of generating and + signing with three extra keys. The market still records each address and + every handler still checks the vaults it is passed against that record. + The order book stays a client-allocated account: at about 180 KB it is too + large for the program to create. Tests derive the vaults instead of + generating them. + ## [2026-07-22] ### Changed diff --git a/finance/order-book/quasar/README.md b/finance/order-book/quasar/README.md index 2e6432958..1a99bdbbd 100644 --- a/finance/order-book/quasar/README.md +++ b/finance/order-book/quasar/README.md @@ -34,11 +34,11 @@ funds, and it moves them only along the place / cancel / settle paths below. ## Accounts and PDAs - `Market` (PDA, seeds `["market", base_mint, quote_mint]`): One trading pair. Stores config + vault addresses. Its PDA is the vaults' token authority. -- `OrderBook` (keypair account, not a PDA): Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. +- `OrderBook` (at a public key the client generates, not a PDA): Two critbit slabs (bids + asks), ~180 KB. Zero-copy. Bound to its market by the market's stored `order_book`. - `MarketUser` (PDA, seeds `["market_user", market, owner]`): Per-user, per-market. Tracks open order ids and `unsettled_*` balances owed back to the user. - `Order` (PDA, seeds `["order", market, order_id]`): One order. `order_id` is the book's monotonic counter at placement time. -- `base_vault` / `quote_vault` (token accounts): Hold locked funds while orders are open. Market PDA is the authority. -- `fee_vault` (token account): Accumulates taker fees (quote mint). Kept separate so user balances and fees can't be confused. +- `base_vault` / `quote_vault` (token accounts, PDAs at seeds `["base_vault", market]` and `["quote_vault", market]`): Hold locked funds while orders are open. Market PDA is the authority. +- `fee_vault` (token account, PDA at seeds `["fee_vault", market]`): Accumulates taker fees (quote mint). Kept separate so user balances and fees can't be confused. The order book is **not** a PDA. Solana caps inner-CPI account allocations at 10 KB, so a ~180 KB account can't be created with an `init` constraint: the client calls `system_program::create_account` directly (sizing it to diff --git a/finance/order-book/quasar/src/instructions/initialize_market.rs b/finance/order-book/quasar/src/instructions/initialize_market.rs index 7d6f9477d..e95216d0d 100644 --- a/finance/order-book/quasar/src/instructions/initialize_market.rs +++ b/finance/order-book/quasar/src/instructions/initialize_market.rs @@ -2,7 +2,9 @@ use quasar_lang::prelude::*; use quasar_spl::prelude::*; use crate::errors::OrderBookError; -use crate::state::{load_order_book_init, Market, MarketInner}; +use crate::state::{ + load_order_book_init, BaseVaultPda, FeeVaultPda, Market, MarketInner, QuoteVaultPda, +}; // Basis points are hundredths of a percent; 10000 bps == 100%. Fees above 100% // would be nonsensical, so we cap here. @@ -41,6 +43,7 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + address = BaseVaultPda::seeds(market.address()), token(mint = base_mint, authority = market, token_program = token_program), )] pub base_vault: Account, @@ -48,6 +51,7 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + address = QuoteVaultPda::seeds(market.address()), token(mint = quote_mint, authority = market, token_program = token_program), )] pub quote_vault: Account, @@ -57,6 +61,7 @@ pub struct InitializeMarketAccountConstraints { #[account( init, payer = authority, + address = FeeVaultPda::seeds(market.address()), token(mint = quote_mint, authority = market, token_program = token_program), )] pub fee_vault: Account, diff --git a/finance/order-book/quasar/src/state/market.rs b/finance/order-book/quasar/src/state/market.rs index 87278404e..f4a771c5a 100644 --- a/finance/order-book/quasar/src/state/market.rs +++ b/finance/order-book/quasar/src/state/market.rs @@ -2,6 +2,27 @@ use quasar_lang::prelude::*; pub const MARKET_SEED: &[u8] = b"market"; +/// The three vaults are PDAs of the market: each is found from its own seed +/// and the market's address, so any client can derive where the market keeps +/// its tokens without reading the market first. The market also records each +/// address, and every handler that touches a vault checks it against that +/// record, which is what stops the fee vault being passed as the quote vault. +/// +/// Base-token vault PDA at seeds = [b"base_vault", market]. +#[derive(Seeds)] +#[seeds(b"base_vault", market: Address)] +pub struct BaseVaultPda; + +/// Quote-token vault PDA at seeds = [b"quote_vault", market]. +#[derive(Seeds)] +#[seeds(b"quote_vault", market: Address)] +pub struct QuoteVaultPda; + +/// Fee vault PDA (quote mint) at seeds = [b"fee_vault", market]. +#[derive(Seeds)] +#[seeds(b"fee_vault", market: Address)] +pub struct FeeVaultPda; + /// A Market is one trading pair (base/quote) with its own vaults and order /// book. The market PDA itself is the authority of the token vaults, so funds /// can only move out via program-signed CPIs (place/cancel/settle). diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index 45016c08d..cc3710d33 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -10,7 +10,10 @@ use { PlaceOrderInstruction, SettleFundsInstruction, WithdrawFeesInstruction, }, errors::OrderBookError, - state::{Market, MarketUser, Order, OrderStatus, ORDER_BOOK_ACCOUNT_SIZE}, + state::{ + BaseVaultPda, FeeVaultPda, Market, MarketUser, Order, OrderStatus, QuoteVaultPda, + ORDER_BOOK_ACCOUNT_SIZE, + }, }, quasar_test::prelude::*, }; @@ -31,9 +34,6 @@ const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); const BASE_MINT: Pubkey = Pubkey::new_from_array([2; 32]); const QUOTE_MINT: Pubkey = Pubkey::new_from_array([3; 32]); const ORDER_BOOK: Pubkey = Pubkey::new_from_array([4; 32]); -const BASE_VAULT: Pubkey = Pubkey::new_from_array([5; 32]); -const QUOTE_VAULT: Pubkey = Pubkey::new_from_array([6; 32]); -const FEE_VAULT: Pubkey = Pubkey::new_from_array([7; 32]); const MAKER: Pubkey = Pubkey::new_from_array([8; 32]); const TAKER: Pubkey = Pubkey::new_from_array([9; 32]); const MAKER_BASE: Pubkey = Pubkey::new_from_array([10; 32]); @@ -76,9 +76,6 @@ fn init_market(test: &mut Test) -> Pubkey { order_book: ORDER_BOOK, base_mint: BASE_MINT, quote_mint: QUOTE_MINT, - base_vault: BASE_VAULT, - quote_vault: QUOTE_VAULT, - fee_vault: FEE_VAULT, fee_basis_points: FEE_BASIS_POINTS, tick_size: TICK_SIZE, base_lot_size: BASE_LOT_SIZE, @@ -90,6 +87,22 @@ fn init_market(test: &mut Test) -> Pubkey { test.derive_pda(Market::seeds(&BASE_MINT, "E_MINT)) } +/// The market's three vaults. They are PDAs of the market, so a client derives +/// them rather than choosing them. +struct Vaults { + base: Pubkey, + quote: Pubkey, + fee: Pubkey, +} + +fn vaults(test: &mut Test, market: Pubkey) -> Vaults { + Vaults { + base: test.derive_pda(BaseVaultPda::seeds(&market)), + quote: test.derive_pda(QuoteVaultPda::seeds(&market)), + fee: test.derive_pda(FeeVaultPda::seeds(&market)), + } +} + fn initialize_market_user(test: &mut Test, market: Pubkey, owner: Pubkey) -> Pubkey { test.add(Wallet::new().at(owner)); test.send(InitializeMarketUserInstruction { owner, market }) @@ -117,12 +130,13 @@ fn place_order( remaining_accounts.push(AccountMeta::new(*maker_order, false)); remaining_accounts.push(AccountMeta::new(*maker_market_user, false)); } + let vaults = vaults(test, market); test.send(PlaceOrderInstruction { market, order_book: ORDER_BOOK, - base_vault: BASE_VAULT, - quote_vault: QUOTE_VAULT, - fee_vault: FEE_VAULT, + base_vault: vaults.base, + quote_vault: vaults.quote, + fee_vault: vaults.fee, user_base_account, user_quote_account, base_mint: BASE_MINT, @@ -143,11 +157,12 @@ fn settle_funds( user_base_account: Pubkey, user_quote_account: Pubkey, ) -> Outcome { + let vaults = vaults(test, market); test.send(SettleFundsInstruction { owner, market, - base_vault: BASE_VAULT, - quote_vault: QUOTE_VAULT, + base_vault: vaults.base, + quote_vault: vaults.quote, user_base_account, user_quote_account, base_mint: BASE_MINT, @@ -159,14 +174,16 @@ fn settle_funds( fn initialize_market_stamps_market_and_order_book(test: &mut Test) { let market = init_market(test); - // Market state records the pair, vaults, and parameters. + // Market state records the pair, vaults, and parameters. The vaults it + // records are the market's PDAs, not addresses the client chose. + let vaults = vaults(test, market); let state = test.read::(market); assert_eq!(state.authority, AUTHORITY, "authority"); assert_eq!(state.base_mint, BASE_MINT, "base_mint"); assert_eq!(state.quote_mint, QUOTE_MINT, "quote_mint"); - assert_eq!(state.base_vault, BASE_VAULT, "base_vault"); - assert_eq!(state.quote_vault, QUOTE_VAULT, "quote_vault"); - assert_eq!(state.fee_vault, FEE_VAULT, "fee_vault"); + assert_eq!(state.base_vault, vaults.base, "base_vault"); + assert_eq!(state.quote_vault, vaults.quote, "quote_vault"); + assert_eq!(state.fee_vault, vaults.fee, "fee_vault"); assert_eq!(state.order_book, ORDER_BOOK, "order_book"); assert_eq!(u16::from(state.fee_basis_points), FEE_BASIS_POINTS); @@ -268,9 +285,10 @@ fn place_match_settle_withdraw_moves_tokens_and_fees(test: &mut Test) { // Both settle, then the authority sweeps the fee vault. settle_funds(test, market, MAKER, MAKER_BASE, MAKER_QUOTE).succeeds(); settle_funds(test, market, TAKER, TAKER_BASE, TAKER_QUOTE).succeeds(); + let vaults = vaults(test, market); test.send(WithdrawFeesInstruction { market, - fee_vault: FEE_VAULT, + fee_vault: vaults.fee, authority_quote_account: AUTHORITY_QUOTE, quote_mint: QUOTE_MINT, authority: AUTHORITY, @@ -298,11 +316,11 @@ fn place_match_settle_withdraw_moves_tokens_and_fees(test: &mut Test) { // Fee swept to the authority. assert_eq!(test.tokens(AUTHORITY_QUOTE), FEE_QUOTE); - assert_eq!(test.tokens(FEE_VAULT), 0); + assert_eq!(test.tokens(vaults.fee), 0); // Vaults drained after settlement (maker sold all base, taker paid gross). - assert_eq!(test.tokens(BASE_VAULT), 0); - assert_eq!(test.tokens(QUOTE_VAULT), 0); + assert_eq!(test.tokens(vaults.base), 0); + assert_eq!(test.tokens(vaults.quote), 0); } /// Cancelling a resting order credits the locked base back to the owner's @@ -365,9 +383,10 @@ fn withdraw_fees_rejects_a_non_authority_signer(test: &mut Test) { test.add(Wallet::new().at(ATTACKER)); test.add(TokenAccount::new(QUOTE_MINT, ATTACKER).at(ATTACKER_QUOTE)); + let vaults = vaults(test, market); test.send(WithdrawFeesInstruction { market, - fee_vault: FEE_VAULT, + fee_vault: vaults.fee, authority_quote_account: ATTACKER_QUOTE, quote_mint: QUOTE_MINT, authority: ATTACKER, From c0c853df2fd755d7c34078a6116069b839fd1f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:35:54 +0000 Subject: [PATCH 2/2] ci: skip the validator in anchor test for the LiteSVM projects built without an IDL Ported from #149, which fixes the same failure on main: surfpool 1.6 will not start without target/idl/.json, and order-book is one of the nine projects built with --no-idl, so `anchor test` died before any test ran. Those projects test with LiteSVM via `cargo test`, so `anchor test` now passes --skip-local-validator for them. This is a no-op once #149 merges; its CHANGELOG entry stays there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011VUvZT5384GaSBhhgJwFp3 --- .github/workflows/anchor.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/anchor.yml b/.github/workflows/anchor.yml index 1925cceeb..899cc0c5a 100644 --- a/.github/workflows/anchor.yml +++ b/.github/workflows/anchor.yml @@ -275,8 +275,8 @@ jobs: done fi - # TEMPORARY WORKAROUND - delete this block and the `$idl_flag` - # below once an Anchor release after 2.0.0-rc.1 is out. + # TEMPORARY WORKAROUND - delete this block and the `$idl_flag` and + # `$test_flag` below once an Anchor release after 2.0.0-rc.1 is out. # # anchor-derive-accounts 2.0.0-rc.1 emits enum type definitions with # the JSON key "fields" where the IDL spec requires "variants", so @@ -297,6 +297,14 @@ jobs: # projects consumes a generated IDL (no `idls/` directory, no # `declare_program!`). The program is still compiled and its tests # still run. + # + # The skip also means `anchor test` must not start a validator for + # these projects. Surfpool 1.6 deploys the program at startup + # through a runbook that reads `target/idl/.json`, and with + # no IDL it exits with `Runbook execution failed` before any test + # runs. None of these projects needs a validator: each one's + # Anchor.toml runs `cargo test`, which tests against LiteSVM + # in-process. case "$project" in ./basics/pyth/anchor \ |./finance/betting-market/anchor \ @@ -309,9 +317,11 @@ jobs: |./tokens/token-extensions/transfer-hook/allow-block-list-token/anchor) echo "::notice::skipping IDL generation for $project (anchor#4947)" idl_flag="--no-idl" + test_flag="--skip-local-validator" ;; *) idl_flag="" + test_flag="" ;; esac @@ -327,7 +337,7 @@ jobs: # Run anchor test. It regenerates the IDL too, so the skip above # has to apply here as well or the projects it exempts fail at the # test step instead of the build step. - if ! anchor test $idl_flag; then + if ! anchor test $idl_flag $test_flag; then echo "::error::anchor test failed for $project" echo "$project: anchor test failed" >> $GITHUB_WORKSPACE/failed_projects.txt rm -rf target node_modules