diff --git a/finance/escrow/anchor-v1/CHANGELOG.md b/finance/escrow/anchor-v1/CHANGELOG.md index 230835d2..17539125 100644 --- a/finance/escrow/anchor-v1/CHANGELOG.md +++ b/finance/escrow/anchor-v1/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased (2026-11-10) + +- Tests run the three wrong-account attacks on `take_offer`, and each asserts that no token moves. `test_take_offer_rejects_taker_account_as_maker_token_account_b`: Bob names a second TSLAx account of his own as Alice's, refused by `associated_token::authority = maker` with `ConstraintTokenOwner` (2015); naming his associated token account there is already refused as a duplicate mutable account. `test_take_offer_rejects_vault_of_another_offer`: with two of Alice's offers open, Bob takes the first and names the second's vault, refused by `associated_token::authority = offer` with `ConstraintTokenOwner`. `test_take_offer_rejects_worthless_token_mint_b`: Bob pays in a mint he created, refused by `has_one = token_mint_b` with `ConstraintHasOne` (2001). + ## Unreleased, 2026-10-05 - The tests tell the book's story the way the book does: token A is USDC, minted at 6 decimals, token B is TSLAx at 8, and Alice offers 250 USDC (`USDC_OFFERED`, 250,000,000 minor units) for 1 TSLAx (`TSLAX_WANTED`, 100,000,000 minor units), which Bob takes. Both start with the standard wallet of 1 SOL and 1,000 USDC, and Bob also holds 1 TSLAx, so the take leaves him at 1,250 USDC. The switched-offer tests re-make the offer at 1 USDC for the same 1 TSLAx, and at the same 250 USDC for 2 TSLAx. The take test asserts both sides' balances in both tokens. `test_cancel_offer_rejects_non_maker` asserts Anchor's `ConstraintHasOne` (2001), so every refusal test asserts its error code. diff --git a/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs index e18e2248..07374c32 100644 --- a/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs @@ -1,9 +1,13 @@ use { anchor_lang::{ error::ErrorCode, - solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + solana_program::{ + instruction::Instruction, program_pack::Pack, pubkey::Pubkey, system_instruction, + system_program, + }, InstructionData, ToAccountMetas, }, + anchor_spl::token::spl_token, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -655,22 +659,49 @@ fn test_make_offer_rejects_same_mint() { ); } -// Bob's `take_offer` for Alice's offer `offer_id`, signed for the given terms. -fn take_offer_instruction( - es: &EscrowSetup, - offer_id: u64, - minimum_token_a_out: u64, - maximum_token_b_in: u64, -) -> Instruction { - let (offer_pda, _bump) = Pubkey::find_program_address( +// The address of Alice's offer `offer_id`. +fn offer_address(es: &EscrowSetup, offer_id: u64) -> Pubkey { + Pubkey::find_program_address( &[ b"offer", es.alice.pubkey().as_ref(), &offer_id.to_le_bytes(), ], &es.program_id, - ); - let vault = derive_ata(&offer_pda, &es.mint_a); + ) + .0 +} + +// The accounts Bob names to take Alice's offer `offer_id` honestly. An attack +// test changes one or more of them before building the instruction. +fn take_offer_accounts( + es: &EscrowSetup, + offer_id: u64, +) -> escrow::accounts::TakeOfferAccountConstraints { + let offer_pda = offer_address(es, offer_id); + escrow::accounts::TakeOfferAccountConstraints { + taker: es.bob.pubkey(), + maker: es.alice.pubkey(), + token_mint_a: es.mint_a, + token_mint_b: es.mint_b, + taker_token_account_a: es.bob_ata_a, + taker_token_account_b: es.bob_ata_b, + maker_token_account_b: es.alice_ata_b, + offer: offer_pda, + vault: derive_ata(&offer_pda, &es.mint_a), + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } +} + +// Bob's `take_offer` naming `accounts`, signed for the given terms. +fn take_offer_instruction_with_accounts( + es: &EscrowSetup, + accounts: escrow::accounts::TakeOfferAccountConstraints, + minimum_token_a_out: u64, + maximum_token_b_in: u64, +) -> Instruction { Instruction::new_with_bytes( es.program_id, &escrow::instruction::TakeOffer { @@ -678,21 +709,22 @@ fn take_offer_instruction( maximum_token_b_in, } .data(), - escrow::accounts::TakeOfferAccountConstraints { - taker: es.bob.pubkey(), - maker: es.alice.pubkey(), - token_mint_a: es.mint_a, - token_mint_b: es.mint_b, - taker_token_account_a: es.bob_ata_a, - taker_token_account_b: es.bob_ata_b, - maker_token_account_b: es.alice_ata_b, - offer: offer_pda, - vault, - associated_token_program: ata_program_id(), - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), + accounts.to_account_metas(None), + ) +} + +// Bob's `take_offer` for Alice's offer `offer_id`, signed for the given terms. +fn take_offer_instruction( + es: &EscrowSetup, + offer_id: u64, + minimum_token_a_out: u64, + maximum_token_b_in: u64, +) -> Instruction { + take_offer_instruction_with_accounts( + es, + take_offer_accounts(es, offer_id), + minimum_token_a_out, + maximum_token_b_in, ) } @@ -834,3 +866,236 @@ fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { // 250 USDC. assert_switched_offer_refused(USDC_OFFERED, 2 * TSLAX_WANTED); } + +// Every token balance a take could move, in token A and token B: the vault +// named, Alice's two accounts, and Bob's two accounts. +fn token_balances(es: &EscrowSetup, vault: &Pubkey) -> [u64; 5] { + [ + vault, + &es.alice_ata_a, + &es.alice_ata_b, + &es.bob_ata_a, + &es.bob_ata_b, + ] + .map(|address| get_token_account_balance(&es.svm, address).unwrap()) +} + +// Bob sends `take_offer`, signed for the offer's own terms, and the result is +// returned rather than unwrapped so a test can check the refusal. +fn try_take_offer( + es: &mut EscrowSetup, + accounts: escrow::accounts::TakeOfferAccountConstraints, +) -> Result<(), solana_kite::SolanaKiteError> { + let take_offer_ix = + take_offer_instruction_with_accounts(es, accounts, USDC_OFFERED, TSLAX_WANTED); + send_transaction_from_instructions( + &mut es.svm, + vec![take_offer_ix], + &[&es.payer, &es.bob], + &es.payer.pubkey(), + None, + ) +} + +// A token account for `owner` at a fresh address rather than at the owner's +// associated token account address, created through the System Program and +// the Token program. +fn create_token_account(es: &mut EscrowSetup, mint: &Pubkey, owner: &Pubkey) -> Pubkey { + let token_account = Keypair::new(); + let space = spl_token::state::Account::LEN; + let create_account_ix = system_instruction::create_account( + &es.payer.pubkey(), + &token_account.pubkey(), + es.svm.minimum_balance_for_rent_exemption(space), + space as u64, + &token_program_id(), + ); + let initialize_account_ix = spl_token::instruction::initialize_account3( + &token_program_id(), + &token_account.pubkey(), + mint, + owner, + ) + .unwrap(); + send_transaction_from_instructions( + &mut es.svm, + vec![create_account_ix, initialize_account_ix], + &[&es.payer, &token_account], + &es.payer.pubkey(), + None, + ) + .unwrap(); + token_account.pubkey() +} + +#[test] +fn test_take_offer_rejects_taker_account_as_maker_token_account_b() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let offer_id: u64 = 9; + try_make_offer( + &mut es, + offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob names a second TSLAx account of his own as Alice's, so the 1 TSLAx + // he pays would come back to him and the 250 USDC would cost nothing. (His + // associated token account for TSLAx is already `taker_token_account_b`, + // and naming it twice is refused as a duplicate mutable account.) + let bob = es.bob.pubkey(); + let bob_second_tslax_account = create_token_account(&mut es, &mint_b, &bob); + let mut accounts = take_offer_accounts(&es, offer_id); + accounts.maker_token_account_b = bob_second_tslax_account; + let vault = accounts.vault; + let balances_before = token_balances(&es, &vault); + + let result = try_take_offer(&mut es, accounts); + + // `associated_token::authority = maker` on `maker_token_account_b` + // refuses an account Alice does not own. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintTokenOwner); + assert_eq!( + token_balances(&es, &vault), + balances_before, + "no tokens move when the maker's token B account is Bob's" + ); + assert_eq!( + get_token_account_balance(&es.svm, &bob_second_tslax_account).unwrap(), + 0, + "Bob's second TSLAx account receives nothing" + ); +} + +#[test] +fn test_take_offer_rejects_vault_of_another_offer() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + // Alice has two offers open: 250 USDC for 1 TSLAx, and 500 USDC for + // 2 TSLAx. + let first_offer_id: u64 = 10; + let second_offer_id: u64 = 11; + try_make_offer( + &mut es, + first_offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + try_make_offer( + &mut es, + second_offer_id, + 2 * USDC_OFFERED, + 2 * TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob takes the first offer, paying its 1 TSLAx, but names the second + // offer's vault so he would receive its 500 USDC. + let mut accounts = take_offer_accounts(&es, first_offer_id); + let first_vault = accounts.vault; + let second_vault = derive_ata(&offer_address(&es, second_offer_id), &es.mint_a); + accounts.vault = second_vault; + let balances_before = token_balances(&es, &first_vault); + let second_vault_before = get_token_account_balance(&es.svm, &second_vault).unwrap(); + + let result = try_take_offer(&mut es, accounts); + + // `associated_token::authority = offer` on `vault` refuses a vault the + // first offer does not own. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintTokenOwner); + assert_eq!( + token_balances(&es, &first_vault), + balances_before, + "no tokens move when the vault belongs to another offer" + ); + assert_eq!( + get_token_account_balance(&es.svm, &second_vault).unwrap(), + second_vault_before, + "the second offer's USDC stays in its vault" + ); + assert_eq!(second_vault_before, 2 * USDC_OFFERED); +} + +#[test] +fn test_take_offer_rejects_worthless_token_mint_b() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let offer_id: u64 = 12; + try_make_offer( + &mut es, + offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob creates his own mint at TSLAx's decimals, mints himself 1 of its + // tokens, and creates Alice's account for it, so every token account + // constraint on token B holds for his mint. + let worthless_mint = + create_token_mint(&mut es.svm, &es.bob, TSLAX_DECIMALS, None, None).unwrap(); + let bob_worthless_ata = create_associated_token_account( + &mut es.svm, + &es.bob.pubkey(), + &worthless_mint, + &es.payer, + None, + ) + .unwrap(); + let alice_worthless_ata = create_associated_token_account( + &mut es.svm, + &es.alice.pubkey(), + &worthless_mint, + &es.payer, + None, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut es.svm, + &worthless_mint, + &bob_worthless_ata, + TSLAX_WANTED, + &es.bob, + None, + ) + .unwrap(); + + let mut accounts = take_offer_accounts(&es, offer_id); + accounts.token_mint_b = worthless_mint; + accounts.taker_token_account_b = bob_worthless_ata; + accounts.maker_token_account_b = alice_worthless_ata; + let vault = accounts.vault; + let balances_before = token_balances(&es, &vault); + + let result = try_take_offer(&mut es, accounts); + + // `has_one = token_mint_b` on `offer` refuses a mint other than the one + // the offer stored. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintHasOne); + assert_eq!( + token_balances(&es, &vault), + balances_before, + "no tokens move when token B is Bob's own mint" + ); + assert_eq!( + get_token_account_balance(&es.svm, &bob_worthless_ata).unwrap(), + TSLAX_WANTED, + "Bob keeps his worthless tokens" + ); + assert_eq!( + get_token_account_balance(&es.svm, &alice_worthless_ata).unwrap(), + 0, + "Alice receives none of Bob's worthless tokens" + ); +} diff --git a/finance/escrow/anchor/CHANGELOG.md b/finance/escrow/anchor/CHANGELOG.md index 35b9c97c..778e2cb6 100644 --- a/finance/escrow/anchor/CHANGELOG.md +++ b/finance/escrow/anchor/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased (2026-11-10) + +- Tests run the three wrong-account attacks on `take_offer`, and each asserts that no token moves. `test_take_offer_rejects_taker_account_as_maker_token_account_b`: Bob names a second TSLAx account of his own as Alice's, refused by `associated_token::authority = maker` with `ConstraintAddress` (2012); naming his associated token account there is already refused as a duplicate mutable account. `test_take_offer_rejects_vault_of_another_offer`: with two of Alice's offers open, Bob takes the first and names the second's vault, refused by `associated_token::authority = offer` with `ConstraintAddress`. `test_take_offer_rejects_worthless_token_mint_b`: Bob pays in a mint he created, refused by `address = offer.token_mint_b` with `ConstraintAddress`. + ## Unreleased, 2026-10-05 - The tests tell the book's story the way the book does: token A is USDC, minted at 6 decimals, token B is TSLAx at 8, and Alice offers 250 USDC (`USDC_OFFERED`, 250,000,000 minor units) for 1 TSLAx (`TSLAX_WANTED`, 100,000,000 minor units), which Bob takes. Both start with the standard wallet of 1 SOL and 1,000 USDC, and Bob also holds 1 TSLAx, so the take leaves him at 1,250 USDC. The switched-offer tests re-make the offer at 1 USDC for the same 1 TSLAx, and at the same 250 USDC for 2 TSLAx. The take test asserts both sides' balances in both tokens. `test_cancel_offer_rejects_non_maker` asserts Anchor's `ConstraintAddress` (2012), so every refusal test asserts its error code. diff --git a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs index b8e8eb4f..31c37336 100644 --- a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs @@ -1,8 +1,9 @@ use { anchor_lang::{ - solana_program::instruction::Instruction, system_program, Address, ErrorCode, - InstructionData, ToAccountMetas, + solana_program::{instruction::Instruction, system_instruction}, + system_program, Address, ErrorCode, InstructionData, ToAccountMetas, }, + anchor_spl::token::{spl_token, TokenAccount}, anchor_v2_testing::{litesvm::LiteSVM, Keypair, Signer}, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, @@ -654,22 +655,49 @@ fn test_make_offer_rejects_same_mint() { ); } -// Bob's `take_offer` for Alice's offer `offer_id`, signed for the given terms. -fn take_offer_instruction( - es: &EscrowSetup, - offer_id: u64, - minimum_token_a_out: u64, - maximum_token_b_in: u64, -) -> Instruction { - let (offer_pda, _bump) = Address::find_program_address( +// The address of Alice's offer `offer_id`. +fn offer_address(es: &EscrowSetup, offer_id: u64) -> Address { + Address::find_program_address( &[ b"offer", es.alice.pubkey().as_ref(), &offer_id.to_le_bytes(), ], &es.program_id, - ); - let vault = derive_ata(&offer_pda, &es.mint_a); + ) + .0 +} + +// The accounts Bob names to take Alice's offer `offer_id` honestly. An attack +// test changes one or more of them before building the instruction. +fn take_offer_accounts( + es: &EscrowSetup, + offer_id: u64, +) -> escrow::accounts::TakeOfferAccountConstraints { + let offer_pda = offer_address(es, offer_id); + escrow::accounts::TakeOfferAccountConstraints { + taker: es.bob.pubkey(), + maker: es.alice.pubkey(), + token_mint_a: es.mint_a, + token_mint_b: es.mint_b, + taker_token_account_a: es.bob_ata_a, + taker_token_account_b: es.bob_ata_b, + maker_token_account_b: es.alice_ata_b, + offer: offer_pda, + vault: derive_ata(&offer_pda, &es.mint_a), + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::ID, + } +} + +// Bob's `take_offer` naming `accounts`, signed for the given terms. +fn take_offer_instruction_with_accounts( + es: &EscrowSetup, + accounts: escrow::accounts::TakeOfferAccountConstraints, + minimum_token_a_out: u64, + maximum_token_b_in: u64, +) -> Instruction { Instruction::new_with_bytes( es.program_id, &escrow::instruction::TakeOffer { @@ -677,21 +705,22 @@ fn take_offer_instruction( maximum_token_b_in, } .data(), - escrow::accounts::TakeOfferAccountConstraints { - taker: es.bob.pubkey(), - maker: es.alice.pubkey(), - token_mint_a: es.mint_a, - token_mint_b: es.mint_b, - taker_token_account_a: es.bob_ata_a, - taker_token_account_b: es.bob_ata_b, - maker_token_account_b: es.alice_ata_b, - offer: offer_pda, - vault, - associated_token_program: ata_program_id(), - token_program: token_program_id(), - system_program: system_program::ID, - } - .to_account_metas(None), + accounts.to_account_metas(None), + ) +} + +// Bob's `take_offer` for Alice's offer `offer_id`, signed for the given terms. +fn take_offer_instruction( + es: &EscrowSetup, + offer_id: u64, + minimum_token_a_out: u64, + maximum_token_b_in: u64, +) -> Instruction { + take_offer_instruction_with_accounts( + es, + take_offer_accounts(es, offer_id), + minimum_token_a_out, + maximum_token_b_in, ) } @@ -833,3 +862,237 @@ fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { // 250 USDC. assert_switched_offer_refused(USDC_OFFERED, 2 * TSLAX_WANTED); } +// Every token balance a take could move, in token A and token B: the vault +// named, Alice's two accounts, and Bob's two accounts. +fn token_balances(es: &EscrowSetup, vault: &Address) -> [u64; 5] { + [ + vault, + &es.alice_ata_a, + &es.alice_ata_b, + &es.bob_ata_a, + &es.bob_ata_b, + ] + .map(|address| get_token_account_balance(&es.svm, address).unwrap()) +} + +// Bob sends `take_offer`, signed for the offer's own terms, and the result is +// returned rather than unwrapped so a test can check the refusal. +fn try_take_offer( + es: &mut EscrowSetup, + accounts: escrow::accounts::TakeOfferAccountConstraints, +) -> Result<(), solana_kite::SolanaKiteError> { + let take_offer_ix = + take_offer_instruction_with_accounts(es, accounts, USDC_OFFERED, TSLAX_WANTED); + send_transaction_from_instructions( + &mut es.svm, + vec![take_offer_ix], + &[&es.payer, &es.bob], + &es.payer.pubkey(), + None, + ) +} + +// A token account for `owner` at a fresh address rather than at the owner's +// associated token account address, created through the System Program and +// the Token program. +fn create_token_account(es: &mut EscrowSetup, mint: &Address, owner: &Address) -> Address { + let token_account = Keypair::new(); + let space = TokenAccount::LEN; + let create_account_ix = system_instruction::create_account( + &es.payer.pubkey(), + &token_account.pubkey(), + es.svm.minimum_balance_for_rent_exemption(space), + space as u64, + &token_program_id(), + ); + let initialize_account_ix = spl_token::instruction::initialize_account3( + &token_program_id(), + &token_account.pubkey(), + mint, + owner, + ) + .unwrap(); + send_transaction_from_instructions( + &mut es.svm, + vec![create_account_ix, initialize_account_ix], + &[&es.payer, &token_account], + &es.payer.pubkey(), + None, + ) + .unwrap(); + token_account.pubkey() +} + +#[test] +fn test_take_offer_rejects_taker_account_as_maker_token_account_b() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let offer_id: u64 = 9; + try_make_offer( + &mut es, + offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob names a second TSLAx account of his own as Alice's, so the 1 TSLAx + // he pays would come back to him and the 250 USDC would cost nothing. (His + // associated token account for TSLAx is already `taker_token_account_b`, + // and naming it twice is refused as a duplicate mutable account.) + let bob = es.bob.pubkey(); + let bob_second_tslax_account = create_token_account(&mut es, &mint_b, &bob); + let mut accounts = take_offer_accounts(&es, offer_id); + accounts.maker_token_account_b = bob_second_tslax_account; + let vault = accounts.vault; + let balances_before = token_balances(&es, &vault); + + let result = try_take_offer(&mut es, accounts); + + // `associated_token::authority = maker` on `maker_token_account_b` + // refuses an account that is not Alice's associated token account for + // TSLAx. Anchor 2 reports that as `ConstraintAddress`. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintAddress); + assert_eq!( + token_balances(&es, &vault), + balances_before, + "no tokens move when the maker's token B account is Bob's" + ); + assert_eq!( + get_token_account_balance(&es.svm, &bob_second_tslax_account).unwrap(), + 0, + "Bob's second TSLAx account receives nothing" + ); +} + +#[test] +fn test_take_offer_rejects_vault_of_another_offer() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + // Alice has two offers open: 250 USDC for 1 TSLAx, and 500 USDC for + // 2 TSLAx. + let first_offer_id: u64 = 10; + let second_offer_id: u64 = 11; + try_make_offer( + &mut es, + first_offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + try_make_offer( + &mut es, + second_offer_id, + 2 * USDC_OFFERED, + 2 * TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob takes the first offer, paying its 1 TSLAx, but names the second + // offer's vault so he would receive its 500 USDC. + let mut accounts = take_offer_accounts(&es, first_offer_id); + let first_vault = accounts.vault; + let second_vault = derive_ata(&offer_address(&es, second_offer_id), &es.mint_a); + accounts.vault = second_vault; + let balances_before = token_balances(&es, &first_vault); + let second_vault_before = get_token_account_balance(&es.svm, &second_vault).unwrap(); + + let result = try_take_offer(&mut es, accounts); + + // `associated_token::authority = offer` on `vault` refuses a vault that + // is not the first offer's associated token account for USDC. Anchor 2 + // reports that as `ConstraintAddress`. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintAddress); + assert_eq!( + token_balances(&es, &first_vault), + balances_before, + "no tokens move when the vault belongs to another offer" + ); + assert_eq!( + get_token_account_balance(&es.svm, &second_vault).unwrap(), + second_vault_before, + "the second offer's USDC stays in its vault" + ); + assert_eq!(second_vault_before, 2 * USDC_OFFERED); +} + +#[test] +fn test_take_offer_rejects_worthless_token_mint_b() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let offer_id: u64 = 12; + try_make_offer( + &mut es, + offer_id, + USDC_OFFERED, + TSLAX_WANTED, + mint_b, + alice_ata_b, + ) + .unwrap(); + + // Bob creates his own mint at TSLAx's decimals, mints himself 1 of its + // tokens, and creates Alice's account for it, so every token account + // constraint on token B holds for his mint. + let worthless_mint = + create_token_mint(&mut es.svm, &es.bob, TSLAX_DECIMALS, None, None).unwrap(); + let bob_worthless_ata = create_associated_token_account( + &mut es.svm, + &es.bob.pubkey(), + &worthless_mint, + &es.payer, + None, + ) + .unwrap(); + let alice_worthless_ata = create_associated_token_account( + &mut es.svm, + &es.alice.pubkey(), + &worthless_mint, + &es.payer, + None, + ) + .unwrap(); + mint_tokens_to_token_account( + &mut es.svm, + &worthless_mint, + &bob_worthless_ata, + TSLAX_WANTED, + &es.bob, + None, + ) + .unwrap(); + + let mut accounts = take_offer_accounts(&es, offer_id); + accounts.token_mint_b = worthless_mint; + accounts.taker_token_account_b = bob_worthless_ata; + accounts.maker_token_account_b = alice_worthless_ata; + let vault = accounts.vault; + let balances_before = token_balances(&es, &vault); + + let result = try_take_offer(&mut es, accounts); + + // `address = offer.token_mint_b` on `token_mint_b` refuses a mint other + // than the one the offer stored. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintAddress); + assert_eq!( + token_balances(&es, &vault), + balances_before, + "no tokens move when token B is Bob's own mint" + ); + assert_eq!( + get_token_account_balance(&es.svm, &bob_worthless_ata).unwrap(), + TSLAX_WANTED, + "Bob keeps his worthless tokens" + ); + assert_eq!( + get_token_account_balance(&es.svm, &alice_worthless_ata).unwrap(), + 0, + "Alice receives none of Bob's worthless tokens" + ); +} diff --git a/finance/escrow/native/CHANGELOG.md b/finance/escrow/native/CHANGELOG.md index 64c0a34b..dd0167e2 100644 --- a/finance/escrow/native/CHANGELOG.md +++ b/finance/escrow/native/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased (2026-11-10) + +- Tests run the three wrong-account attacks on `take_offer`, and each asserts that no token moves. `test_take_offer_rejects_taker_account_as_maker_token_account_b`: the taker names their own TSLAx account as the maker's, refused with `TokenAccountMismatch` because it is not the maker's associated token account for mint B. `test_take_offer_rejects_vault_of_another_offer`: with two of the maker's offers open, the taker takes the first and names the second's vault, refused with `TokenAccountMismatch` because it is not the first offer's associated token account for mint A. `test_take_offer_rejects_worthless_token_mint_b`: the taker pays in a mint they created, refused with `MintMismatch` against the offer's stored mint. + ## Unreleased, 2026-10-05 - The tests tell the book's story the way the book does: mint A is USDC, minted at 6 decimals, mint B is TSLAx at 8, and the maker offers 250 USDC (`USDC_OFFERED`, 250,000,000 minor units) for 1 TSLAx (`TSLAX_WANTED`, 100,000,000 minor units), which the taker takes. Both start with the standard wallet of 1 SOL and 1,000 USDC, and the taker also holds 1 TSLAx, so the take leaves the taker at 1,250 USDC. The switched-offer tests re-make the offer at 1 USDC for the same 1 TSLAx, and at the same 250 USDC for 2 TSLAx. The take test asserts both sides' balances in both tokens. `test_cancel_offer_rejects_non_maker` asserts `MakerMismatch`, so every refusal test asserts its error code. diff --git a/finance/escrow/native/program/tests/test.rs b/finance/escrow/native/program/tests/test.rs index 04c1d90d..f60b27a5 100644 --- a/finance/escrow/native/program/tests/test.rs +++ b/finance/escrow/native/program/tests/test.rs @@ -188,7 +188,13 @@ fn setup() -> EscrowSetup { TAKER_TSLAX, ); // The taker's own 1,000 USDC, in an account the payer opens for them. - fund_token_account(&mut svm, &payer, &mint_a.pubkey(), &taker.pubkey(), TAKER_USDC); + fund_token_account( + &mut svm, + &payer, + &mint_a.pubkey(), + &taker.pubkey(), + TAKER_USDC, + ); let (offer, _bump) = Pubkey::find_program_address( &[b"offer", maker.pubkey().as_ref(), &OFFER_ID.to_le_bytes()], @@ -236,20 +242,58 @@ fn make_offer_instruction_for( mint_b: &Pubkey, maker_account_b: &Pubkey, ) -> Instruction { + make_offer_instruction_with_id( + es, + OFFER_ID, + token_a_offered_amount, + token_b_wanted_amount, + mint_b, + maker_account_b, + ) +} + +/// The maker's offer `offer_id` and its vault, the offer PDA's associated +/// token account for mint A. +fn offer_and_vault(es: &EscrowSetup, offer_id: u64) -> (Pubkey, Pubkey) { + let (offer, _bump) = Pubkey::find_program_address( + &[ + b"offer", + es.maker.pubkey().as_ref(), + &offer_id.to_le_bytes(), + ], + &es.program_id, + ); + ( + offer, + get_associated_token_address(&offer, &es.mint_a.pubkey()), + ) +} + +/// A `make_offer` instruction for offer `offer_id`, so a test can open more +/// than one offer from the same maker. +fn make_offer_instruction_with_id( + es: &EscrowSetup, + offer_id: u64, + token_a_offered_amount: u64, + token_b_wanted_amount: u64, + mint_b: &Pubkey, + maker_account_b: &Pubkey, +) -> Instruction { + let (offer, vault) = offer_and_vault(es, offer_id); let mut make_data = vec![MAKE_OFFER]; - make_data.extend_from_slice(&OFFER_ID.to_le_bytes()); + make_data.extend_from_slice(&offer_id.to_le_bytes()); make_data.extend_from_slice(&token_a_offered_amount.to_le_bytes()); make_data.extend_from_slice(&token_b_wanted_amount.to_le_bytes()); Instruction { program_id: es.program_id, accounts: vec![ - AccountMeta::new(es.offer, false), + AccountMeta::new(offer, false), AccountMeta::new_readonly(es.mint_a.pubkey(), false), AccountMeta::new_readonly(*mint_b, false), AccountMeta::new(es.maker_account_a, false), AccountMeta::new(*maker_account_b, false), - AccountMeta::new(es.vault, false), + AccountMeta::new(vault, false), AccountMeta::new(es.maker.pubkey(), true), AccountMeta::new_readonly(spl_token_interface::id(), false), AccountMeta::new_readonly(spl_associated_token_account_interface::program::id(), false), @@ -445,7 +489,9 @@ fn test_cancel_offer_rejects_non_maker() { // `EscrowError` variants in declaration order, as the program reports them in // `InstructionError::Custom`. +const TOKEN_ACCOUNT_MISMATCH: u32 = 1; const MAKER_MISMATCH: u32 = 2; +const MINT_MISMATCH: u32 = 3; const ZERO_AMOUNT: u32 = 7; const SAME_MINT: u32 = 8; const OFFER_TERMS_CHANGED: u32 = 9; @@ -590,3 +636,148 @@ fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { // same 250 USDC. assert_switched_offer_refused(USDC_OFFERED, 2 * TSLAX_WANTED); } + +// Positions in `take_offer_instruction`'s account list, the order the +// program unpacks them in, for the accounts an attack test swaps. +const TAKE_TOKEN_MINT_B: usize = 2; +const TAKE_MAKER_TOKEN_ACCOUNT_B: usize = 3; +const TAKE_TAKER_TOKEN_ACCOUNT_B: usize = 5; +const TAKE_VAULT: usize = 6; + +/// Every token balance a take could move, in mint A and mint B: the vault, +/// the maker's two accounts, and the taker's two accounts. +fn token_balances(es: &EscrowSetup, vault: &Pubkey) -> [u64; 5] { + [ + vault, + &es.maker_account_a, + &es.maker_account_b, + &es.taker_account_a, + &es.taker_account_b, + ] + .map(|address| token_amount(&es.svm, address)) +} + +/// Send a `take_offer` the program should refuse, and check it failed with +/// `expected_code` and moved no tokens in or out of `vault` or either party's +/// accounts. +fn assert_take_offer_refused( + es: &mut EscrowSetup, + instruction: Instruction, + vault: &Pubkey, + expected_code: u32, +) { + let payer = es.payer.insecure_clone(); + let taker = es.taker.insecure_clone(); + let balances_before = token_balances(es, vault); + let result = try_send(&mut es.svm, &payer, &[instruction], &[&taker]); + assert_fails_with(result, expected_code); + assert_eq!( + token_balances(es, vault), + balances_before, + "a refused take moves no tokens" + ); +} + +#[test] +fn test_take_offer_rejects_taker_account_as_maker_token_account_b() { + let mut es = setup(); + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + let make_ix = make_offer_instruction(&es); + send(&mut es.svm, &payer, &[make_ix], &[&maker]); + + // The taker names their own TSLAx account as the maker's, so the 1 TSLAx + // they pay would come straight back to them and the 250 USDC would cost + // nothing. The maker's token B account must be the maker's associated + // token account for mint B. + let mut take_ix = take_offer_instruction(&es); + take_ix.accounts[TAKE_MAKER_TOKEN_ACCOUNT_B].pubkey = es.taker_account_b; + let vault = es.vault; + assert_take_offer_refused(&mut es, take_ix, &vault, TOKEN_ACCOUNT_MISMATCH); +} + +#[test] +fn test_take_offer_rejects_vault_of_another_offer() { + let mut es = setup(); + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + // The maker has two offers open: 250 USDC for 1 TSLAx, and 500 USDC for + // 2 TSLAx. + let second_offer_id: u64 = OFFER_ID + 1; + let make_ix = make_offer_instruction(&es); + let second_make_ix = make_offer_instruction_with_id( + &es, + second_offer_id, + 2 * USDC_OFFERED, + 2 * TSLAX_WANTED, + &es.mint_b.pubkey(), + &es.maker_account_b, + ); + send(&mut es.svm, &payer, &[make_ix, second_make_ix], &[&maker]); + let (_second_offer, second_vault) = offer_and_vault(&es, second_offer_id); + + // The taker takes the first offer, paying its 1 TSLAx, but names the + // second offer's vault so they would receive its 500 USDC. The vault must + // be the first offer's associated token account for mint A. + let mut take_ix = take_offer_instruction(&es); + take_ix.accounts[TAKE_VAULT].pubkey = second_vault; + let first_vault = es.vault; + assert_take_offer_refused(&mut es, take_ix, &first_vault, TOKEN_ACCOUNT_MISMATCH); + assert_eq!( + token_amount(&es.svm, &second_vault), + 2 * USDC_OFFERED, + "the second offer's USDC stays in its vault" + ); +} + +#[test] +fn test_take_offer_rejects_worthless_token_mint_b() { + let mut es = setup(); + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + let taker = es.taker.insecure_clone(); + let make_ix = make_offer_instruction(&es); + send(&mut es.svm, &payer, &[make_ix], &[&maker]); + + // The taker creates their own mint at TSLAx's decimals, mints themselves + // 1 of its tokens, and opens the maker's account for it, so every + // associated token account check on mint B holds for the taker's mint. + let worthless_mint = Keypair::new(); + mint_tokens( + &mut es.svm, + &taker, + &worthless_mint, + TSLAX_DECIMALS, + &taker.pubkey(), + TSLAX_WANTED, + ); + let create_maker_worthless_account = create_associated_token_account( + &taker.pubkey(), + &es.maker.pubkey(), + &worthless_mint.pubkey(), + &spl_token_interface::id(), + ); + send(&mut es.svm, &taker, &[create_maker_worthless_account], &[]); + let taker_worthless_account = + get_associated_token_address(&taker.pubkey(), &worthless_mint.pubkey()); + let maker_worthless_account = + get_associated_token_address(&es.maker.pubkey(), &worthless_mint.pubkey()); + + // The mint must be the one the offer stored. + let mut take_ix = take_offer_instruction(&es); + take_ix.accounts[TAKE_TOKEN_MINT_B].pubkey = worthless_mint.pubkey(); + take_ix.accounts[TAKE_TAKER_TOKEN_ACCOUNT_B].pubkey = taker_worthless_account; + take_ix.accounts[TAKE_MAKER_TOKEN_ACCOUNT_B].pubkey = maker_worthless_account; + let vault = es.vault; + assert_take_offer_refused(&mut es, take_ix, &vault, MINT_MISMATCH); + assert_eq!( + token_amount(&es.svm, &taker_worthless_account), + TSLAX_WANTED, + "the taker keeps their worthless tokens" + ); + assert_eq!( + token_amount(&es.svm, &maker_worthless_account), + 0, + "the maker receives none of the taker's worthless tokens" + ); +} diff --git a/finance/escrow/quasar/CHANGELOG.md b/finance/escrow/quasar/CHANGELOG.md index 923fec27..c96077e0 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased (2026-11-10) + +### Added + +- Tests run the three wrong-account attacks on `take_offer`, and each + asserts that no token moves. Each is refused with + `QuasarError::HasOneMismatch` (3005). + `test_take_offer_rejects_taker_account_as_maker_token_account_b`: the + taker names a second TSLAx account of their own as the maker's, refused by + `has_one(maker_token_account_b)`. + `test_take_offer_rejects_vault_of_another_offer`: with two of the maker's + offers open, the taker takes the first and names the second's vault, + refused by `has_one(vault)`. + `test_take_offer_rejects_worthless_token_mint_b`: the taker pays in a mint + they created, refused by `has_one(token_mint_b)`. + ## [Unreleased, 2026-10-05] ### Changed diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index 9cbdfbad..ca42e123 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -27,8 +27,13 @@ const ATTACKER: Pubkey = Pubkey::new_from_array([10; 32]); const ATTACKER_TOKEN_ACCOUNT_A: Pubkey = Pubkey::new_from_array([11; 32]); const WRONG_MINT: Pubkey = Pubkey::new_from_array([12; 32]); const WRONG_VAULT: Pubkey = Pubkey::new_from_array([13; 32]); +const TAKER_SECOND_TOKEN_ACCOUNT_B: Pubkey = Pubkey::new_from_array([14; 32]); +const SECOND_VAULT: Pubkey = Pubkey::new_from_array([15; 32]); +const WORTHLESS_MINT: Pubkey = Pubkey::new_from_array([16; 32]); +const TAKER_WORTHLESS_TOKEN_ACCOUNT: Pubkey = Pubkey::new_from_array([17; 32]); const OFFER_ID: u64 = 7; +const SECOND_OFFER_ID: u64 = 8; // The story the tests tell: the maker (Alice) offers 250 USDC (token A) and // wants 1 TSLAx (token B) for it, and the taker (Bob) takes the offer. USDC @@ -450,3 +455,161 @@ fn test_take_offer_rejects_switched_offer_wanting_more_token_b(test: &mut Test) // same 250 USDC. assert_switched_offer_refused(test, USDC_OFFERED, 2 * TSLAX_WANTED); } + +/// Every token balance a take could move, in token A and token B: the vault +/// named, the maker's two accounts, and the taker's two accounts. +fn token_balances(test: &mut Test, vault: Pubkey) -> [u64; 5] { + [ + vault, + MAKER_TOKEN_ACCOUNT_A, + MAKER_TOKEN_ACCOUNT_B, + TAKER_TOKEN_ACCOUNT_A, + TAKER_TOKEN_ACCOUNT_B, + ] + .map(|address| test.tokens(address)) +} + +/// A maker with a live offer of 250 USDC for 1 TSLAx, made through +/// `make_offer`, and a taker holding 1,000 USDC and 1 TSLAx. +fn offer_made_and_taker_ready(test: &mut Test) { + maker_holding_token_a(test); + test.add(Wallet::new().at(TAKER)); + taker_holding_usdc_and_tslax(test); + make_offer(test, USDC_OFFERED, TSLAX_WANTED, TSLAX_MINT).succeeds(); +} + +#[quasar_test] +fn test_take_offer_rejects_taker_account_as_maker_token_account_b(test: &mut Test) { + offer_made_and_taker_ready(test); + // The taker names a second TSLAx account of their own as the maker's, so + // the 1 TSLAx they pay would come back to them and the 250 USDC would + // cost nothing. + test.add(TokenAccount::new(TSLAX_MINT, TAKER).at(TAKER_SECOND_TOKEN_ACCOUNT_B)); + let balances_before = token_balances(test, VAULT); + + // has_one(maker_token_account_b) refuses an account other than the one + // the offer stored at make time. + test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: USDC_MINT, + token_mint_b: TSLAX_MINT, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, + maker_token_account_b: TAKER_SECOND_TOKEN_ACCOUNT_B, + vault: VAULT, + minimum_token_a_out: USDC_OFFERED, + maximum_token_b_in: TSLAX_WANTED, + }) + .fails_with(QuasarError::HasOneMismatch); + + assert_eq!( + token_balances(test, VAULT), + balances_before, + "no tokens move when the maker's token B account is the taker's" + ); + assert_eq!( + test.tokens(TAKER_SECOND_TOKEN_ACCOUNT_B), + 0, + "the taker's second TSLAx account receives nothing" + ); +} + +#[quasar_test] +fn test_take_offer_rejects_vault_of_another_offer(test: &mut Test) { + // The maker has two offers open: 250 USDC for 1 TSLAx, and 500 USDC for + // 2 TSLAx. + offer_made_and_taker_ready(test); + test.send(MakeOfferInstruction { + maker: MAKER, + token_mint_a: USDC_MINT, + token_mint_b: TSLAX_MINT, + maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: SECOND_VAULT, + id: SECOND_OFFER_ID, + deposit: 2 * USDC_OFFERED, + receive: 2 * TSLAX_WANTED, + }) + .succeeds(); + let balances_before = token_balances(test, VAULT); + + // The taker takes the first offer, paying its 1 TSLAx, but names the + // second offer's vault so they would receive its 500 USDC. + // has_one(vault) refuses a vault other than the one the first offer + // stored at make time. + test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: USDC_MINT, + token_mint_b: TSLAX_MINT, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: SECOND_VAULT, + minimum_token_a_out: USDC_OFFERED, + maximum_token_b_in: TSLAX_WANTED, + }) + .fails_with(QuasarError::HasOneMismatch); + + assert_eq!( + token_balances(test, VAULT), + balances_before, + "no tokens move when the vault belongs to another offer" + ); + assert_eq!( + test.tokens(SECOND_VAULT), + 2 * USDC_OFFERED, + "the second offer's USDC stays in its vault" + ); +} + +#[quasar_test] +fn test_take_offer_rejects_worthless_token_mint_b(test: &mut Test) { + offer_made_and_taker_ready(test); + // The taker creates their own mint at TSLAx's decimals and holds 1 of its + // tokens. has_one(maker_token_account_b) holds the maker's side to the + // TSLAx account the offer stored, so the taker names that account. + test.add( + Mint::new(TAKER) + .at(WORTHLESS_MINT) + .supply(TSLAX_WANTED) + .decimals(TSLAX_DECIMALS), + ); + test.add( + TokenAccount::new(WORTHLESS_MINT, TAKER) + .at(TAKER_WORTHLESS_TOKEN_ACCOUNT) + .amount(TSLAX_WANTED), + ); + let balances_before = token_balances(test, VAULT); + + // has_one(token_mint_b) refuses a mint other than the one the offer + // stored at make time. + test.send(TakeOfferInstruction { + taker: TAKER, + offer_id_seed: OFFER_ID, + maker: MAKER, + token_mint_a: USDC_MINT, + token_mint_b: WORTHLESS_MINT, + taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, + taker_token_account_b: TAKER_WORTHLESS_TOKEN_ACCOUNT, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + minimum_token_a_out: USDC_OFFERED, + maximum_token_b_in: TSLAX_WANTED, + }) + .fails_with(QuasarError::HasOneMismatch); + + assert_eq!( + token_balances(test, VAULT), + balances_before, + "no tokens move when token B is the taker's own mint" + ); + assert_eq!( + test.tokens(TAKER_WORTHLESS_TOKEN_ACCOUNT), + TSLAX_WANTED, + "the taker keeps their worthless tokens" + ); +}