From 5b515a912ecace1018e1652978d43316628177d1 Mon Sep 17 00:00:00 2001 From: cds-amal Date: Sun, 17 May 2026 22:08:05 -0400 Subject: [PATCH 1/3] Remove original tests --- programs/escrow/tests/test_with_expiry.rs | 113 --------- programs/escrow/tests/test_without_expiry.rs | 176 -------------- programs/escrow/tests/utils.rs | 242 ------------------- 3 files changed, 531 deletions(-) delete mode 100644 programs/escrow/tests/test_with_expiry.rs delete mode 100644 programs/escrow/tests/test_without_expiry.rs delete mode 100644 programs/escrow/tests/utils.rs diff --git a/programs/escrow/tests/test_with_expiry.rs b/programs/escrow/tests/test_with_expiry.rs deleted file mode 100644 index 7cdbd05..0000000 --- a/programs/escrow/tests/test_with_expiry.rs +++ /dev/null @@ -1,113 +0,0 @@ -mod utils; - -use { - utils::{ - current_unix_timestamp, escrow_state, send_instruction, set_unix_timestamp, setup, - token_balance, try_send_instruction, EscrowAccounts, DEFAULT_OFFER_AMOUNT, MINT_AMOUNT, - }, -}; - -#[test] -fn make_with_future_expiry_persists_timestamp() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 10); - - let now = current_unix_timestamp(&svm); - let expiry = now.checked_add(3_600).unwrap(); - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, Some(expiry)), - ); - - let escrow = escrow_state(&svm, &accounts.escrow); - assert_eq!(escrow.expiry_utc, Some(expiry)); - assert_eq!(token_balance(&svm, &accounts.vault), DEFAULT_OFFER_AMOUNT); -} - -#[test] -fn make_rejects_past_expiry() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 11); - - set_unix_timestamp(&mut svm, 1_000_000); - - let past = 500_000; - let result = try_send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, Some(past)), - ); - - assert!(result.is_err()); - assert!(svm.get_account(&accounts.escrow).is_none()); -} - -#[test] -fn take_succeeds_before_expiry() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 12); - - let expiry = current_unix_timestamp(&svm).checked_add(3_600).unwrap(); - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, Some(expiry)), - ); - - send_instruction(&mut svm, &taker_authority, accounts.take_ix()); - - assert!(svm.get_account(&accounts.escrow).is_none()); - assert!(svm.get_account(&accounts.vault).is_none()); - assert_eq!( - token_balance(&svm, &accounts.taker_ata_a), - DEFAULT_OFFER_AMOUNT - ); - assert_eq!( - token_balance(&svm, &accounts.maker_ata_b), - DEFAULT_OFFER_AMOUNT - ); -} - -#[test] -fn take_fails_after_expiry() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 13); - - let expiry = current_unix_timestamp(&svm).checked_add(3_600).unwrap(); - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, Some(expiry)), - ); - - set_unix_timestamp(&mut svm, expiry.checked_add(1).unwrap()); - - let result = try_send_instruction(&mut svm, &taker_authority, accounts.take_ix()); - assert!(result.is_err()); - - assert!(svm.get_account(&accounts.escrow).is_some()); - assert_eq!(token_balance(&svm, &accounts.vault), DEFAULT_OFFER_AMOUNT); -} - -#[test] -fn refund_works_after_expiry() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 14); - - let expiry = current_unix_timestamp(&svm).checked_add(3_600).unwrap(); - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, Some(expiry)), - ); - - set_unix_timestamp(&mut svm, expiry.checked_add(1_000).unwrap()); - - send_instruction(&mut svm, &maker_authority, accounts.refund_ix()); - - assert!(svm.get_account(&accounts.escrow).is_none()); - assert!(svm.get_account(&accounts.vault).is_none()); - assert_eq!(token_balance(&svm, &accounts.maker_ata_a), MINT_AMOUNT); -} diff --git a/programs/escrow/tests/test_without_expiry.rs b/programs/escrow/tests/test_without_expiry.rs deleted file mode 100644 index f529f27..0000000 --- a/programs/escrow/tests/test_without_expiry.rs +++ /dev/null @@ -1,176 +0,0 @@ -mod utils; - -use { - utils::{ - escrow_state, send_instruction, setup, token_balance, try_send_instruction, EscrowAccounts, - DEFAULT_OFFER_AMOUNT, MINT_AMOUNT, - }, -}; - -#[test] -fn make_locks_tokens_in_vault_and_initialises_escrow() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 1); - - let maker_a_before = token_balance(&svm, &accounts.maker_ata_a); - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - - let maker_a_after = token_balance(&svm, &accounts.maker_ata_a); - assert_eq!(maker_a_before - maker_a_after, DEFAULT_OFFER_AMOUNT); - assert_eq!(token_balance(&svm, &accounts.vault), DEFAULT_OFFER_AMOUNT); - - let escrow = escrow_state(&svm, &accounts.escrow); - assert_eq!(escrow.seed, accounts.seed); - assert_eq!(escrow.maker, accounts.maker); - assert_eq!(escrow.mint_a, accounts.mint_a); - assert_eq!(escrow.mint_b, accounts.mint_b); - assert_eq!(escrow.amount, DEFAULT_OFFER_AMOUNT); - assert!(escrow.expiry_utc.is_none()); -} - -#[test] -fn deposit_and_amount_can_differ() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 2); - - let deposit = 7_000_000; - let amount = 3_000_000; - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(amount, deposit, None), - ); - - assert_eq!(token_balance(&svm, &accounts.vault), deposit); - assert_eq!(escrow_state(&svm, &accounts.escrow).amount, amount); -} - -#[test] -fn take_swaps_balances_and_closes_state() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 3); - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - - let taker_b_before = token_balance(&svm, &accounts.taker_ata_b); - - send_instruction(&mut svm, &taker_authority, accounts.take_ix()); - - assert_eq!( - taker_b_before - token_balance(&svm, &accounts.taker_ata_b), - DEFAULT_OFFER_AMOUNT - ); - assert_eq!( - token_balance(&svm, &accounts.maker_ata_b), - DEFAULT_OFFER_AMOUNT - ); - - assert_eq!( - token_balance(&svm, &accounts.taker_ata_a), - DEFAULT_OFFER_AMOUNT - ); - - assert!(svm.get_account(&accounts.escrow).is_none()); - assert!(svm.get_account(&accounts.vault).is_none()); -} - -#[test] -fn refund_returns_vault_and_closes_state() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 4); - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - - send_instruction(&mut svm, &maker_authority, accounts.refund_ix()); - - assert!(svm.get_account(&accounts.escrow).is_none()); - assert!(svm.get_account(&accounts.vault).is_none()); - - assert_eq!(token_balance(&svm, &accounts.maker_ata_a), MINT_AMOUNT); -} - -#[test] -fn take_drains_vault_when_deposit_differs_from_amount() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 200); - - let deposit = 10_000_000; - let amount = 4_000_000; - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(amount, deposit, None), - ); - assert_eq!(token_balance(&svm, &accounts.vault), deposit); - - let taker_b_before = token_balance(&svm, &accounts.taker_ata_b); - - send_instruction(&mut svm, &taker_authority, accounts.take_ix()); - - assert!(svm.get_account(&accounts.escrow).is_none()); - assert!(svm.get_account(&accounts.vault).is_none()); - - assert_eq!( - taker_b_before - token_balance(&svm, &accounts.taker_ata_b), - amount - ); - assert_eq!(token_balance(&svm, &accounts.taker_ata_a), deposit); - assert_eq!(token_balance(&svm, &accounts.maker_ata_b), amount); -} - -#[test] -fn two_concurrent_escrows_use_distinct_seeds() { - let (mut svm, maker_authority, taker_authority) = setup(); - let a = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 100); - let b = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 101); - - assert_ne!(a.escrow, b.escrow); - assert_ne!(a.vault, b.vault); - - send_instruction( - &mut svm, - &maker_authority, - a.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - send_instruction( - &mut svm, - &maker_authority, - b.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - - send_instruction(&mut svm, &maker_authority, a.refund_ix()); - assert!(svm.get_account(&a.escrow).is_none()); - assert!(svm.get_account(&b.escrow).is_some()); -} - -#[test] -fn refund_signer_must_match_escrow_maker() { - let (mut svm, maker_authority, taker_authority) = setup(); - let accounts = EscrowAccounts::new(&mut svm, &maker_authority, &taker_authority, 5); - - send_instruction( - &mut svm, - &maker_authority, - accounts.make_ix(DEFAULT_OFFER_AMOUNT, DEFAULT_OFFER_AMOUNT, None), - ); - - let refund_ix = accounts.refund_ix_with_maker(accounts.taker, accounts.taker_ata_a); - let result = try_send_instruction(&mut svm, &taker_authority, refund_ix); - assert!(result.is_err(), "taker should not be able to refund"); - assert!(svm.get_account(&accounts.escrow).is_some()); -} diff --git a/programs/escrow/tests/utils.rs b/programs/escrow/tests/utils.rs deleted file mode 100644 index 66e2056..0000000 --- a/programs/escrow/tests/utils.rs +++ /dev/null @@ -1,242 +0,0 @@ -#![allow(dead_code)] - -use { - anchor_lang::{ - prelude::*, solana_program::instruction::Instruction, - system_program::ID as SYSTEM_PROGRAM_ID, InstructionData, ToAccountMetas, - }, - anchor_spl::{ - associated_token::{self, ID as ASSOCIATED_TOKEN_PROGRAM_ID}, - token::ID as TOKEN_PROGRAM_ID, - }, - escrow::{program_pack::Pack, ESCROW_SEED}, - litesvm::{types::TransactionResult, LiteSVM}, - litesvm_token::{spl_token, CreateAssociatedTokenAccount, CreateMint, MintTo}, - solana_keypair::Keypair, - solana_message::Message, - solana_pubkey::Pubkey, - solana_signer::Signer, - solana_transaction::Transaction, -}; - -pub const INITIAL_USER_LAMPORTS: u64 = 2_000_000_000; -pub const MINT_AMOUNT: u64 = 1_000_000_000; -pub const DEFAULT_OFFER_AMOUNT: u64 = 10_000_000; -pub const MINT_DECIMALS: u8 = 6; - -pub fn setup() -> (LiteSVM, Keypair, Keypair) { - let maker_authority = Keypair::new(); - let taker_authority = Keypair::new(); - - let bytes = include_bytes!("../../../target/deploy/escrow.so"); - - let mut svm = LiteSVM::new(); - svm.add_program(escrow::id(), bytes).unwrap(); - - svm.airdrop(&maker_authority.pubkey(), INITIAL_USER_LAMPORTS) - .unwrap(); - svm.airdrop(&taker_authority.pubkey(), INITIAL_USER_LAMPORTS) - .unwrap(); - - (svm, maker_authority, taker_authority) -} - -pub fn send_instruction(svm: &mut LiteSVM, user_authority: &Keypair, instruction: Instruction) { - try_send_instruction(svm, user_authority, instruction).unwrap(); -} - -pub fn try_send_instruction( - svm: &mut LiteSVM, - user_authority: &Keypair, - instruction: Instruction, -) -> TransactionResult { - let message = Message::new(&[instruction], Some(&user_authority.pubkey())); - let recent_blockhash = svm.latest_blockhash(); - let transaction = Transaction::new(&[user_authority], message, recent_blockhash); - svm.send_transaction(transaction) -} - -pub struct EscrowAccounts { - pub maker: Pubkey, - pub taker: Pubkey, - pub mint_a: Pubkey, - pub mint_b: Pubkey, - pub maker_ata_a: Pubkey, - pub maker_ata_b: Pubkey, - pub taker_ata_a: Pubkey, - pub taker_ata_b: Pubkey, - pub seed: u64, - pub escrow: Pubkey, - pub vault: Pubkey, -} - -impl EscrowAccounts { - pub fn new( - svm: &mut LiteSVM, - maker_authority: &Keypair, - taker_authority: &Keypair, - seed: u64, - ) -> Self { - let maker = maker_authority.pubkey(); - let taker = taker_authority.pubkey(); - - let mint_a = CreateMint::new(svm, maker_authority) - .decimals(MINT_DECIMALS) - .authority(&maker) - .send() - .unwrap(); - - let mint_b = CreateMint::new(svm, taker_authority) - .decimals(MINT_DECIMALS) - .authority(&taker) - .send() - .unwrap(); - - let maker_ata_a = CreateAssociatedTokenAccount::new(svm, maker_authority, &mint_a) - .owner(&maker) - .send() - .unwrap(); - let maker_ata_b = CreateAssociatedTokenAccount::new(svm, maker_authority, &mint_b) - .owner(&maker) - .send() - .unwrap(); - let taker_ata_a = CreateAssociatedTokenAccount::new(svm, taker_authority, &mint_a) - .owner(&taker) - .send() - .unwrap(); - let taker_ata_b = CreateAssociatedTokenAccount::new(svm, taker_authority, &mint_b) - .owner(&taker) - .send() - .unwrap(); - - MintTo::new(svm, maker_authority, &mint_a, &maker_ata_a, MINT_AMOUNT) - .send() - .unwrap(); - MintTo::new(svm, taker_authority, &mint_b, &taker_ata_b, MINT_AMOUNT) - .send() - .unwrap(); - - let (escrow, _bump) = Pubkey::find_program_address( - &[ESCROW_SEED, maker.as_ref(), &seed.to_le_bytes()], - &escrow::id(), - ); - let vault = associated_token::get_associated_token_address(&escrow, &mint_a); - - Self { - maker, - taker, - mint_a, - mint_b, - maker_ata_a, - maker_ata_b, - taker_ata_a, - taker_ata_b, - seed, - escrow, - vault, - } - } - - pub fn make_ix(&self, amount: u64, deposit: u64, expiry_utc: Option) -> Instruction { - Instruction { - program_id: escrow::id(), - accounts: escrow::accounts::Make { - escrow: self.escrow, - maker: self.maker, - maker_ata_a: self.maker_ata_a, - mint_a: self.mint_a, - mint_b: self.mint_b, - vault: self.vault, - system_program: SYSTEM_PROGRAM_ID, - token_program: TOKEN_PROGRAM_ID, - associated_token_program: ASSOCIATED_TOKEN_PROGRAM_ID, - } - .to_account_metas(None), - data: escrow::instruction::Make { - amount, - deposit, - seed: self.seed, - expiry_utc, - } - .data(), - } - } - - pub fn take_ix(&self) -> Instruction { - Instruction { - program_id: escrow::id(), - accounts: escrow::accounts::Take { - escrow: self.escrow, - maker: self.maker, - mint_a: self.mint_a, - mint_b: self.mint_b, - vault: self.vault, - maker_ata_b: self.maker_ata_b, - taker: self.taker, - taker_ata_a: self.taker_ata_a, - taker_ata_b: self.taker_ata_b, - system_program: SYSTEM_PROGRAM_ID, - token_program: TOKEN_PROGRAM_ID, - associated_token_program: ASSOCIATED_TOKEN_PROGRAM_ID, - } - .to_account_metas(None), - data: escrow::instruction::Take {}.data(), - } - } - - pub fn refund_ix(&self) -> Instruction { - Instruction { - program_id: escrow::id(), - accounts: escrow::accounts::Refund { - escrow: self.escrow, - maker: self.maker, - maker_ata_a: self.maker_ata_a, - mint_a: self.mint_a, - vault: self.vault, - system_program: SYSTEM_PROGRAM_ID, - token_program: TOKEN_PROGRAM_ID, - } - .to_account_metas(None), - data: escrow::instruction::Refund {}.data(), - } - } - - - pub fn refund_ix_with_maker(&self, maker: Pubkey, maker_ata_a: Pubkey) -> Instruction { - Instruction { - program_id: escrow::id(), - accounts: escrow::accounts::Refund { - escrow: self.escrow, - maker, - maker_ata_a, - mint_a: self.mint_a, - vault: self.vault, - system_program: SYSTEM_PROGRAM_ID, - token_program: TOKEN_PROGRAM_ID, - } - .to_account_metas(None), - data: escrow::instruction::Refund {}.data(), - } - } -} - -pub fn token_balance(svm: &LiteSVM, ata: &Pubkey) -> u64 { - spl_token::state::Account::unpack(&svm.get_account(ata).unwrap().data) - .unwrap() - .amount -} - -pub fn escrow_state(svm: &LiteSVM, escrow: &Pubkey) -> escrow::state::Escrow { - let account = svm.get_account(escrow).unwrap(); - escrow::state::Escrow::try_deserialize(&mut account.data.as_ref()).unwrap() -} - -pub fn current_unix_timestamp(svm: &LiteSVM) -> i64 { - svm.get_sysvar::().unix_timestamp -} - -pub fn set_unix_timestamp(svm: &mut LiteSVM, new_ts: i64) { - let mut clock = svm.get_sysvar::(); - clock.unix_timestamp = new_ts; - svm.set_sysvar::(&clock); -} From b6abe6799373750fe44f4e3372d3b08ec5118b1f Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 18 May 2026 01:29:07 -0400 Subject: [PATCH 2/3] test with anchor-litesvm for testing --- Anchor.toml | 4 +- Cargo.lock | 325 ++++++++++++++++++++- README.md | 179 +++++++++++- programs/escrow/Cargo.toml | 3 + programs/escrow/src/instructions/make.rs | 5 + programs/escrow/src/instructions/refund.rs | 5 + programs/escrow/src/instructions/take.rs | 5 + programs/escrow/src/lib.rs | 5 +- programs/escrow/src/test_helpers.rs | 63 ++++ programs/escrow/tests/common/mod.rs | 170 +++++++++++ programs/escrow/tests/test_make.rs | 135 +++++++++ programs/escrow/tests/test_refund.rs | 69 +++++ programs/escrow/tests/test_take.rs | 153 ++++++++++ 13 files changed, 1106 insertions(+), 15 deletions(-) create mode 100644 programs/escrow/src/test_helpers.rs create mode 100644 programs/escrow/tests/common/mod.rs create mode 100644 programs/escrow/tests/test_make.rs create mode 100644 programs/escrow/tests/test_refund.rs create mode 100644 programs/escrow/tests/test_take.rs diff --git a/Anchor.toml b/Anchor.toml index c1b032c..5e34719 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,9 +1,11 @@ +[toolchain] + [features] resolution = true skip-lint = false [programs.localnet] -escrow = "Exn6aYyaYd87AhNrEKhaGHXGJgXumXeNCkJnkKzM23wV" +escrow = "5YuYrfNC8emUaLBbHcu7AvyxNRgbvp9B5TaDehFz9g9K" [provider] cluster = "localnet" diff --git a/Cargo.lock b/Cargo.lock index 7df683a..a4efbd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,6 +311,40 @@ dependencies = [ "serde", ] +[[package]] +name = "anchor-litesvm" +version = "0.4.0" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +dependencies = [ + "anchor-lang", + "anchor-litesvm-derive", + "base64 0.22.1", + "borsh", + "litesvm 0.11.0", + "litesvm-utils", + "sha2 0.10.9", + "solana-account", + "solana-hash 3.1.0", + "solana-keypair", + "solana-program", + "solana-signature", + "solana-signer", + "solana-transaction", + "spl-associated-token-account", + "spl-token", + "thiserror 2.0.18", +] + +[[package]] +name = "anchor-litesvm-derive" +version = "0.4.0" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "anchor-spl" version = "1.0.2" @@ -1250,9 +1284,10 @@ name = "escrow" version = "0.1.0" dependencies = [ "anchor-lang", + "anchor-litesvm", "anchor-spl", - "litesvm", - "litesvm-token", + "litesvm 0.10.0", + "litesvm-token 0.10.0", "solana-keypair", "solana-message", "solana-pubkey 4.2.0", @@ -1691,7 +1726,71 @@ dependencies = [ "solana-precompile-error", "solana-program-error", "solana-program-runtime", - "solana-rent", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sha256-hasher", + "solana-signature", + "solana-signer", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-log-collector", + "solana-svm-timings", + "solana-svm-transaction", + "solana-system-interface 2.0.0", + "solana-system-program", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "litesvm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347d8c652d592c618ac996f2ab21f8c0b0f2da3fbbca227a6887ee61bb75f2de" +dependencies = [ + "agave-feature-set", + "agave-reserved-account-keys", + "agave-syscalls", + "ansi_term", + "bincode", + "indexmap", + "itertools 0.14.0", + "log", + "serde", + "solana-account", + "solana-address 2.6.0", + "solana-address-lookup-table-interface", + "solana-bpf-loader-program", + "solana-builtins", + "solana-clock", + "solana-compute-budget", + "solana-compute-budget-instruction", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-feature-gate-interface", + "solana-fee", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instructions-sysvar", + "solana-keypair", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-message", + "solana-native-token", + "solana-nonce", + "solana-nonce-account", + "solana-precompile-error", + "solana-program-error", + "solana-program-runtime", + "solana-rent 3.1.0", "solana-sdk-ids", "solana-sha256-hasher", "solana-signature", @@ -1719,14 +1818,14 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f319bae54889c96cef85253ea2374310998294d1dbd1d40c3b9e117a273d3c1" dependencies = [ - "litesvm", + "litesvm 0.10.0", "smallvec", "solana-account", "solana-address 2.6.0", "solana-keypair", "solana-program-option", "solana-program-pack", - "solana-rent", + "solana-rent 3.1.0", "solana-signer", "solana-system-interface 2.0.0", "solana-transaction", @@ -1735,6 +1834,46 @@ dependencies = [ "spl-token-interface", ] +[[package]] +name = "litesvm-token" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6cdb7d678bfa9279f8cb29079c976a4d930c7b30070a9687f4d6d64b528e748" +dependencies = [ + "litesvm 0.11.0", + "smallvec", + "solana-account", + "solana-address 2.6.0", + "solana-keypair", + "solana-program-option", + "solana-program-pack", + "solana-rent 3.1.0", + "solana-signer", + "solana-system-interface 2.0.0", + "solana-transaction", + "solana-transaction-error", + "spl-associated-token-account-interface", + "spl-token-interface", +] + +[[package]] +name = "litesvm-utils" +version = "0.4.0" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +dependencies = [ + "litesvm 0.11.0", + "litesvm-token 0.11.0", + "solana-keypair", + "solana-program", + "solana-program-pack", + "solana-signer", + "solana-system-interface 2.0.0", + "solana-transaction", + "spl-associated-token-account", + "spl-token", + "thiserror 2.0.18", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1756,6 +1895,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "merlin" version = "3.0.0" @@ -2428,6 +2576,8 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" dependencies = [ + "bincode", + "serde_core", "solana-address 2.6.0", "solana-program-error", "solana-program-memory", @@ -2765,15 +2915,54 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +name = "solana-epoch-stake" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027e6d0b9e7daac5b2ac7c3f9ca1b727861121d9ef05084cf435ff736051e7c2" +dependencies = [ + "solana-define-syscall 5.1.0", + "solana-pubkey 4.2.0", +] + +[[package]] +name = "solana-example-mocks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978855d164845c1b0235d4b4d101cadc55373fffaf0b5b6cfa2194d25b2ed658" +dependencies = [ + "serde", + "serde_derive", + "solana-address-lookup-table-interface", + "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keccak-hasher", + "solana-message", + "solana-nonce", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-interface 2.0.0", + "thiserror 2.0.18", +] + [[package]] name = "solana-feature-gate-interface" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75ca9b5cbb6f500f7fd73db5bd95640f71a83f04d6121a0e59a43b202dca2731" dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-account-info", + "solana-instruction", "solana-program-error", "solana-pubkey 4.2.0", + "solana-rent 4.2.0", "solana-sdk-ids", + "solana-system-interface 3.2.0", ] [[package]] @@ -2837,6 +3026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" dependencies = [ "bincode", + "borsh", "serde", "serde_derive", "solana-define-syscall 5.1.0", @@ -3083,6 +3273,53 @@ dependencies = [ "num-traits", ] +[[package]] +name = "solana-program" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b12305dd81045d705f427acd0435a2e46444b65367d7179d7bdcfc3bc5f5eb" +dependencies = [ + "memoffset", + "solana-account-info", + "solana-big-mod-exp", + "solana-blake3-hasher", + "solana-borsh", + "solana-clock", + "solana-cpi", + "solana-define-syscall 3.0.0", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-epoch-stake", + "solana-example-mocks", + "solana-fee-calculator", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instruction-error", + "solana-instructions-sysvar", + "solana-keccak-hasher", + "solana-last-restart-slot", + "solana-msg", + "solana-native-token", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-secp256k1-recover", + "solana-serde-varint", + "solana-serialize-utils", + "solana-sha256-hasher", + "solana-short-vec", + "solana-slot-hashes", + "solana-slot-history", + "solana-stable-layout", + "solana-sysvar", + "solana-sysvar-id", +] + [[package]] name = "solana-program-entrypoint" version = "3.1.1" @@ -3102,6 +3339,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" dependencies = [ "borsh", + "serde", + "serde_derive", ] [[package]] @@ -3153,7 +3392,7 @@ dependencies = [ "solana-loader-v3-interface", "solana-program-entrypoint", "solana-pubkey 3.0.0", - "solana-rent", + "solana-rent 3.1.0", "solana-sbpf", "solana-sdk-ids", "solana-slot-hashes", @@ -3204,6 +3443,15 @@ dependencies = [ "solana-sysvar-id", ] +[[package]] +name = "solana-rent" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9809b081e99bc142ce803bcd7ee18306759ce3b30a96a9da3f6f41c45e50ef0" +dependencies = [ + "solana-sdk-macro", +] + [[package]] name = "solana-sanitize" version = "3.0.1" @@ -3531,6 +3779,8 @@ checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74" dependencies = [ "base64 0.22.1", "bincode", + "bytemuck", + "bytemuck_derive", "lazy_static", "serde", "serde_derive", @@ -3547,7 +3797,7 @@ dependencies = [ "solana-program-error", "solana-program-memory", "solana-pubkey 4.2.0", - "solana-rent", + "solana-rent 3.1.0", "solana-sdk-ids", "solana-sdk-macro", "solana-slot-hashes", @@ -3599,7 +3849,7 @@ dependencies = [ "solana-instruction", "solana-instructions-sysvar", "solana-pubkey 3.0.0", - "solana-rent", + "solana-rent 3.1.0", "solana-sbpf", "solana-sdk-ids", ] @@ -3634,7 +3884,7 @@ dependencies = [ "solana-instruction", "solana-instruction-error", "solana-pubkey 3.0.0", - "solana-rent", + "solana-rent 3.1.0", "solana-sdk-ids", "solana-serde-varint", "solana-serialize-utils", @@ -3664,7 +3914,7 @@ dependencies = [ "solana-packet", "solana-program-runtime", "solana-pubkey 3.0.0", - "solana-rent", + "solana-rent 3.1.0", "solana-sdk-ids", "solana-signer", "solana-slot-hashes", @@ -3800,12 +4050,39 @@ dependencies = [ "der", ] +[[package]] +name = "spl-associated-token-account" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0242277e290c023de8826f504abcf9206b3cd4e18d9ace4ec59a698b2828e88b" +dependencies = [ + "borsh", + "num-derive", + "num-traits", + "solana-account-info", + "solana-cpi", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-system-interface 2.0.0", + "solana-sysvar", + "spl-associated-token-account-interface", + "spl-token-2022-interface", + "spl-token-interface", + "thiserror 2.0.18", +] + [[package]] name = "spl-associated-token-account-interface" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6433917b60441d68d99a17e121d9db0ea15a9a69c0e5afa34649cf5ba12612f" dependencies = [ + "borsh", "solana-instruction", "solana-pubkey 3.0.0", ] @@ -3866,6 +4143,34 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "spl-token" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "878b0183d51fcd8a53e1604f4c13321894cf53227e6773c529b0d03d499a8dfd" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-cpi", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sysvar", + "spl-token-interface", + "thiserror 2.0.18", +] + [[package]] name = "spl-token-2022-interface" version = "2.1.0" diff --git a/README.md b/README.md index f778be2..1209bcd 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,187 @@ sequenceDiagram P->>P: close escrow → maker rent ``` -## Running tests - -All tests are Rust LiteSVM (`programs/escrow/tests/`). No JS/TS suite — clock-warp + deterministic mints are easier in-Rust. +## Tests ```sh anchor test ``` +The suite lives under `programs/escrow/tests/`, written against [`anchor-litesvm`](https://crates.io/crates/anchor-litesvm). No JS/TS harness; clock warp and deterministic mints are easier in-Rust, and the program's `#[error_code]` enum is in scope so assertions can name errors directly. + +A full run, 15 tests across the three instruction files: + +```text + Running unittests src/lib.rs +running 1 test +test test_id ... ok +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured + + Running tests/test_make.rs +running 6 tests +test make_works_no_expiry ... ok +test make_works_expiry ... ok +test make_locks_tokens_in_vault_and_initialises_escrow ... ok +test make_rejects_past_expiry ... ok +test deposit_and_amount_can_differ ... ok +test two_concurrent_escrows_use_distinct_seeds ... ok +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured + + Running tests/test_refund.rs +running 3 tests +test refund_returns_vault_and_closes_state ... ok +test refund_works_after_expiry ... ok +test refund_signer_must_match_escrow_maker ... ok +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured + + Running tests/test_take.rs +running 6 tests +test asymmetric_mint_decimals_are_pinned ... ok +test take_rejects_swapped_mints ... ok +test take_drains_vault_when_deposit_differs_from_amount ... ok +test take_settles_swap_and_closes_escrow ... ok +test take_after_expiry_fails_with_escrow_expired ... ok +test take_succeeds_before_expiry ... ok +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured +``` + +### Structured CPI logs + +Calling `.print_logs_structured()` on a `TransactionResult` (or letting `send_ok` / `send_anchor_err` do it automatically when an assertion fires) prints the program invocation chain as a tree instead of as the flat `Program log:` dump that `solana-test-validator` emits. The compute units per frame come along for free. Here's `take_settles_swap_and_closes_escrow` running clean: + +```text +=== Structured Transaction Logs === +Transaction +└── 5YuYrfNC8emUaLBbHcu7AvyxNRgbvp9B5TaDehFz9g9K [1] ✓ 65457cu + ├── ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL [2] ✓ 13416cu + │ ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 183cu + │ ├── 11111111111111111111111111111111 [3] ✓ + │ ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 38cu + │ └── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 235cu + ├── ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL [2] ✓ 13517cu + │ ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 183cu + │ ├── 11111111111111111111111111111111 [3] ✓ + │ ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 38cu + │ └── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [3] ✓ 235cu + ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [2] ✓ 105cu + ├── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [2] ✓ 105cu + └── TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA [2] ✓ 118cu +Compute Units: 65457 +==================================== +``` + +Reading top-down, the depth-2 frames map to: + +1. `ATokenGPvbd...JA8knL` — `init_if_needed` on `taker_ata_a` (taker's ATA for mint A). Nested calls go: token-program existence check, system-program allocate, two more token-program calls (initialize + set authority). +2. `ATokenGPvbd...JA8knL` — same shape, this time for `maker_ata_b` (maker's ATA for mint B). +3. `Tokenkeg...Q5DA` — `transfer_checked` for `pay_maker` (mint B, taker -> maker). +4. `Tokenkeg...Q5DA` — `transfer_checked` for `release_to_taker` (mint A, vault -> taker, signed by the escrow PDA). +5. `Tokenkeg...Q5DA` — `close_account` on the vault, with the rent returned to the maker. + +That's the entire `take` flow visible in one screen. When a test fails inside one of those frames, the tree still prints, so you can see which CPI tripped without grepping the flat log dump for `Program log: AnchorError`. + +### Layout + +```text +programs/escrow/ +├── src/test_helpers.rs # EscrowBundle: the index page for accounts +└── tests/ + ├── common/mod.rs # setup_accounts, run_make, make_ix_with + ├── test_make.rs + ├── test_take.rs + └── test_refund.rs +``` + +Files are split by instruction. (The earlier shape on `main` split by feature flag instead: `test_with_expiry.rs` vs `test_without_expiry.rs`. Since expiry is a property of every ix, that split scattered each ix's tests across two files; the per-ix layout lets you read top-to-bottom against one instruction at a time.) + +### `EscrowBundle`: one place that lists every account + +`EscrowBundle` in `src/test_helpers.rs` is the suite's **index page for accounts**: every pubkey any ix reads or inits is listed once, with per-field doc comments explaining role and lifecycle (signer, PDA, ATA owned by whom, init vs pre-existing). Field names double as a search dimension: `grep accs.vault tests/` finds every test that touches the vault. + +```rust +#[derive(Bundle, Copy, Clone, Debug)] +pub struct EscrowBundle { + pub maker: Pubkey, + pub taker: Pubkey, + pub mint_a: Pubkey, + pub mint_b: Pubkey, + pub maker_ata_a: Pubkey, + pub maker_ata_b: Pubkey, + pub taker_ata_a: Pubkey, + pub taker_ata_b: Pubkey, + pub escrow: Pubkey, + pub vault: Pubkey, +} +``` + +The struct is gated behind `#[cfg(not(target_os = "solana"))]` so BPF program builds don't pull in `anchor-litesvm`. + +### What the new ergonomics removed + +The previous suite hand-rolled per-ix builders for `make`, `take`, and `refund`, each one re-typing the full account list as a `to_account_metas(None)` block plus a parallel `InstructionData::data()` block: + +```rust +// before: utils.rs on main +pub fn refund_ix(&self) -> Instruction { + Instruction { + program_id: escrow::id(), + accounts: escrow::accounts::Refund { + escrow: self.escrow, + maker: self.maker, + maker_ata_a: self.maker_ata_a, + mint_a: self.mint_a, + vault: self.vault, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None), + data: escrow::instruction::Refund {}.data(), + } +} +``` + +With `#[derive(Bundle)]`, a generated `From` impl fills the accounts slot, so the call site collapses to one line: + +```rust +// after +let ix = ctx.program().build_ix(accs.bundle, instruction::Refund {}); +``` + +Transaction sending got the same treatment: `ctx.svm.send_ok(ix, &[&signer])` and `ctx.svm.send_anchor_err(ix, &[&signer], "EscrowExpired")` replace hand-rolled `Message::new` + `Transaction::new` + `send_transaction`. Naming the error string keeps the assertion next to the program's `#[error_code]` variant; no decoding `TransactionError` -> `InstructionError` -> `Custom(n)` to figure out which check fired. + +The host-side scaffolding shrank to match: `tests/utils.rs` (242 lines, three per-ix builders) became `tests/common/mod.rs` (170 lines, mostly the documented `setup_accounts` helper and constants), with a documented 63-line `test_helpers.rs` carrying the bundle definition that every ix reuses. + +### Adversarial tests mutate the bundle inline + +Negative tests don't need a parallel `refund_ix_with_maker` helper that duplicates the boilerplate (which is what `main` had). The spoof reads at the call site, right next to the assertion that depends on it: + +```rust +let mut spoofed = accs.bundle; +spoofed.maker = accs.taker.pubkey(); // pretend the taker is the maker +spoofed.maker_ata_a = accs.taker_ata_a; // and supply a matching ATA so we + // reach the has_one check +let ix = ctx.program().build_ix(spoofed, instruction::Refund {}); +ctx.svm.send_anchor_err(ix, &[&accs.taker], "ConstraintHasOne"); +``` + +### Two scaffolding invariants, two classes of swap bug + +```rust +pub const MINT_A_DECIMALS: u8 = 6; +pub const MINT_B_DECIMALS: u8 = 9; +pub const DEPOSIT: u64 = 1_000_000; +pub const RECEIVE: u64 = 2_000_000_000; +``` + +`DEPOSIT != RECEIVE` and `MINT_A_DECIMALS != MINT_B_DECIMALS` look like the same idea ("use asymmetric numbers so swap bugs surface"), but they each catch a different class of bug, and they surface that bug in different ways. Worth pulling apart: + +| Invariant | Bug it catches | How it surfaces | Test that exercises it | +| --- | --- | --- | --- | +| `DEPOSIT != RECEIVE` (with a ~750x ratio) | A `take` that confused `vault.amount` (what's locked) with `escrow.amount` (the price) | A wrong post-balance: the taker would receive `RECEIVE` of mint_a instead of `DEPOSIT`, and the assertions trip | `take_drains_vault_when_deposit_differs_from_amount` | +| `MINT_A_DECIMALS != MINT_B_DECIMALS` | A `take` that confused `mint_a` with `mint_b` (wrong CPI account or wrong `decimals` constant) | First as an Anchor account-validation failure (`has_one = mint_a`, `associated_token::mint = mint_a`); if those ever regressed, SPL `transfer_checked` would still reject the CPI because passed decimals wouldn't match the on-chain mint | `take_rejects_swapped_mints` (active defense); `asymmetric_mint_decimals_are_pinned` (pins the constant so the dormant defense doesn't quietly go stale) | + +So the decimals difference is an experiment (i left it in to show a regression test), mostly belt-and-suspenders: under the current `Take` constraints (`has_one`, `associated_token::mint`), a mint-pubkey swap fails before any `transfer_checked` runs, and `take_rejects_swapped_mints` is what asserts that. The asymmetric decimals would carry the defense if those constraints ever regressed. The `asymmetric_mint_decimals_are_pinned` test exists only to lock the constant in place; if someone flattens both mints to the same decimals, that test trips and the second line of defense is gone. + ## Running the frontend 1. Start a local validator (pick one): diff --git a/programs/escrow/Cargo.toml b/programs/escrow/Cargo.toml index 425bb1f..ba3197b 100644 --- a/programs/escrow/Cargo.toml +++ b/programs/escrow/Cargo.toml @@ -24,6 +24,9 @@ custom-panic = [] anchor-lang = {version = "1.0.2", features = ["init-if-needed"]} anchor-spl = "1.0.2" +[target.'cfg(not(target_os = "solana"))'.dependencies] +anchor-litesvm = { git = "https://github.com/cds-rs/anchor-litesvm", branch = "class/ask" } + [dev-dependencies] litesvm = "0.10.0" litesvm-token = "0.10.0" diff --git a/programs/escrow/src/instructions/make.rs b/programs/escrow/src/instructions/make.rs index b6d70c6..8068528 100644 --- a/programs/escrow/src/instructions/make.rs +++ b/programs/escrow/src/instructions/make.rs @@ -7,6 +7,11 @@ use anchor_spl::token_interface::{ transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, }; +#[cfg_attr( + not(target_os = "solana"), + derive(anchor_litesvm::BundledPubkeys), + bundled_with(crate::test_helpers::EscrowBundle) +)] #[derive(Accounts)] #[instruction(seed: u64)] pub struct Make<'info> { diff --git a/programs/escrow/src/instructions/refund.rs b/programs/escrow/src/instructions/refund.rs index c38db43..5c03120 100644 --- a/programs/escrow/src/instructions/refund.rs +++ b/programs/escrow/src/instructions/refund.rs @@ -6,6 +6,11 @@ use anchor_spl::token_interface::{ TransferChecked, }; +#[cfg_attr( + not(target_os = "solana"), + derive(anchor_litesvm::BundledPubkeys), + bundled_with(crate::test_helpers::EscrowBundle) +)] #[derive(Accounts)] pub struct Refund<'info> { #[account(mut)] diff --git a/programs/escrow/src/instructions/take.rs b/programs/escrow/src/instructions/take.rs index 036e453..80e923d 100644 --- a/programs/escrow/src/instructions/take.rs +++ b/programs/escrow/src/instructions/take.rs @@ -7,6 +7,11 @@ use anchor_spl::token_interface::{ TransferChecked, }; +#[cfg_attr( + not(target_os = "solana"), + derive(anchor_litesvm::BundledPubkeys), + bundled_with(crate::test_helpers::EscrowBundle) +)] #[derive(Accounts)] pub struct Take<'info> { #[account(mut)] diff --git a/programs/escrow/src/lib.rs b/programs/escrow/src/lib.rs index 16ecb60..0c9d985 100644 --- a/programs/escrow/src/lib.rs +++ b/programs/escrow/src/lib.rs @@ -3,11 +3,14 @@ pub mod error; pub mod instructions; pub mod state; +#[cfg(not(target_os = "solana"))] +pub mod test_helpers; + pub use constants::*; pub use instructions::*; pub use state::*; -declare_id!("Exn6aYyaYd87AhNrEKhaGHXGJgXumXeNCkJnkKzM23wV"); +declare_id!("5YuYrfNC8emUaLBbHcu7AvyxNRgbvp9B5TaDehFz9g9K"); #[program] pub mod escrow { diff --git a/programs/escrow/src/test_helpers.rs b/programs/escrow/src/test_helpers.rs new file mode 100644 index 0000000..cfea10f --- /dev/null +++ b/programs/escrow/src/test_helpers.rs @@ -0,0 +1,63 @@ +//! Host-side test scaffolding. Gated `#[cfg(not(target_os = "solana"))]` +//! so the BPF program build doesn't pull in anchor-litesvm. + +use anchor_lang::prelude::Pubkey; +use anchor_litesvm::Bundle; + +/// Union of all bundle-projected accounts across Make, Take, and Refund. +/// Each instruction's `From` impl only references the subset of fields it +/// actually has, so unused fields per-instruction are fine. +/// +/// This struct is the test suite's **index page for accounts**: every account +/// any test touches is listed here, documented with its role and lifecycle. +/// Use the field names as a search dimension when navigating the suite +/// (e.g. `grep vault tests/` finds every test that touches the vault). +#[derive(Bundle, Copy, Clone, Debug)] +pub struct EscrowBundle { + /// Maker's wallet pubkey. Signer of `make` and `refund`; SystemAccount + /// (close-rent destination) on `take`. The escrow PDA pins this value + /// via `has_one = maker`. + pub maker: Pubkey, + + /// Taker's wallet pubkey. Signer of `take`. Pays init-if-needed for + /// `taker_ata_a` and `maker_ata_b`. + pub taker: Pubkey, + + /// Mint of the token the maker deposits. Tests use different `decimals` + /// than `mint_b` so a take that confused the two would surface as a + /// wrong post-balance. + pub mint_a: Pubkey, + + /// Mint of the token the maker wants in exchange. + pub mint_b: Pubkey, + + /// Maker's ATA for `mint_a`. Must exist before `make` (it's the source + /// of the deposit transfer). `refund` returns the vault contents here. + pub maker_ata_a: Pubkey, + + /// Maker's ATA for `mint_b`. Created by `take` via init-if-needed; + /// destination for the taker's payment of `escrow.amount`. + pub maker_ata_b: Pubkey, + + /// Taker's ATA for `mint_a`. Created by `take` via init-if-needed; + /// destination for the vault contents (`vault.amount`) released by the + /// program. + pub taker_ata_a: Pubkey, + + /// Taker's ATA for `mint_b`. Must exist before `take` (it's the source + /// of the taker's payment to the maker). + pub taker_ata_b: Pubkey, + + /// Escrow account PDA: seeds `[b"escrow", maker, seed.to_le_bytes()]`. + /// Inited by `make`, closed by `take` or `refund`. Tests with non-default + /// seeds (e.g. `two_concurrent_escrows_use_distinct_seeds`) derive a + /// different value via `common::make_ix_with(seed, ..)` and use the + /// returned bundle's `escrow`/`vault` fields rather than the default + /// ones mirrored on `common::Accounts`. + pub escrow: Pubkey, + + /// Vault ATA: ATA(escrow, mint_a). Inited by `make` (holds the deposit); + /// closed by `take` or `refund` after the contents are transferred out, + /// with the rent returned to the maker. + pub vault: Pubkey, +} diff --git a/programs/escrow/tests/common/mod.rs b/programs/escrow/tests/common/mod.rs new file mode 100644 index 0000000..9d8d68b --- /dev/null +++ b/programs/escrow/tests/common/mod.rs @@ -0,0 +1,170 @@ +//! Shared scaffolding for the escrow integration tests. +//! +//! Each `.rs` file under `tests/` is a separate crate; this module is pulled +//! in via `mod common;` from each test file. Helpers that aren't used by a +//! given test file would otherwise trip `dead_code`; the blanket allow keeps +//! both call sites honest without forcing a parallel set of trimmed-down +//! variants. + +#![allow(dead_code)] + +use anchor_lang::prelude::Pubkey; +use anchor_lang::solana_program::instruction::Instruction; +use anchor_litesvm::{AnchorContext, Signer, TestHelpers, TransactionHelpers}; +use anchor_spl::associated_token::get_associated_token_address; +use escrow::test_helpers::EscrowBundle; +use escrow::{ESCROW_SEED, ID, instruction}; +use solana_keypair::Keypair; + +/// Scenario constants. `DEPOSIT != RECEIVE` and the two mints differ in +/// decimals so a `take` that confused `escrow.amount`/`vault.amount` or +/// `mint_a`/`mint_b` decimals would produce visibly wrong post-balances. +pub const SEED: u64 = 42; +pub const MINT_A_DECIMALS: u8 = 6; +pub const MINT_B_DECIMALS: u8 = 9; +pub const DEPOSIT: u64 = 1_000_000; +pub const RECEIVE: u64 = 2_000_000_000; +pub const FUNDED_A: u64 = 5_000_000; +pub const FUNDED_B: u64 = 5_000_000_000; + +pub struct Accounts { + pub maker: Keypair, + pub taker: Keypair, + pub mint_a: Keypair, + pub mint_b: Keypair, + + // Pubkeys lifted to the top level so assertions can write `accs.vault` + // instead of `accs.bundle.vault` or re-deriving via + // `get_associated_token_address`. These mirror the bundle's fields for + // the default `SEED`; tests that build for a different seed via + // `make_ix_with` should use the returned bundle's `escrow`/`vault`. + pub maker_ata_a: Pubkey, + pub maker_ata_b: Pubkey, + pub taker_ata_a: Pubkey, + pub taker_ata_b: Pubkey, + pub escrow: Pubkey, + pub vault: Pubkey, + + /// Fully populated EscrowBundle: every field that any of make/take/refund + /// can read is set, so the same bundle is reusable across ixs. + pub bundle: EscrowBundle, +} + +/// Stand up maker + taker + both mints + funded ATAs and derive every +/// pubkey the program will read or init. Does NOT run `make` — the escrow +/// PDA and vault ATA are addresses only until the caller invokes `make`. +pub fn setup_accounts(ctx: &mut AnchorContext) -> Accounts { + let maker = ctx + .svm + .create_funded_account(10_000_000_000) + .expect("fund maker"); + let taker = ctx + .svm + .create_funded_account(10_000_000_000) + .expect("fund taker"); + + let mint_a = ctx + .svm + .create_token_mint(&maker, MINT_A_DECIMALS) + .expect("create mint_a"); + let mint_b = ctx + .svm + .create_token_mint(&maker, MINT_B_DECIMALS) + .expect("create mint_b"); + + let maker_ata_a = ctx + .svm + .create_associated_token_account(&mint_a.pubkey(), &maker) + .expect("create maker_ata_a"); + ctx.svm + .mint_to(&mint_a.pubkey(), &maker_ata_a, &maker, FUNDED_A) + .expect("fund maker_ata_a"); + + let taker_ata_b = ctx + .svm + .create_associated_token_account(&mint_b.pubkey(), &taker) + .expect("create taker_ata_b"); + ctx.svm + .mint_to(&mint_b.pubkey(), &taker_ata_b, &maker, FUNDED_B) + .expect("fund taker_ata_b"); + + let escrow = ctx.svm.get_pda( + &[ESCROW_SEED, maker.pubkey().as_ref(), &SEED.to_le_bytes()], + &ID, + ); + let vault = get_associated_token_address(&escrow, &mint_a.pubkey()); + let taker_ata_a = get_associated_token_address(&taker.pubkey(), &mint_a.pubkey()); + let maker_ata_b = get_associated_token_address(&maker.pubkey(), &mint_b.pubkey()); + + let bundle = EscrowBundle { + maker: maker.pubkey(), + taker: taker.pubkey(), + mint_a: mint_a.pubkey(), + mint_b: mint_b.pubkey(), + maker_ata_a, + maker_ata_b, + taker_ata_a, + taker_ata_b, + escrow, + vault, + }; + + Accounts { + maker, + taker, + mint_a, + mint_b, + maker_ata_a, + maker_ata_b, + taker_ata_a, + taker_ata_b, + escrow, + vault, + bundle, + } +} + +/// Run the default `make` ix (seed=SEED, amount=RECEIVE, deposit=DEPOSIT) so +/// the vault is funded and the escrow account exists. Both take and refund +/// tests start from this state. +pub fn run_make(ctx: &mut AnchorContext, accs: &Accounts, expiry_utc: Option) { + let (ix, _) = make_ix_with(ctx, accs, SEED, RECEIVE, DEPOSIT, expiry_utc); + ctx.svm + .send_instruction(ix, &[&accs.maker]) + .unwrap() + .assert_success(); +} + +/// Build (but do not send) a `make` ix with arbitrary seed/amount/deposit. +/// Returns the ix and a bundle whose `escrow`/`vault` derive from the given +/// seed, so the caller can reuse it for follow-up ixs (take, refund) against +/// the resulting escrow. +pub fn make_ix_with( + ctx: &AnchorContext, + accs: &Accounts, + seed: u64, + amount: u64, + deposit: u64, + expiry_utc: Option, +) -> (Instruction, EscrowBundle) { + let escrow = ctx.svm.get_pda( + &[ESCROW_SEED, accs.maker.pubkey().as_ref(), &seed.to_le_bytes()], + &ID, + ); + let vault = get_associated_token_address(&escrow, &accs.mint_a.pubkey()); + let mut bundle = accs.bundle; + bundle.escrow = escrow; + bundle.vault = vault; + + let ix = ctx.program().build_ix( + bundle, + instruction::Make { + seed, + amount, + deposit, + expiry_utc, + }, + ); + (ix, bundle) +} + diff --git a/programs/escrow/tests/test_make.rs b/programs/escrow/tests/test_make.rs new file mode 100644 index 0000000..992eb91 --- /dev/null +++ b/programs/escrow/tests/test_make.rs @@ -0,0 +1,135 @@ +use anchor_litesvm::{AnchorLiteSVM, TestHelpers, TransactionHelpers}; +use escrow::state::Escrow; +use escrow::{ID, instruction}; + +mod common; +use common::{DEPOSIT, FUNDED_A, RECEIVE, SEED, make_ix_with, run_make, setup_accounts}; + +#[test] +fn make_works_no_expiry() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + + let accs = setup_accounts(&mut ctx); + let ix = ctx.program().build_ix( + accs.bundle, + instruction::Make { + seed: SEED, + amount: RECEIVE, + deposit: DEPOSIT, + expiry_utc: None, + }, + ); + ctx.svm.send_ok(ix, &[&accs.maker]).print_logs_structured(); +} + +#[test] +fn make_works_expiry() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + + let accs = setup_accounts(&mut ctx); + let expiry = ctx.svm.get_unix_timestamp() + 30 * 24 * 60 * 60; // now + 30 days + let ix = ctx.program().build_ix( + accs.bundle, + instruction::Make { + seed: SEED, + amount: RECEIVE, + deposit: DEPOSIT, + expiry_utc: Some(expiry), + }, + ); + ctx.svm.send_ok(ix, &[&accs.maker]); + + // The persisted escrow should round-trip the expiry we passed in. + assert_eq!( + ctx.get_account::(&accs.escrow).unwrap().expiry_utc, + Some(expiry), + ); +} + +#[test] +fn make_locks_tokens_in_vault_and_initialises_escrow() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + + let before = ctx.svm.token_balance(&accs.maker_ata_a).expect("maker_ata_a"); + run_make(&mut ctx, &accs, None); + let after = ctx.svm.token_balance(&accs.maker_ata_a).expect("maker_ata_a"); + + assert_eq!(before - after, DEPOSIT, "maker A balance should drop by DEPOSIT"); + assert_eq!(ctx.svm.token_balance(&accs.vault), Some(DEPOSIT)); + + let escrow: Escrow = ctx.get_account(&accs.escrow).unwrap(); + assert_eq!(escrow.seed, SEED); + assert_eq!(escrow.maker, accs.bundle.maker); + assert_eq!(escrow.mint_a, accs.bundle.mint_a); + assert_eq!(escrow.mint_b, accs.bundle.mint_b); + assert_eq!(escrow.amount, RECEIVE); + assert!(escrow.expiry_utc.is_none()); +} + +#[test] +fn make_rejects_past_expiry() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + + // Anchor's check is `now < expiry`; passing `now` itself trips it. + let past = ctx.svm.get_unix_timestamp(); + let (ix, _) = make_ix_with(&ctx, &accs, SEED, RECEIVE, DEPOSIT, Some(past)); + ctx.svm.send_anchor_err(ix, &[&accs.maker], "ExpirationDateTooOld"); + + assert!( + ctx.svm.get_account(&accs.escrow).is_none(), + "escrow must not be created when make fails" + ); +} + +#[test] +fn deposit_and_amount_can_differ() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + + // Vault holds what the maker put in (`deposit`); the escrow records what + // the maker wants back (`amount`). The two are independent fields. + let deposit = 1_500_000; + let amount = 4_242_424_242; + let (ix, _) = make_ix_with(&ctx, &accs, SEED, amount, deposit, None); + ctx.svm.send_ok(ix, &[&accs.maker]); + + assert_eq!(ctx.svm.token_balance(&accs.vault), Some(deposit)); + assert_eq!( + ctx.get_account::(&accs.escrow).unwrap().amount, + amount, + ); +} + +#[test] +fn two_concurrent_escrows_use_distinct_seeds() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + + let (ix_a, bundle_a) = make_ix_with(&ctx, &accs, 100, RECEIVE, DEPOSIT, None); + let (ix_b, bundle_b) = make_ix_with(&ctx, &accs, 101, RECEIVE, DEPOSIT, None); + + assert_ne!(bundle_a.escrow, bundle_b.escrow); + assert_ne!(bundle_a.vault, bundle_b.vault); + + ctx.svm.send_ok(ix_a, &[&accs.maker]); + ctx.svm.send_ok(ix_b, &[&accs.maker]); + + // Both escrows live independently; refund one and the other survives. + let refund_a = ctx.program().build_ix(bundle_a, instruction::Refund {}); + ctx.svm.send_ok(refund_a, &[&accs.maker]); + + assert!(ctx.svm.get_account(&bundle_a.escrow).is_none()); + assert!(ctx.svm.get_account(&bundle_b.escrow).is_some()); + + // Sanity: total mint_a movement is `2 * DEPOSIT - DEPOSIT = DEPOSIT`, + // so the maker is down exactly one deposit relative to FUNDED_A. + assert_eq!(ctx.svm.token_balance(&accs.maker_ata_a), Some(FUNDED_A - DEPOSIT)); +} diff --git a/programs/escrow/tests/test_refund.rs b/programs/escrow/tests/test_refund.rs new file mode 100644 index 0000000..55422c8 --- /dev/null +++ b/programs/escrow/tests/test_refund.rs @@ -0,0 +1,69 @@ +use anchor_litesvm::{AnchorLiteSVM, Signer, TestHelpers, TransactionHelpers}; +use escrow::{ID, instruction}; + +mod common; +use common::{FUNDED_A, run_make, setup_accounts}; + +#[test] +fn refund_returns_vault_and_closes_state() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + run_make(&mut ctx, &accs, None); + + let ix = ctx.program().build_ix(accs.bundle, instruction::Refund {}); + ctx.svm.send_ok(ix, &[&accs.maker]).print_logs_structured(); + + assert!(ctx.svm.get_account(&accs.escrow).is_none(), "escrow should be closed"); + assert!(ctx.svm.get_account(&accs.vault).is_none(), "vault should be closed"); + // Deposit went out then came back, so the maker is whole again. + assert_eq!(ctx.svm.token_balance(&accs.maker_ata_a), Some(FUNDED_A)); +} + +#[test] +fn refund_works_after_expiry() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + let expiry = ctx.svm.get_unix_timestamp() + 60 * 60; + run_make(&mut ctx, &accs, Some(expiry)); + + // Refund has no time guard; expiry only gates take. Confirm here. + ctx.svm.advance_seconds(2 * 60 * 60); + + let ix = ctx.program().build_ix(accs.bundle, instruction::Refund {}); + ctx.svm.send_ok(ix, &[&accs.maker]); + + assert!(ctx.svm.get_account(&accs.escrow).is_none()); + assert!(ctx.svm.get_account(&accs.vault).is_none()); + assert_eq!(ctx.svm.token_balance(&accs.maker_ata_a), Some(FUNDED_A)); +} + +#[test] +fn refund_signer_must_match_escrow_maker() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + run_make(&mut ctx, &accs, None); + + // For the program to reach `has_one = maker`, the `maker_ata_a` slot we + // pass must be a real, initialized ATA — otherwise Anchor rejects at + // the earlier `associated_token` constraint with AccountNotInitialized. + ctx.svm + .create_associated_token_account(&accs.mint_a.pubkey(), &accs.taker) + .expect("create taker_ata_a"); + + // Swap in the taker as the would-be maker. The escrow PDA on disk still + // records the real maker; `has_one = maker` should reject the substitution. + let mut spoofed = accs.bundle; + spoofed.maker = accs.taker.pubkey(); + spoofed.maker_ata_a = accs.taker_ata_a; + + let ix = ctx.program().build_ix(spoofed, instruction::Refund {}); + ctx.svm.send_anchor_err(ix, &[&accs.taker], "ConstraintHasOne"); + + assert!( + ctx.svm.get_account(&accs.escrow).is_some(), + "escrow must survive a failed refund" + ); +} diff --git a/programs/escrow/tests/test_take.rs b/programs/escrow/tests/test_take.rs new file mode 100644 index 0000000..cfdf179 --- /dev/null +++ b/programs/escrow/tests/test_take.rs @@ -0,0 +1,153 @@ +use anchor_litesvm::{AnchorLiteSVM, TestHelpers, TransactionHelpers}; +use escrow::{ID, instruction}; + +mod common; +use common::{ + DEPOSIT, MINT_A_DECIMALS, MINT_B_DECIMALS, RECEIVE, SEED, make_ix_with, run_make, + setup_accounts, +}; + +#[test] +fn take_settles_swap_and_closes_escrow() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + run_make(&mut ctx, &accs, None); + + let ix = ctx.program().build_ix(accs.bundle, instruction::Take {}); + ctx.svm.send_ok(ix, &[&accs.taker]).print_logs_structured(); + + assert_eq!(ctx.svm.token_balance(&accs.taker_ata_a), Some(DEPOSIT), "taker should hold DEPOSIT mint_a"); + assert_eq!(ctx.svm.token_balance(&accs.maker_ata_b), Some(RECEIVE), "maker should hold RECEIVE mint_b"); + assert!(ctx.svm.get_account(&accs.vault).is_none(), "vault should be closed"); + assert!(ctx.svm.get_account(&accs.escrow).is_none(), "escrow should be closed"); +} + +#[test] +fn take_succeeds_before_expiry() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + let expiry = ctx.svm.get_unix_timestamp() + 60 * 60; // 1h window + run_make(&mut ctx, &accs, Some(expiry)); + + // Stay inside the expiry window; take should still succeed. + let ix = ctx.program().build_ix(accs.bundle, instruction::Take {}); + ctx.svm.send_ok(ix, &[&accs.taker]); + + assert!(ctx.svm.get_account(&accs.escrow).is_none()); + assert!(ctx.svm.get_account(&accs.vault).is_none()); +} + +#[test] +fn take_after_expiry_fails_with_escrow_expired() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + let expiry = ctx.svm.get_unix_timestamp() + 60 * 60; // 1h window + run_make(&mut ctx, &accs, Some(expiry)); + + ctx.svm.advance_seconds(2 * 60 * 60); + + let ix = ctx.program().build_ix(accs.bundle, instruction::Take {}); + ctx.svm.send_anchor_err(ix, &[&accs.taker], "EscrowExpired"); + + assert!(ctx.svm.get_account(&accs.escrow).is_some()); + assert!(ctx.svm.get_account(&accs.vault).is_some()); +} + +/// Pin the asymmetric-decimals invariant the rest of the suite relies on. +/// `MINT_A_DECIMALS != MINT_B_DECIMALS` means that any future regression in +/// the Anchor constraints which left a mint pubkey free to be swapped at +/// the call site would still surface at `transfer_checked`, because the +/// SPL token CPI rejects when the passed decimals don't match the on-chain +/// mint. If someone flattens these to the same value, this test trips so +/// the README's claim about that defense doesn't quietly go stale. +#[test] +fn asymmetric_mint_decimals_are_pinned() { + assert_ne!( + MINT_A_DECIMALS, MINT_B_DECIMALS, + "tests rely on asymmetric mint decimals as a second line of defense \ + against mint-swap bugs at the transfer_checked CPI layer" + ); +} + +/// Spoof: hand `take` the bundle with `mint_a` and `mint_b` swapped. The +/// escrow on disk still records the real (mint_a, mint_b) pair, so Anchor's +/// account-validation constraints (the `associated_token::mint = mint_a` +/// check on `taker_ata_a` / `vault`, or `has_one = mint_a` / `has_one = +/// mint_b` on the escrow) must reject the swap before any token transfer +/// runs. +/// +/// Belt-and-suspenders: even if those constraints ever regressed, the +/// asymmetric decimals (see `asymmetric_mint_decimals_are_pinned`) would +/// still trip `transfer_checked` at the SPL token CPI because the passed +/// decimals wouldn't match the on-chain mint. This test exercises the +/// first line; the constants pin the second. +#[test] +fn take_rejects_swapped_mints() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + run_make(&mut ctx, &accs, None); + + let mut spoofed = accs.bundle; + std::mem::swap(&mut spoofed.mint_a, &mut spoofed.mint_b); + + let ix = ctx.program().build_ix(spoofed, instruction::Take {}); + ctx.svm + .send_instruction(ix, &[&accs.taker]) + .unwrap() + .assert_failure(); + + assert!( + ctx.svm.get_account(&accs.escrow).is_some(), + "escrow must survive a rejected take", + ); + assert_eq!( + ctx.svm.token_balance(&accs.vault), + Some(DEPOSIT), + "vault balance must be untouched after a rejected take", + ); +} + +#[test] +fn take_drains_vault_when_deposit_differs_from_amount() { + let mut ctx = + AnchorLiteSVM::build_with_program(ID, include_bytes!("../../../target/deploy/escrow.so")); + let accs = setup_accounts(&mut ctx); + + // Asymmetric scenario: the maker put up more mint_a than they're asking + // for in mint_b. Take must move `deposit` mint_a out of the vault and + // `amount` mint_b from taker to maker — confusing the two would surface + // here as a wrong post-balance. + let deposit = 2_500_000; + let amount = 750_000_000; + let (make_ix, bundle) = make_ix_with(&ctx, &accs, SEED, amount, deposit, None); + ctx.svm.send_ok(make_ix, &[&accs.maker]); + assert_eq!(ctx.svm.token_balance(&accs.vault), Some(deposit)); + + let taker_b_before = ctx.svm.token_balance(&accs.taker_ata_b).expect("taker_ata_b"); + + let take_ix = ctx.program().build_ix(bundle, instruction::Take {}); + ctx.svm.send_ok(take_ix, &[&accs.taker]); + + assert!(ctx.svm.get_account(&accs.escrow).is_none()); + assert!(ctx.svm.get_account(&accs.vault).is_none()); + + assert_eq!( + taker_b_before - ctx.svm.token_balance(&accs.taker_ata_b).expect("taker_ata_b"), + amount, + "taker B drop must match escrow.amount, not vault.amount", + ); + assert_eq!( + ctx.svm.token_balance(&accs.taker_ata_a), + Some(deposit), + "taker A gain must match vault.amount (= deposit)", + ); + assert_eq!( + ctx.svm.token_balance(&accs.maker_ata_b), + Some(amount), + "maker B gain must match escrow.amount", + ); +} From 66f69cc6678648a846496712688fc664165891ce Mon Sep 17 00:00:00 2001 From: cds-amal Date: Mon, 18 May 2026 21:31:27 -0400 Subject: [PATCH 3/3] test(make): adopt ctx.load helper for typed escrow reads Before: let escrow: Escrow = ctx.get_account(&accs.escrow).unwrap(); After: let escrow: Escrow = ctx.load(&accs.escrow); ctx.load panics with the address and underlying AccountError on missing/malformed, so a load failure points at itself instead of an opaque unwrap site. Inline reads inside assert_eq! are split out so the load and the field comparison fail on separate lines. Cargo.lock bumped to anchor-litesvm ff7ad71 to pull in the helper. --- Cargo.lock | 6 +++--- programs/escrow/tests/test_make.rs | 14 +++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4efbd1..fccbede 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "anchor-litesvm" version = "0.4.0" -source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#ff7ad71cb6a4c349a29bffa0ad4dc3cdc8b1f6cf" dependencies = [ "anchor-lang", "anchor-litesvm-derive", @@ -338,7 +338,7 @@ dependencies = [ [[package]] name = "anchor-litesvm-derive" version = "0.4.0" -source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#ff7ad71cb6a4c349a29bffa0ad4dc3cdc8b1f6cf" dependencies = [ "proc-macro2", "quote", @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "litesvm-utils" version = "0.4.0" -source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#38f93f9305194826f8e420efcccf306e84550a6c" +source = "git+https://github.com/cds-rs/anchor-litesvm?branch=class%2Fask#ff7ad71cb6a4c349a29bffa0ad4dc3cdc8b1f6cf" dependencies = [ "litesvm 0.11.0", "litesvm-token 0.11.0", diff --git a/programs/escrow/tests/test_make.rs b/programs/escrow/tests/test_make.rs index 992eb91..2eed049 100644 --- a/programs/escrow/tests/test_make.rs +++ b/programs/escrow/tests/test_make.rs @@ -42,10 +42,8 @@ fn make_works_expiry() { ctx.svm.send_ok(ix, &[&accs.maker]); // The persisted escrow should round-trip the expiry we passed in. - assert_eq!( - ctx.get_account::(&accs.escrow).unwrap().expiry_utc, - Some(expiry), - ); + let escrow: Escrow = ctx.load(&accs.escrow); + assert_eq!(escrow.expiry_utc, Some(expiry)); } #[test] @@ -61,7 +59,7 @@ fn make_locks_tokens_in_vault_and_initialises_escrow() { assert_eq!(before - after, DEPOSIT, "maker A balance should drop by DEPOSIT"); assert_eq!(ctx.svm.token_balance(&accs.vault), Some(DEPOSIT)); - let escrow: Escrow = ctx.get_account(&accs.escrow).unwrap(); + let escrow: Escrow = ctx.load(&accs.escrow); assert_eq!(escrow.seed, SEED); assert_eq!(escrow.maker, accs.bundle.maker); assert_eq!(escrow.mint_a, accs.bundle.mint_a); @@ -101,10 +99,8 @@ fn deposit_and_amount_can_differ() { ctx.svm.send_ok(ix, &[&accs.maker]); assert_eq!(ctx.svm.token_balance(&accs.vault), Some(deposit)); - assert_eq!( - ctx.get_account::(&accs.escrow).unwrap().amount, - amount, - ); + let escrow: Escrow = ctx.load(&accs.escrow); + assert_eq!(escrow.amount, amount); } #[test]