From 0a05ba0277d81f0fbb181e3c871f1ca79e311fa8 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Fri, 2 Oct 2026 16:41:24 +0000 Subject: [PATCH] Token Swap (Quasar): create the reserves at PDAs of the pool pool_a and pool_b were token accounts at addresses the client chose, which the companion AI plugin's PDA rule counts as a finding: a client could only find a pool's reserves by reading PoolConfig. They now live at [b"pool_a", pool_config] and [b"pool_b", pool_config] (PoolAPda, PoolBPda), created with init, as the order book's Quasar version does for its vaults. PoolConfig still records both addresses and every handler still checks them with has_one, so the substitution tests are unchanged. The tests derive the reserves instead of using fixed addresses, and initialize_pool's instruction builder now derives them too. Claude-Session: https://claude.ai/code/session_01BvopS6WYzsT9Qf32NTu29V --- CHANGELOG.md | 9 ++ finance/token-swap/quasar/CHANGELOG.md | 12 +++ .../src/instructions/initialize_pool.rs | 13 +-- finance/token-swap/quasar/src/lib.rs | 14 +++ finance/token-swap/quasar/src/tests.rs | 85 +++++++++++-------- 5 files changed, 91 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1077e13f..a07ded68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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-10-02] - Token Swap (Quasar): reserves live at PDAs of the pool + +### Changed + +- `finance/token-swap/quasar` creates `pool_a` and `pool_b` at PDAs of the + pool (`[b"pool_a", pool_config]`, `[b"pool_b", pool_config]`) instead of at + addresses the client chose, as the order book's Quasar version does for its + vaults. Clients no longer pass the reserves to `initialize_pool`. + ## [2026-10-02] - Token Swap (Quasar): reserves are bound to the pool ### Fixed diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index db00817d..7435faba 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2026-10-02] - Reserves at PDAs + +### Changed + +- `pool_a` and `pool_b` are PDAs of the pool, at seeds `[b"pool_a", + pool_config]` and `[b"pool_b", pool_config]` (`PoolAPda`, `PoolBPda`), where + before they were token accounts at addresses the client chose. + `initialize_pool` creates them there with `init` rather than + `init(idempotent)`, so a client finds a pool's reserves from the pool's + address alone, and the instruction builder derives them. `PoolConfig` still + records both addresses and every handler still checks them with `has_one`. + ## [2026-10-02] ### Fixed diff --git a/finance/token-swap/quasar/src/instructions/initialize_pool.rs b/finance/token-swap/quasar/src/instructions/initialize_pool.rs index d0d12794..ee9f0a1c 100644 --- a/finance/token-swap/quasar/src/instructions/initialize_pool.rs +++ b/finance/token-swap/quasar/src/instructions/initialize_pool.rs @@ -1,7 +1,7 @@ use { crate::{ state::{Config, PoolConfig, PoolConfigInner}, - ConfigPda, LiquidityMintPda, PoolPda, + ConfigPda, LiquidityMintPda, PoolAPda, PoolBPda, PoolPda, }, quasar_lang::prelude::*, quasar_spl::prelude::*, @@ -10,6 +10,7 @@ use { /// Seeds: /// - `pool_config = [config, mint_a, mint_b]` /// - `liquidity_provider_mint = [b"liquidity", config, mint_a, mint_b]` +/// - `pool_a = [b"pool_a", pool_config]`, `pool_b = [b"pool_b", pool_config]` /// /// `pool_config` owns both reserves and is the LP mint's mint authority; it /// signs for them with its own seeds. `liquidity_provider_mint` derives at a @@ -39,19 +40,21 @@ pub struct InitializePoolAccountConstraints { pub liquidity_provider_mint: Account, pub mint_a: Account, pub mint_b: Account, - /// Pool's token A reserve, owned by `pool_config`. + /// Pool's token A reserve, owned by `pool_config`, at a PDA of the pool. #[account( mut, - init(idempotent), + init, payer = payer, + address = PoolAPda::seeds(pool_config.address()), token(mint = mint_a, authority = pool_config, token_program = token_program), )] pub pool_a: Account, - /// Pool's token B reserve, owned by `pool_config`. + /// Pool's token B reserve, owned by `pool_config`, at a PDA of the pool. #[account( mut, - init(idempotent), + init, payer = payer, + address = PoolBPda::seeds(pool_config.address()), token(mint = mint_b, authority = pool_config, token_program = token_program), )] pub pool_b: Account, diff --git a/finance/token-swap/quasar/src/lib.rs b/finance/token-swap/quasar/src/lib.rs index 042e969c..9638e030 100644 --- a/finance/token-swap/quasar/src/lib.rs +++ b/finance/token-swap/quasar/src/lib.rs @@ -45,6 +45,20 @@ pub struct PoolPda; #[seeds(b"liquidity", config: Address, mint_a: Address, mint_b: Address)] pub struct LiquidityMintPda; +/// The pool's token A reserve, a PDA of the pool at seeds = [b"pool_a", +/// pool_config], so any client can derive where a pool keeps its tokens +/// without being told. `PoolConfig` also records the address, and every +/// handler that touches the reserve checks it with `has_one(pool_a)`. +#[derive(Seeds)] +#[seeds(b"pool_a", pool_config: Address)] +pub struct PoolAPda; + +/// The pool's token B reserve, at seeds = [b"pool_b", pool_config], recorded +/// and checked as `pool_a` is. +#[derive(Seeds)] +#[seeds(b"pool_b", pool_config: Address)] +pub struct PoolBPda; + /// Simple constant-product AMM (token swap). /// /// Six instructions: diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index d8141567..ca66a844 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -10,7 +10,7 @@ use { }, error::AmmError, state::Config, - ConfigPda, LiquidityMintPda, PoolPda, + ConfigPda, LiquidityMintPda, PoolAPda, PoolBPda, PoolPda, }, quasar_test::prelude::*, }; @@ -48,8 +48,6 @@ const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); const PAYER: Pubkey = Pubkey::new_from_array([2; 32]); const MINT_A: Pubkey = Pubkey::new_from_array([3; 32]); const MINT_B: Pubkey = Pubkey::new_from_array([4; 32]); -const POOL_A: Pubkey = Pubkey::new_from_array([5; 32]); -const POOL_B: Pubkey = Pubkey::new_from_array([6; 32]); // The pool-seeding depositor. const SEEDER: Pubkey = Pubkey::new_from_array([7; 32]); const SEEDER_TOKEN_A: Pubkey = Pubkey::new_from_array([8; 32]); @@ -72,6 +70,22 @@ const BAD_ACTOR: Pubkey = Pubkey::new_from_array([22; 32]); const BAD_TOKEN_A: Pubkey = Pubkey::new_from_array([23; 32]); const BAD_TOKEN_B: Pubkey = Pubkey::new_from_array([24; 32]); +/// The pool's address, derived as the program derives it. +fn pool_config_address(test: &Test) -> Pubkey { + let config = test.derive_pda(ConfigPda::seeds()); + test.derive_pda(PoolPda::seeds(&config, &MINT_A, &MINT_B)) +} + +/// The pool's token A reserve, a PDA of the pool. +fn pool_a(test: &Test) -> Pubkey { + test.derive_pda(PoolAPda::seeds(&pool_config_address(test))) +} + +/// The pool's token B reserve, a PDA of the pool. +fn pool_b(test: &Test) -> Pubkey { + test.derive_pda(PoolBPda::seeds(&pool_config_address(test))) +} + struct PoolEnv { pool_config: Pubkey, lp_mint: Pubkey, @@ -95,14 +109,11 @@ fn setup_pool(test: &mut Test) -> PoolEnv { test.add(Mint::new(PAYER).at(MINT_A).decimals(6)); test.add(Mint::new(PAYER).at(MINT_B).decimals(6)); - // initialize_pool: the pool_config and LP-mint PDAs are derived by the - // builder; pool_a/pool_b are non-PDA token accounts the program creates - // at the given addresses, owned by pool_config. + // initialize_pool: the pool_config, LP-mint and reserve PDAs are all + // derived by the builder; the reserves are owned by pool_config. test.send(InitializePoolInstruction { mint_a: MINT_A, mint_b: MINT_B, - pool_a: POOL_A, - pool_b: POOL_B, payer: PAYER, }) .succeeds(); @@ -151,8 +162,8 @@ fn deposit( depositor, mint_a: MINT_A, mint_b: MINT_B, - pool_a: POOL_A, - pool_b: POOL_B, + pool_a: pool_a(test), + pool_b: pool_b(test), liquidity_provider_token: lp_token, token_a, token_b, @@ -201,8 +212,8 @@ fn swap( trader, mint_a: MINT_A, mint_b: MINT_B, - pool_a: POOL_A, - pool_b: POOL_B, + pool_a: pool_a(test), + pool_b: pool_b(test), token_a, token_b, payer: PAYER, @@ -221,8 +232,8 @@ fn claim_fees( test.send(ClaimAdminFeesInstruction { mint_a: MINT_A, mint_b: MINT_B, - pool_a: POOL_A, - pool_b: POOL_B, + pool_a: pool_a(test), + pool_b: pool_b(test), admin, admin_token_a, admin_token_b, @@ -291,7 +302,7 @@ fn initialize_pool_creates_pool_config_and_lp_mint(test: &mut Test) { expected, "LP mint authority must be pool_config" ); - for reserve in [POOL_A, POOL_B] { + for reserve in [pool_a(test), pool_b(test)] { let account = test.account(reserve).expect("reserve missing"); assert_eq!( &account.data[32..64], @@ -314,8 +325,8 @@ fn deposit_liquidity_initial(test: &mut Test) { // LP token account must exist with a non-zero balance, and the pool // reserves must have received the tokens. assert!(lp_balance > 0, "expected LP tokens, got 0"); - assert_eq!(test.tokens(POOL_A), amount_a); - assert_eq!(test.tokens(POOL_B), amount_b); + assert_eq!(test.tokens(pool_a(test)), amount_a); + assert_eq!(test.tokens(pool_b(test)), amount_b); } #[quasar_test] @@ -367,8 +378,8 @@ fn deposit_after_every_lp_token_is_burned(test: &mut Test) { // of each side stays. withdraw(test, SEEDER, SEEDER_LP, RECV_A, RECV_B, lp_balance, 0, 0) .succeeds() - .has_tokens(POOL_A, crate::MINIMUM_LIQUIDITY) - .has_tokens(POOL_B, crate::MINIMUM_LIQUIDITY); + .has_tokens(pool_a(test), crate::MINIMUM_LIQUIDITY) + .has_tokens(pool_b(test), crate::MINIMUM_LIQUIDITY); // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. fund( @@ -463,8 +474,8 @@ fn deposit_clamps_down_never_up(test: &mut Test) { // Exact amounts pulled: all of A, ratio-clamped B, nothing more. .has_tokens(DEPOSITOR_TOKEN_A, 0) .has_tokens(DEPOSITOR_TOKEN_B, stated_b - expected_b_pulled) - .has_tokens(POOL_A, pool_seed_a + stated_a) - .has_tokens(POOL_B, pool_seed_b + expected_b_pulled) + .has_tokens(pool_a(test), pool_seed_a + stated_a) + .has_tokens(pool_b(test), pool_seed_b + expected_b_pulled) // LP mint must be proportional. .has_tokens(DEPOSITOR_LP, expected_lp); } @@ -509,8 +520,8 @@ fn deposit_clamps_down_other_side(test: &mut Test) { .succeeds() .has_tokens(DEPOSITOR_TOKEN_A, stated_a - expected_a_pulled) .has_tokens(DEPOSITOR_TOKEN_B, 0) - .has_tokens(POOL_A, pool_seed_a + expected_a_pulled) - .has_tokens(POOL_B, pool_seed_b + stated_b) + .has_tokens(pool_a(test), pool_seed_a + expected_a_pulled) + .has_tokens(pool_b(test), pool_seed_b + stated_b) // LP mint must be proportional. .has_tokens(DEPOSITOR_LP, expected_lp); } @@ -559,12 +570,12 @@ fn deposit_slippage_rejected(test: &mut Test) { "token B must be untouched after revert" ); assert_eq!( - test.tokens(POOL_A), + test.tokens(pool_a(test)), pool_seed_a, "pool_a must be untouched after revert" ); assert_eq!( - test.tokens(POOL_B), + test.tokens(pool_b(test)), pool_seed_b, "pool_b must be untouched after revert" ); @@ -587,8 +598,8 @@ fn withdraw( depositor, mint_a: MINT_A, mint_b: MINT_B, - pool_a: POOL_A, - pool_b: POOL_B, + pool_a: pool_a(test), + pool_b: pool_b(test), liquidity_provider_token: lp_token, token_a: recv_a, token_b: recv_b, @@ -666,12 +677,12 @@ fn withdraw_slippage_rejected(test: &mut Test) { // Nothing moved: pool reserves and the LP balance are unchanged. assert_eq!( - test.tokens(POOL_A), + test.tokens(pool_a(test)), 2_000_000, "pool_a must be untouched after revert" ); assert_eq!( - test.tokens(POOL_B), + test.tokens(pool_b(test)), 2_000_000, "pool_b must be untouched after revert" ); @@ -719,8 +730,8 @@ fn swap_a_to_b_conserves_balances(test: &mut Test) { // what the pool sent; nothing is minted or lost in transit. .has_tokens(TRADER_TOKEN_A, trader_funding - input) .has_tokens(TRADER_TOKEN_B, expected_output) - .has_tokens(POOL_A, pool_seed_a + input) - .has_tokens(POOL_B, pool_seed_b - expected_output); + .has_tokens(pool_a(test), pool_seed_a + input) + .has_tokens(pool_b(test), pool_seed_b - expected_output); } #[quasar_test] @@ -752,8 +763,8 @@ fn swap_b_to_a_conserves_balances(test: &mut Test) { .succeeds() .has_tokens(TRADER_TOKEN_B, trader_funding - input) .has_tokens(TRADER_TOKEN_A, expected_output) - .has_tokens(POOL_B, pool_seed_b + input) - .has_tokens(POOL_A, pool_seed_a - expected_output); + .has_tokens(pool_b(test), pool_seed_b + input) + .has_tokens(pool_a(test), pool_seed_a - expected_output); } #[quasar_test] @@ -789,12 +800,12 @@ fn swap_slippage_rejected(test: &mut Test) { "trader balance must be untouched after revert" ); assert_eq!( - test.tokens(POOL_A), + test.tokens(pool_a(test)), 10_000_000, "pool_a must be untouched after revert" ); assert_eq!( - test.tokens(POOL_B), + test.tokens(pool_b(test)), 10_000_000, "pool_b must be untouched after revert" ); @@ -829,7 +840,7 @@ fn swap_rejects_substituted_pool_vault(test: &mut Test) { mint_a: MINT_A, mint_b: MINT_B, pool_a: fake_pool_a, - pool_b: POOL_B, + pool_b: pool_b(test), token_a: BAD_TOKEN_A, token_b: BAD_TOKEN_B, payer: PAYER, @@ -840,7 +851,7 @@ fn swap_rejects_substituted_pool_vault(test: &mut Test) { .fails_with(AmmError::InvalidPoolVault); assert_eq!( - test.tokens(POOL_B), + test.tokens(pool_b(test)), 10_000_000, "pool_b must be untouched after the refused swap" );