diff --git a/CHANGELOG.md b/CHANGELOG.md index af189ab47..6461eb962 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-22] - Anchor v2 CI no longer starts a validator for LiteSVM-only projects Surfpool 1.6, which the Anchor v2 workflow installs as `latest`, deploys each 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,