From 9d0c7c0efac7911b1be7a04131fa990943da1b4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:51:08 +0000 Subject: [PATCH 1/2] finance/token-fundraiser (Anchor v1): port close_contributor A successful raise closes the vault and the fundraiser account but leaves every contributor account open, and refund, their only other closer, runs only on a failed raise. The v2 copy gained close_contributor for this; the v1 copy was left behind while anchor-v1/ was frozen. Port the handler, the FundraiserStillOpen error, the two tests, and the README and changelog text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8Wo5zNLqZ2NQqSjGD7WpK --- .../token-fundraiser/anchor-v1/CHANGELOG.md | 13 ++ finance/token-fundraiser/anchor-v1/README.md | 16 +- .../programs/fundraiser/src/error.rs | 2 + .../src/instructions/close_contributor.rs | 45 ++++++ .../fundraiser/src/instructions/mod.rs | 4 +- .../anchor-v1/programs/fundraiser/src/lib.rs | 8 + .../fundraiser/tests/test_fundraiser.rs | 144 ++++++++++++++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs diff --git a/finance/token-fundraiser/anchor-v1/CHANGELOG.md b/finance/token-fundraiser/anchor-v1/CHANGELOG.md index 4f1163881..12dbd4c8c 100644 --- a/finance/token-fundraiser/anchor-v1/CHANGELOG.md +++ b/finance/token-fundraiser/anchor-v1/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-09-23 + +### Added + +- `close_contributor`, ported from the Anchor v2 copy: a contributor closes + their Contributor account once the fundraiser is gone, taking back its rent. + A successful raise closed the vault and the Fundraiser account but left every + Contributor account open, and `refund`, their only other closer, runs only on + a failed raise, so the rent was stuck. The handler's one check is that the + passed fundraiser account is not owned by this program, else the new + `FundraiserStillOpen` error. Two tests cover the rent returning after a claim + and the refusal while the fundraiser exists. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/token-fundraiser/anchor-v1/README.md b/finance/token-fundraiser/anchor-v1/README.md index 19bdebed3..f7c9c5b97 100644 --- a/finance/token-fundraiser/anchor-v1/README.md +++ b/finance/token-fundraiser/anchor-v1/README.md @@ -6,7 +6,7 @@ > `avm install 1.2.0 && avm use 1.2.0`. The Anchor v2 version of this example is in > [`../anchor`](../anchor/). -Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. +Onchain crowdfunding on Solana: a program that collects tokens toward a target amount, like Kickstarter without a payment processor. A **maker** creates a fundraiser [account](https://solana.com/docs/terminology#account), specifies the [mint](https://solana.com/docs/terminology#token-mint) they want to receive, the target amount, and a duration in days. **Contributors** contribute while the window is open. If the target is reached, the maker claims the funds and each contributor closes their own record to take back its rent; if it is not reached by the deadline, contributors can refund, and once refunds are complete the maker can retire the fundraiser and open a new one. ## Architecture @@ -139,6 +139,14 @@ Retires a failed fundraiser so the maker can raise again. The Fundraiser PDA is Anything still in the vault at this point is a direct donation outside the program's accounting; the handler sweeps it to `maker_ata` with `transfer_checked` rather than burning it, then closes the vault with `close_account` (both CPIs signed with the Fundraiser PDA's seeds). The Fundraiser state account is closed via `close = maker`. +### `close_contributor` + +[`programs/fundraiser/src/instructions/close_contributor.rs`](programs/fundraiser/src/instructions/close_contributor.rs), account constraints `CloseContributorAccountConstraints`. + +Lets a contributor close their Contributor account once the fundraiser is gone, taking back its rent. A successful raise exits through `check_contributions`, which closes the vault and the Fundraiser account but cannot reach the Contributor accounts: there is one per contributor and the claim carries none of them. Their other closer, `refund`, runs only on a failed raise, so without this handler every contributor to a successful raise would hold their rent in an account nothing could close. + +One check: the `fundraiser` account passed in is not owned by this program, else `FundraiserStillOpen`. A live fundraiser is program-owned; a closed one belongs to the system program again, whatever lamports it holds. The Contributor account's seeds bind it to that fundraiser address, so no other fundraiser can be substituted. The account is closed via `close = contributor`. + ## Testing The tests are Rust integration tests using [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://crates.io/crates/solana-kite), in [`programs/fundraiser/tests/test_fundraiser.rs`](programs/fundraiser/tests/test_fundraiser.rs). They load the compiled program with `include_bytes!`, so build the program first and rebuild after every program change: @@ -148,7 +156,7 @@ cargo build-sbf cargo test ``` -The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, and that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. +The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exercise both sides of every deadline: contributing inside the window succeeds, contributing after the deadline fails, refunding before the deadline fails, and refunding after the deadline succeeds when the target was not met. It exercises both contribution caps (a single contribution over the 10% cap, and contributions that cumulatively exceed it), and verifies that the claim pays the maker and closes the vault, that direct vault donations do not unlock the claim, that `close_fundraiser` retires a failed raise (only after the deadline, only when the target was missed, only once refunds are complete, sweeping direct donations to the maker) and lets the same maker initialize a fresh fundraiser, and that `close_contributor` returns a contributor's rent after a successful claim and is refused while the fundraiser exists. Assertions check token balances and decoded account state rather than just transaction success. ## FAQ @@ -156,6 +164,10 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci A maker opens a fundraiser with `initialize`, naming the token, target amount, and duration. Contributors deposit with `contribute` while the window is open, and the funds sit in a program-controlled vault that neither side can raid. When the target is reached, the maker claims the raise with `check_contributions`, which pays out the vault and closes the fundraiser. +### What happens to the contributor accounts after a successful raise? + +The claim closes the vault and the Fundraiser account, but each Contributor account stays open with its rent inside. Its owner calls `close_contributor`, which checks that the fundraiser is gone and returns the rent. + ### What happens if the fundraiser misses its target? Contributors call `refund` after the deadline to reclaim exactly what they put in. Once refunds are complete, the maker calls `close_fundraiser` to retire the failed raise and can then open a new one. diff --git a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/error.rs b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/error.rs index 31a745947..06e94d8a1 100644 --- a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/error.rs +++ b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/error.rs @@ -22,4 +22,6 @@ pub enum FundraiserError { RefundsOutstanding, #[msg("Arithmetic overflow")] MathOverflow, + #[msg("The fundraiser still exists, so the contributor account closes through refund")] + FundraiserStillOpen, } diff --git a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs new file mode 100644 index 000000000..60920692e --- /dev/null +++ b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/close_contributor.rs @@ -0,0 +1,45 @@ +use anchor_lang::prelude::*; + +use crate::{state::Contributor, FundraiserError}; + +#[derive(Accounts)] +pub struct CloseContributorAccountConstraints<'info> { + #[account(mut)] + pub contributor: Signer<'info>, + + /// CHECK: the fundraiser this contributor account was written for. The + /// contributor account's seeds bind it to this address, so no other + /// fundraiser can be substituted. The constraint requires the account to + /// be gone: a live fundraiser is owned by this program, and a closed one + /// belongs to the system program again, whatever lamports it holds. + #[account( + constraint = *fundraiser.owner != crate::ID @ FundraiserError::FundraiserStillOpen, + )] + pub fundraiser: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [b"contributor", fundraiser.key().as_ref(), contributor.key().as_ref()], + bump = contributor_account.bump, + close = contributor, + )] + pub contributor_account: Account<'info, Contributor>, +} + +/// Closes a contributor account once its fundraiser is gone, returning the +/// rent to the contributor. +/// +/// A successful raise exits through `check_contributions`, which closes the +/// vault and the fundraiser but cannot reach the contributor accounts: there +/// is one per contributor and the claim carries none of them. Their other +/// closer, `refund`, runs only on a failed raise. Without this handler every +/// contributor to a successful raise would hold their rent in an account +/// nothing could close. +/// +/// The one check is that the fundraiser account no longer exists, which is +/// the `constraint` above; the `close = contributor` constraint then returns +/// the rent. While the fundraiser exists the contribution is live, and +/// `refund` is the way to close it. +pub fn handle_close_contributor(_accounts: &mut CloseContributorAccountConstraints) -> Result<()> { + Ok(()) +} diff --git a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/mod.rs b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/mod.rs index 7ae564501..55a06bbb0 100644 --- a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/mod.rs +++ b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/instructions/mod.rs @@ -3,9 +3,11 @@ pub mod contribute; pub mod checker; pub mod refund; pub mod close; +pub mod close_contributor; pub use initialize_fundraiser::*; pub use contribute::*; pub use checker::*; pub use refund::*; -pub use close::*; \ No newline at end of file +pub use close::*; +pub use close_contributor::*; \ No newline at end of file diff --git a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/lib.rs b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/lib.rs index ccbee926a..83e1c2272 100644 --- a/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/lib.rs +++ b/finance/token-fundraiser/anchor-v1/programs/fundraiser/src/lib.rs @@ -53,4 +53,12 @@ pub mod fundraiser { Ok(()) } + + pub fn close_contributor( + mut context: Context, + ) -> Result<()> { + handle_close_contributor(&mut context.accounts)?; + + Ok(()) + } } diff --git a/finance/token-fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs b/finance/token-fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs index c32e479e0..12068ddfe 100644 --- a/finance/token-fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/token-fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs @@ -250,6 +250,23 @@ fn build_check_contributions_instruction( ) } +fn build_close_contributor_instruction( + setup: &FundraiserSetup, + contributor: &Pubkey, + contributor_account_pda: &Pubkey, +) -> Instruction { + Instruction::new_with_bytes( + setup.program_id, + &fundraiser::instruction::CloseContributor {}.data(), + fundraiser::accounts::CloseContributorAccountConstraints { + contributor: *contributor, + fundraiser: setup.fundraiser_pda, + contributor_account: *contributor_account_pda, + } + .to_account_metas(None), + ) +} + fn build_close_fundraiser_instruction(setup: &FundraiserSetup, maker_ata: &Pubkey) -> Instruction { Instruction::new_with_bytes( setup.program_id, @@ -961,3 +978,130 @@ fn test_check_contributions_ignores_direct_vault_donations() { "Fundraiser account must stay open after a failed claim" ); } + +#[test] +fn test_close_contributor_after_successful_claim_returns_rent() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + // 10 contributors at the 10% cap reach the target exactly. + let mut contributors = Vec::new(); + for _ in 0..10 { + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .unwrap(); + contributors.push((contributor, contributor_account_pda)); + } + + let maker_ata = derive_ata(&setup.maker.pubkey(), &setup.mint); + let check_instruction = build_check_contributions_instruction(&setup, &maker_ata); + send_transaction_from_instructions( + &mut setup.svm, + vec![check_instruction], + &[&setup.maker], + &setup.maker.pubkey(), + ) + .unwrap(); + assert!( + setup.svm.get_account(&setup.fundraiser_pda).is_none(), + "Fundraiser account must be closed after a successful claim" + ); + + // The claim closed the fundraiser and the vault, but every contributor + // account is still open with its rent inside. + let (contributor, contributor_account_pda) = &contributors[0]; + let rent = setup + .svm + .get_account(contributor_account_pda) + .expect("Contributor account survives the claim") + .lamports; + let lamports_before = setup + .svm + .get_account(&contributor.pubkey()) + .unwrap() + .lamports; + + let close_instruction = + build_close_contributor_instruction(&setup, &contributor.pubkey(), contributor_account_pda); + send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[contributor], + &contributor.pubkey(), + ) + .unwrap(); + + assert!( + setup.svm.get_account(contributor_account_pda).is_none(), + "Contributor account must be closed" + ); + let lamports_after = setup + .svm + .get_account(&contributor.pubkey()) + .unwrap() + .lamports; + // The contributor paid the transaction fee out of the same balance, so + // the rent came back less that fee. + let fee = 5_000; + assert_eq!( + lamports_after, + lamports_before + rent - fee, + "The contributor account's rent must return to the contributor" + ); +} + +#[test] +fn test_close_contributor_while_fundraiser_open_fails() { + let mut setup = full_setup(); + initialize_fundraiser(&mut setup, AMOUNT_TO_RAISE, DURATION_DAYS); + + let (contributor, contributor_ata, contributor_account_pda) = + create_funded_contributor(&mut setup); + let contribute_instruction = build_contribute_instruction( + &setup, + &contributor.pubkey(), + &contributor_ata, + &contributor_account_pda, + MAX_CONTRIBUTION, + ); + send_transaction_from_instructions( + &mut setup.svm, + vec![contribute_instruction], + &[&contributor], + &contributor.pubkey(), + ) + .unwrap(); + + // The fundraiser is live, so the contribution is live too: closing the + // record now would erase what the vault owes this contributor. + let close_instruction = build_close_contributor_instruction( + &setup, + &contributor.pubkey(), + &contributor_account_pda, + ); + let result = send_transaction_from_instructions( + &mut setup.svm, + vec![close_instruction], + &[&contributor], + &contributor.pubkey(), + ); + assert!( + result.is_err(), + "Closing a contributor account must fail while its fundraiser exists" + ); + let contributor_state = read_contributor_state(&setup.svm, &contributor_account_pda); + assert_eq!(contributor_state.amount, MAX_CONTRIBUTION); +} From ff3f528f59dab60ad77c6ca3a39a5affdec545cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:51:08 +0000 Subject: [PATCH 2/2] finance/order-book (Anchor v1): make the base, quote, and fee vaults market PDAs The v2 copy moved the three vaults to PDAs of the market at ["base_vault", market], ["quote_vault", market] and ["fee_vault", market]; the v1 copy still created them at client-generated keys. Port the seeds, the tests (which now derive the vaults instead of generating and signing with them), and the README and changelog text. The v1 copy keeps checking the vaults it is passed with has_one, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8Wo5zNLqZ2NQqSjGD7WpK --- CHANGELOG.md | 14 ++ finance/order-book/anchor-v1/CHANGELOG.md | 14 ++ finance/order-book/anchor-v1/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 +++++++----------- 6 files changed, 124 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6ebb76b..0dd0c17d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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-23] - The token fundraiser and order book Anchor v1 copies catch up + +Under the old rule that `anchor-v1/` copies were frozen, two v1 copies were +left behind by changes to their v2 counterparts. Now that the copies track +each other, both are ported. + +- Token fundraiser (Anchor v1): `close_contributor` and the + `FundraiserStillOpen` error, so a contributor to a successful raise can take + back their Contributor account's rent. Same two tests as the v2 copy. +- Order book (Anchor v1): the base, quote, and fee vaults are PDAs of the + market at `["base_vault", market]`, `["quote_vault", market]` and + `["fee_vault", market]`, so a client derives them instead of generating and + signing with three extra keys. The tests derive them too. + ## [2026-09-23] - Anchor v1 copies track their Anchor v2 counterparts CONTRIBUTING.md described each `anchor-v1/` copy as a frozen snapshot that diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index 4f1163881..cae3632e7 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-09-23 + +### Changed + +- Ported from the Anchor v2 copy: 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 with + `has_one`. 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-v1/README.md b/finance/order-book/anchor-v1/README.md index 723741e4a..79bb1ba7e 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -213,9 +213,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. @@ -372,23 +372,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 `has_one`; 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 `has_one`; 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 -`has_one = 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 `has_one = 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 @@ -543,7 +545,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:** @@ -559,10 +563,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-v1/programs/order-book/src/instructions/initialize_market.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/initialize_market.rs index 0361fc696..ba7592e6c 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/instructions/initialize_market.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/initialize_market.rs @@ -2,7 +2,9 @@ use anchor_lang::prelude::*; 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. @@ -89,6 +91,8 @@ pub struct InitializeMarketAccountConstraints<'info> { #[account( init, payer = authority, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, token::mint = base_mint, token::authority = market, token::token_program = token_program @@ -98,6 +102,8 @@ pub struct InitializeMarketAccountConstraints<'info> { #[account( init, payer = authority, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, token::mint = quote_mint, token::authority = market, token::token_program = token_program @@ -109,6 +115,8 @@ pub struct InitializeMarketAccountConstraints<'info> { #[account( init, payer = authority, + seeds = [FEE_VAULT_SEED, market.key().as_ref()], + bump, token::mint = quote_mint, token::authority = market, token::token_program = token_program diff --git a/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs b/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs index f8db55790..bccfde907 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs +++ b/finance/order-book/anchor-v1/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-v1/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs index 0d25dbb54..c1c81392f 100644 --- a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs @@ -34,6 +34,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 @@ -94,6 +97,12 @@ fn market_pda(program_id: &Pubkey, base_mint: &Pubkey, quote_mint: &Pubkey) -> P market } +/// The market's vaults are PDAs of the market, one seed each. +fn vault_pda(program_id: &Pubkey, seed: &[u8], market: &Pubkey) -> Pubkey { + let (vault, _) = Pubkey::find_program_address(&[seed, market.as_ref()], program_id); + vault +} + fn market_user_pda(program_id: &Pubkey, market: &Pubkey, owner: &Pubkey) -> Pubkey { let (market_user, _) = Pubkey::find_program_address( &[MARKET_USER_SEED, market.as_ref(), owner.as_ref()], @@ -126,11 +135,11 @@ struct Scenario { seller: Keypair, base_mint: Pubkey, quote_mint: Pubkey, - 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: Pubkey, + quote_vault: Pubkey, + // Fees accumulate here (quote mint). The market PDA signs transfers out of + // it, same as the other two vaults. + fee_vault: Pubkey, market: Pubkey, // 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, @@ -194,11 +203,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, @@ -397,7 +406,7 @@ fn build_withdraw_fees_ix( &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(), @@ -441,8 +450,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, @@ -466,13 +475,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(); @@ -509,13 +512,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(); @@ -539,11 +536,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 ); } @@ -557,13 +554,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(); @@ -608,7 +599,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. @@ -618,7 +609,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 ); @@ -652,7 +643,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!( @@ -660,7 +651,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 ); } @@ -705,13 +696,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(); @@ -762,13 +747,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(); @@ -850,7 +829,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. @@ -954,7 +933,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!( @@ -1003,7 +982,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!( @@ -1064,9 +1043,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, @@ -1099,13 +1078,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"); @@ -1120,13 +1093,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"); @@ -1141,13 +1108,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"); @@ -1171,13 +1132,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!( @@ -1316,7 +1271,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 ); @@ -1391,7 +1346,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. @@ -1469,7 +1424,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 ); @@ -1875,7 +1830,7 @@ fn fee_rounds_up_when_gross_is_not_a_bps_multiple() { &sc.buyer.pubkey()).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. @@ -1927,7 +1882,7 @@ fn fee_vault_receives_exactly_bps_of_taker_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 ); } @@ -1984,7 +1939,7 @@ fn authority_can_withdraw_fees_after_match() { 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 ); @@ -1999,7 +1954,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!(