From 9845739a89f91a2ec6b7ca22242dd7ec9d51e433 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 29 Sep 2026 21:11:02 +0000 Subject: [PATCH 1/2] Escrow: refuse offers with zero tokens or the same token on both sides make_offer now refuses an offer with zero tokens on either side, and an offer of a token for a different amount of itself, in all four variants: - anchor and anchor-v1: ZeroAmount is checked in the handler. A same-token offer never reaches the handler, because the maker's token-A and token-B accounts would be the same account and Anchor refuses the same mutable account twice (ConstraintDuplicateMutableAccount); a test pins that. - native and quasar: ZeroAmount and SameMint are both checked in the handler, since neither refuses the duplicate on its own (quasar's token accounts are not associated token accounts, so they need not coincide). Each variant gets a test per refusal that asserts the error code and that the maker's tokens did not move. Claude-Session: https://claude.ai/code/session_01JGEoAUjMm7Evv69k46eNcn --- finance/escrow/anchor-v1/CHANGELOG.md | 4 + finance/escrow/anchor-v1/README.md | 4 +- .../anchor-v1/programs/escrow/src/error.rs | 6 +- .../escrow/src/instructions/make_offer.rs | 17 ++- .../anchor-v1/programs/escrow/src/lib.rs | 4 + .../programs/escrow/tests/test_escrow.rs | 116 ++++++++++++++++++ finance/escrow/anchor/CHANGELOG.md | 4 + finance/escrow/anchor/README.md | 4 +- .../anchor/programs/escrow/src/error.rs | 6 +- .../escrow/src/instructions/make_offer.rs | 17 ++- .../escrow/anchor/programs/escrow/src/lib.rs | 4 + .../programs/escrow/tests/test_escrow.rs | 116 ++++++++++++++++++ finance/escrow/native/CHANGELOG.md | 4 + finance/escrow/native/README.md | 2 +- finance/escrow/native/program/src/error.rs | 6 + .../program/src/instructions/make_offer.rs | 9 ++ finance/escrow/native/program/tests/test.rs | 81 +++++++++++- finance/escrow/quasar/CHANGELOG.md | 7 ++ finance/escrow/quasar/src/error.rs | 12 ++ .../quasar/src/instructions/make_offer.rs | 21 +++- finance/escrow/quasar/src/lib.rs | 2 + finance/escrow/quasar/src/tests.rs | 48 ++++++++ 22 files changed, 476 insertions(+), 18 deletions(-) create mode 100644 finance/escrow/quasar/src/error.rs diff --git a/finance/escrow/anchor-v1/CHANGELOG.md b/finance/escrow/anchor-v1/CHANGELOG.md index 4f1163881..555cf023a 100644 --- a/finance/escrow/anchor-v1/CHANGELOG.md +++ b/finance/escrow/anchor-v1/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-09-29 + +- `make_offer` refuses an offer with zero tokens on either side, with `ZeroAmount`. An offer of a token for a different amount of itself was already refused before the handler runs, because the maker's token-A and token-B accounts would be the same account and Anchor refuses the same mutable account twice (`ConstraintDuplicateMutableAccount`); a test now pins that. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/escrow/anchor-v1/README.md b/finance/escrow/anchor-v1/README.md index 30744fd8b..bf316351d 100644 --- a/finance/escrow/anchor-v1/README.md +++ b/finance/escrow/anchor-v1/README.md @@ -21,7 +21,7 @@ The maker pays the rent for the offer account and the vault, and every path that ## Lifecycle -A maker opens an offer with `make_offer`, passing the `id`, `token_a_offered_amount`, and `token_b_wanted_amount`. The maker signs and pays all rent. The handler creates the offer PDA and the vault, creates the maker's token-B associated token account if needed (paid by the maker, so the eventual taker never funds a maker-owned account), moves the offered token A into the vault with `transfer_checked`, and records the offer state. +A maker opens an offer with `make_offer`, passing the `id`, `token_a_offered_amount`, and `token_b_wanted_amount`. The maker signs and pays all rent. The handler creates the offer PDA and the vault, creates the maker's token-B associated token account if needed (paid by the maker, so the eventual taker never funds a maker-owned account), moves the offered token A into the vault with `transfer_checked`, and records the offer state. It refuses an offer with zero tokens on either side (`ZeroAmount`). An offer of a token for itself never reaches the handler: the maker's token-A and token-B accounts would be the same account, which Anchor refuses (`ConstraintDuplicateMutableAccount`). A taker settles the offer with `take_offer`. The taker signs. Anchor's constraints bind every account to the stored offer state (`has_one` on the maker and both mints, associated-token constraints on the vault and all token accounts, and the PDA seeds on the offer itself). The handler sends the wanted token B from the taker to the maker, releases the vault's token A to the taker signed by the offer PDA, and closes both the vault and the offer account back to the maker, who paid their rent. The taker's own token-A account is created on the fly if needed, paid by the taker. @@ -45,7 +45,7 @@ The tests are Rust integration tests running against [LiteSVM](https://www.ancho cargo test ``` -(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). ## FAQ diff --git a/finance/escrow/anchor-v1/programs/escrow/src/error.rs b/finance/escrow/anchor-v1/programs/escrow/src/error.rs index c37199a44..a8c4644c7 100644 --- a/finance/escrow/anchor-v1/programs/escrow/src/error.rs +++ b/finance/escrow/anchor-v1/programs/escrow/src/error.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; #[error_code] -pub enum ErrorCode { - #[msg("Custom error message")] - CustomError, +pub enum EscrowError { + #[msg("An offer must offer and want more than zero tokens")] + ZeroAmount, } diff --git a/finance/escrow/anchor-v1/programs/escrow/src/instructions/make_offer.rs b/finance/escrow/anchor-v1/programs/escrow/src/instructions/make_offer.rs index cee614335..1a6c9369a 100644 --- a/finance/escrow/anchor-v1/programs/escrow/src/instructions/make_offer.rs +++ b/finance/escrow/anchor-v1/programs/escrow/src/instructions/make_offer.rs @@ -5,7 +5,7 @@ use anchor_spl::{ token_interface::{Mint, TokenAccount, TokenInterface}, }; -use crate::Offer; +use crate::{error::EscrowError, Offer}; use super::transfer_tokens; @@ -65,6 +65,21 @@ pub struct MakeOfferAccountConstraints<'info> { pub system_program: Program<'info, System>, } +// Refuse an offer with nothing on one side. An offer of a token for a +// different amount of itself never reaches this handler: the maker's token-A +// and token-B accounts would be the same account, and Anchor refuses the same +// mutable account twice with `ConstraintDuplicateMutableAccount`. +pub fn handle_validate_offer( + token_a_offered_amount: u64, + token_b_wanted_amount: u64, +) -> Result<()> { + require!( + token_a_offered_amount > 0 && token_b_wanted_amount > 0, + EscrowError::ZeroAmount + ); + Ok(()) +} + // Move the tokens from the maker's ATA to the vault pub fn handle_send_offered_tokens_to_vault( context: &Context, diff --git a/finance/escrow/anchor-v1/programs/escrow/src/lib.rs b/finance/escrow/anchor-v1/programs/escrow/src/lib.rs index 8fdc1c556..bbe6076be 100644 --- a/finance/escrow/anchor-v1/programs/escrow/src/lib.rs +++ b/finance/escrow/anchor-v1/programs/escrow/src/lib.rs @@ -19,6 +19,10 @@ pub mod escrow { token_a_offered_amount: u64, token_b_wanted_amount: u64, ) -> Result<()> { + instructions::make_offer::handle_validate_offer( + token_a_offered_amount, + token_b_wanted_amount, + )?; instructions::make_offer::handle_send_offered_tokens_to_vault( &context, token_a_offered_amount, 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 1adf2d112..3e9c22e89 100644 --- a/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs @@ -490,3 +490,119 @@ fn test_cancel_offer_rejects_non_maker() { "Bob must not be able to cancel Alice's offer" ); } + +// Anchor numbers a program's errors from 6000 in declaration order, and a +// failed transaction reports the number as `Custom(n)`. Matching it shows the +// transaction failed for the check under test, not for some unrelated reason. +fn assert_fails_with( + result: Result<(), solana_kite::SolanaKiteError>, + expected: escrow::error::EscrowError, +) { + let code = 6000 + expected as u32; + let error = format!("{:?}", result.expect_err("transaction should have failed")); + assert!( + error.contains(&format!("Custom({code})")), + "expected error {code}, got: {error}" + ); +} + +// Alice sends `make_offer` for the given amounts and wanted token, and the +// result is returned rather than unwrapped so a test can check the refusal. +fn try_make_offer( + es: &mut EscrowSetup, + offer_id: u64, + token_a_offered_amount: u64, + token_b_wanted_amount: u64, + token_mint_b: Pubkey, + maker_token_account_b: Pubkey, +) -> Result<(), solana_kite::SolanaKiteError> { + let (offer_pda, _bump) = 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); + let make_offer_ix = Instruction::new_with_bytes( + es.program_id, + &escrow::instruction::MakeOffer { + id: offer_id, + token_a_offered_amount, + token_b_wanted_amount, + } + .data(), + escrow::accounts::MakeOfferAccountConstraints { + maker: es.alice.pubkey(), + token_mint_a: es.mint_a, + token_mint_b, + maker_token_account_a: es.alice_ata_a, + maker_token_account_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), + ); + send_transaction_from_instructions( + &mut es.svm, + vec![make_offer_ix], + &[&es.payer, &es.alice], + &es.payer.pubkey(), + ) +} + +#[test] +fn test_make_offer_rejects_zero_offered_amount() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 5, 0, 1_000_000, mint_b, alice_ata_b); + + assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); + assert_eq!( + get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(), + alice_balance_before + ); +} + +#[test] +fn test_make_offer_rejects_zero_wanted_amount() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 6, 1_000_000, 0, mint_b, alice_ata_b); + + assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); + assert_eq!( + get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(), + alice_balance_before + ); +} + +#[test] +fn test_make_offer_rejects_same_mint() { + let mut es = full_setup(); + // Alice asks for token A in return for token A, so her token-B account is + // her token-A account. + let (mint_a, alice_ata_a) = (es.mint_a, es.alice_ata_a); + let alice_balance_before = get_token_account_balance(&es.svm, &alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 7, 1_000_000, 2_000_000, mint_a, alice_ata_a); + + // Anchor refuses the same mutable account twice before the handler runs. + let error = format!("{:?}", result.expect_err("a same-token offer must fail")); + assert!( + error.contains("Custom(2040)"), + "expected ConstraintDuplicateMutableAccount (2040), got: {error}" + ); + assert_eq!( + get_token_account_balance(&es.svm, &alice_ata_a).unwrap(), + alice_balance_before + ); +} diff --git a/finance/escrow/anchor/CHANGELOG.md b/finance/escrow/anchor/CHANGELOG.md index 4f1163881..555cf023a 100644 --- a/finance/escrow/anchor/CHANGELOG.md +++ b/finance/escrow/anchor/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-09-29 + +- `make_offer` refuses an offer with zero tokens on either side, with `ZeroAmount`. An offer of a token for a different amount of itself was already refused before the handler runs, because the maker's token-A and token-B accounts would be the same account and Anchor refuses the same mutable account twice (`ConstraintDuplicateMutableAccount`); a test now pins that. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/escrow/anchor/README.md b/finance/escrow/anchor/README.md index fa6118177..0db2ad655 100644 --- a/finance/escrow/anchor/README.md +++ b/finance/escrow/anchor/README.md @@ -21,7 +21,7 @@ The maker pays the rent for the offer account and the vault, and every path that ## Lifecycle -A maker opens an offer with `make_offer`, passing the `id`, `token_a_offered_amount`, and `token_b_wanted_amount`. The maker signs and pays all rent. The handler creates the offer PDA and the vault, creates the maker's token-B associated token account if needed (paid by the maker, so the eventual taker never funds a maker-owned account), moves the offered token A into the vault with `transfer_checked`, and records the offer state. +A maker opens an offer with `make_offer`, passing the `id`, `token_a_offered_amount`, and `token_b_wanted_amount`. The maker signs and pays all rent. The handler creates the offer PDA and the vault, creates the maker's token-B associated token account if needed (paid by the maker, so the eventual taker never funds a maker-owned account), moves the offered token A into the vault with `transfer_checked`, and records the offer state. It refuses an offer with zero tokens on either side (`ZeroAmount`). An offer of a token for itself never reaches the handler: the maker's token-A and token-B accounts would be the same account, which Anchor refuses (`ConstraintDuplicateMutableAccount`). A taker settles the offer with `take_offer`. The taker signs. Anchor's constraints bind every account to the stored offer state (`address = offer.maker` on the maker and `address = offer.token_mint_a` / `address = offer.token_mint_b` on the mints, associated-token constraints on the vault and all token accounts, and the PDA seeds on the offer itself). The handler sends the wanted token B from the taker to the maker, releases the vault's token A to the taker signed by the offer PDA, and closes both the vault and the offer account back to the maker, who paid their rent. The taker's own token-A account is created on the fly if needed, paid by the taker. @@ -45,7 +45,7 @@ The tests are Rust integration tests running against [LiteSVM](https://www.ancho cargo test ``` -(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). ## FAQ diff --git a/finance/escrow/anchor/programs/escrow/src/error.rs b/finance/escrow/anchor/programs/escrow/src/error.rs index c37199a44..a8c4644c7 100644 --- a/finance/escrow/anchor/programs/escrow/src/error.rs +++ b/finance/escrow/anchor/programs/escrow/src/error.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; #[error_code] -pub enum ErrorCode { - #[msg("Custom error message")] - CustomError, +pub enum EscrowError { + #[msg("An offer must offer and want more than zero tokens")] + ZeroAmount, } diff --git a/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs b/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs index 55bcf37a7..5ba13255d 100644 --- a/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs +++ b/finance/escrow/anchor/programs/escrow/src/instructions/make_offer.rs @@ -6,7 +6,7 @@ use anchor_spl::{ token_interface::{Mint, TokenAccount, TokenInterface}, }; -use crate::Offer; +use crate::{error::EscrowError, Offer}; use super::transfer_tokens; @@ -69,6 +69,21 @@ pub struct MakeOfferAccountConstraints { pub system_program: Program, } +// Refuse an offer with nothing on one side. An offer of a token for a +// different amount of itself never reaches this handler: the maker's token-A +// and token-B accounts would be the same account, and Anchor refuses the same +// mutable account twice with `ConstraintDuplicateMutableAccount`. +pub fn handle_validate_offer( + token_a_offered_amount: u64, + token_b_wanted_amount: u64, +) -> Result<()> { + require!( + token_a_offered_amount > 0 && token_b_wanted_amount > 0, + EscrowError::ZeroAmount + ); + Ok(()) +} + // Move the tokens from the maker's ATA to the vault pub fn handle_send_offered_tokens_to_vault( context: &mut Context, diff --git a/finance/escrow/anchor/programs/escrow/src/lib.rs b/finance/escrow/anchor/programs/escrow/src/lib.rs index a09ab0bf9..9244aa4ea 100644 --- a/finance/escrow/anchor/programs/escrow/src/lib.rs +++ b/finance/escrow/anchor/programs/escrow/src/lib.rs @@ -19,6 +19,10 @@ pub mod escrow { token_a_offered_amount: u64, token_b_wanted_amount: u64, ) -> Result<()> { + instructions::make_offer::handle_validate_offer( + token_a_offered_amount, + token_b_wanted_amount, + )?; instructions::make_offer::handle_send_offered_tokens_to_vault( context, token_a_offered_amount, diff --git a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs index 6f02572da..c9de14b2e 100644 --- a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs @@ -488,3 +488,119 @@ fn test_cancel_offer_rejects_non_maker() { "Bob must not be able to cancel Alice's offer" ); } + +// Anchor numbers a program's errors from 6000 in declaration order, and a +// failed transaction reports the number as `Custom(n)`. Matching it shows the +// transaction failed for the check under test, not for some unrelated reason. +fn assert_fails_with( + result: Result<(), solana_kite::SolanaKiteError>, + expected: escrow::error::EscrowError, +) { + let code = 6000 + expected as u32; + let error = format!("{:?}", result.expect_err("transaction should have failed")); + assert!( + error.contains(&format!("Custom({code})")), + "expected error {code}, got: {error}" + ); +} + +// Alice sends `make_offer` for the given amounts and wanted token, and the +// result is returned rather than unwrapped so a test can check the refusal. +fn try_make_offer( + es: &mut EscrowSetup, + offer_id: u64, + token_a_offered_amount: u64, + token_b_wanted_amount: u64, + token_mint_b: Address, + maker_token_account_b: Address, +) -> Result<(), solana_kite::SolanaKiteError> { + let (offer_pda, _bump) = 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); + let make_offer_ix = Instruction::new_with_bytes( + es.program_id, + &escrow::instruction::MakeOffer { + id: offer_id, + token_a_offered_amount, + token_b_wanted_amount, + } + .data(), + escrow::accounts::MakeOfferAccountConstraints { + maker: es.alice.pubkey(), + token_mint_a: es.mint_a, + token_mint_b, + maker_token_account_a: es.alice_ata_a, + maker_token_account_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), + ); + send_transaction_from_instructions( + &mut es.svm, + vec![make_offer_ix], + &[&es.payer, &es.alice], + &es.payer.pubkey(), + ) +} + +#[test] +fn test_make_offer_rejects_zero_offered_amount() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 5, 0, 1_000_000, mint_b, alice_ata_b); + + assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); + assert_eq!( + get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(), + alice_balance_before + ); +} + +#[test] +fn test_make_offer_rejects_zero_wanted_amount() { + let mut es = full_setup(); + let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); + let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 6, 1_000_000, 0, mint_b, alice_ata_b); + + assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); + assert_eq!( + get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(), + alice_balance_before + ); +} + +#[test] +fn test_make_offer_rejects_same_mint() { + let mut es = full_setup(); + // Alice asks for token A in return for token A, so her token-B account is + // her token-A account. + let (mint_a, alice_ata_a) = (es.mint_a, es.alice_ata_a); + let alice_balance_before = get_token_account_balance(&es.svm, &alice_ata_a).unwrap(); + + let result = try_make_offer(&mut es, 7, 1_000_000, 2_000_000, mint_a, alice_ata_a); + + // Anchor refuses the same mutable account twice before the handler runs. + let error = format!("{:?}", result.expect_err("a same-token offer must fail")); + assert!( + error.contains("Custom(2040)"), + "expected ConstraintDuplicateMutableAccount (2040), got: {error}" + ); + assert_eq!( + get_token_account_balance(&es.svm, &alice_ata_a).unwrap(), + alice_balance_before + ); +} diff --git a/finance/escrow/native/CHANGELOG.md b/finance/escrow/native/CHANGELOG.md index 4f1163881..d8d5a9858 100644 --- a/finance/escrow/native/CHANGELOG.md +++ b/finance/escrow/native/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-09-29 + +- `make_offer` refuses an offer with zero tokens on either side (`ZeroAmount`) and an offer of a token for a different amount of itself (`SameMint`). + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/escrow/native/README.md b/finance/escrow/native/README.md index 29e90181b..981f0a989 100644 --- a/finance/escrow/native/README.md +++ b/finance/escrow/native/README.md @@ -43,4 +43,4 @@ The Rust + [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) tests loa cargo test --manifest-path=./program/Cargo.toml ``` -The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). diff --git a/finance/escrow/native/program/src/error.rs b/finance/escrow/native/program/src/error.rs index d7b66590b..9ee00b516 100644 --- a/finance/escrow/native/program/src/error.rs +++ b/finance/escrow/native/program/src/error.rs @@ -23,6 +23,12 @@ pub enum EscrowError { #[error("Arithmetic overflow")] ArithmeticOverflow, + + #[error("An offer must offer and want more than zero tokens")] + ZeroAmount, + + #[error("An offer must exchange two different tokens")] + SameMint, } impl From for ProgramError { diff --git a/finance/escrow/native/program/src/instructions/make_offer.rs b/finance/escrow/native/program/src/instructions/make_offer.rs index 5d1015d5b..00ed13502 100644 --- a/finance/escrow/native/program/src/instructions/make_offer.rs +++ b/finance/escrow/native/program/src/instructions/make_offer.rs @@ -46,6 +46,15 @@ impl MakeOffer { return Err(ProgramError::MissingRequiredSignature); } + // Refuse an offer nobody could take fairly: zero tokens on either side, + // or a token swapped for a different amount of itself. + if args.token_a_offered_amount == 0 || args.token_b_wanted_amount == 0 { + return Err(EscrowError::ZeroAmount.into()); + } + if token_mint_a.key == token_mint_b.key { + return Err(EscrowError::SameMint.into()); + } + let offer_seeds = &[ Offer::SEED_PREFIX, maker.key.as_ref(), diff --git a/finance/escrow/native/program/tests/test.rs b/finance/escrow/native/program/tests/test.rs index a7132b48f..c06c21806 100644 --- a/finance/escrow/native/program/tests/test.rs +++ b/finance/escrow/native/program/tests/test.rs @@ -168,19 +168,37 @@ fn setup() -> EscrowSetup { } fn make_offer_instruction(es: &EscrowSetup) -> Instruction { + make_offer_instruction_for( + es, + AMOUNT_A, + AMOUNT_B, + &es.mint_b.pubkey(), + &es.maker_account_b, + ) +} + +/// A `make_offer` instruction for the given amounts and wanted token, so a +/// test can build an offer the program should refuse. +fn make_offer_instruction_for( + es: &EscrowSetup, + token_a_offered_amount: u64, + token_b_wanted_amount: u64, + mint_b: &Pubkey, + maker_account_b: &Pubkey, +) -> Instruction { let mut make_data = vec![MAKE_OFFER]; make_data.extend_from_slice(&OFFER_ID.to_le_bytes()); - make_data.extend_from_slice(&AMOUNT_A.to_le_bytes()); - make_data.extend_from_slice(&AMOUNT_B.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_readonly(es.mint_a.pubkey(), false), - AccountMeta::new_readonly(es.mint_b.pubkey(), false), + AccountMeta::new_readonly(*mint_b, false), AccountMeta::new(es.maker_account_a, false), - AccountMeta::new(es.maker_account_b, false), + AccountMeta::new(*maker_account_b, false), AccountMeta::new(es.vault, false), AccountMeta::new(es.maker.pubkey(), true), AccountMeta::new_readonly(spl_token_interface::id(), false), @@ -352,3 +370,58 @@ fn test_cancel_offer_rejects_non_maker() { // The vault still holds the offered tokens. assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); } + +// `EscrowError` variants in declaration order, as the program reports them in +// `InstructionError::Custom`. +const ZERO_AMOUNT: u32 = 7; +const SAME_MINT: u32 = 8; + +/// Send a `make_offer` the program should refuse, and check it failed with +/// `expected_code`, left the maker's tokens where they were, and created no +/// offer account. +fn assert_make_offer_refused(es: &mut EscrowSetup, instruction: Instruction, expected_code: u32) { + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + let result = try_send(&mut es.svm, &payer, &[instruction], &[&maker]); + let error = format!( + "{:?}", + result.expect_err("make_offer should have failed").err + ); + assert!( + error.contains(&format!("Custom({expected_code})")), + "expected error {expected_code}, got: {error}" + ); + assert_eq!(token_amount(&es.svm, &es.maker_account_a), MINTED_AMOUNT); + assert_eq!(lamports(&es.svm, &es.offer), 0); +} + +#[test] +fn test_make_offer_rejects_zero_offered_amount() { + let mut es = setup(); + let instruction = + make_offer_instruction_for(&es, 0, AMOUNT_B, &es.mint_b.pubkey(), &es.maker_account_b); + assert_make_offer_refused(&mut es, instruction, ZERO_AMOUNT); +} + +#[test] +fn test_make_offer_rejects_zero_wanted_amount() { + let mut es = setup(); + let instruction = + make_offer_instruction_for(&es, AMOUNT_A, 0, &es.mint_b.pubkey(), &es.maker_account_b); + assert_make_offer_refused(&mut es, instruction, ZERO_AMOUNT); +} + +#[test] +fn test_make_offer_rejects_same_mint() { + let mut es = setup(); + // The maker asks for token A in return for token A, so their token-B + // account is their token-A account. + let instruction = make_offer_instruction_for( + &es, + AMOUNT_A, + AMOUNT_B, + &es.mint_a.pubkey(), + &es.maker_account_a, + ); + assert_make_offer_refused(&mut es, instruction, SAME_MINT); +} diff --git a/finance/escrow/quasar/CHANGELOG.md b/finance/escrow/quasar/CHANGELOG.md index d5bbad0bd..196c16220 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2026-09-29] + +### Changed + +- `make_offer` refuses an offer with zero tokens on either side (`ZeroAmount`) + and an offer of a token for a different amount of itself (`SameMint`). + ## [2026-07-22] ### Changed diff --git a/finance/escrow/quasar/src/error.rs b/finance/escrow/quasar/src/error.rs new file mode 100644 index 000000000..f693a42d0 --- /dev/null +++ b/finance/escrow/quasar/src/error.rs @@ -0,0 +1,12 @@ +use quasar_lang::prelude::*; + +#[error_code] +pub enum EscrowError { + /// The offer puts zero tokens on one side, so nobody could take it fairly. + // 6000 is the conventional Anchor-compatible starting offset for + // program-specific error codes (Quasar's #[error_code] starts at 0 + // unless told otherwise; framework errors occupy 3000+). + ZeroAmount = 6000, + /// The offer swaps a token for a different amount of itself. + SameMint, +} diff --git a/finance/escrow/quasar/src/instructions/make_offer.rs b/finance/escrow/quasar/src/instructions/make_offer.rs index 7365060ab..b39efc806 100644 --- a/finance/escrow/quasar/src/instructions/make_offer.rs +++ b/finance/escrow/quasar/src/instructions/make_offer.rs @@ -1,5 +1,8 @@ use { - crate::state::{Offer, OfferInner}, + crate::{ + error::EscrowError, + state::{Offer, OfferInner}, + }, quasar_lang::prelude::*, quasar_spl::prelude::*, }; @@ -34,6 +37,22 @@ pub struct MakeOfferAccountConstraints { pub system_program: Program, } +/// Refuse an offer nobody could take fairly: zero tokens on either side, or a +/// token swapped for a different amount of itself. +#[inline(always)] +pub fn handle_validate_offer( + accounts: &MakeOfferAccountConstraints, + deposit: u64, + receive: u64, +) -> Result<(), ProgramError> { + require!(deposit > 0 && receive > 0, EscrowError::ZeroAmount); + require!( + accounts.token_mint_a.address() != accounts.token_mint_b.address(), + EscrowError::SameMint + ); + Ok(()) +} + #[inline(always)] pub fn handle_make_offer( accounts: &mut MakeOfferAccountConstraints, diff --git a/finance/escrow/quasar/src/lib.rs b/finance/escrow/quasar/src/lib.rs index ef09b8f7b..0cf7eb37f 100644 --- a/finance/escrow/quasar/src/lib.rs +++ b/finance/escrow/quasar/src/lib.rs @@ -2,6 +2,7 @@ use quasar_lang::prelude::*; +mod error; pub mod instructions; use instructions::*; pub mod state; @@ -24,6 +25,7 @@ mod quasar_escrow { deposit: u64, receive: u64, ) -> Result<(), ProgramError> { + instructions::make_offer::handle_validate_offer(&ctx.accounts, deposit, receive)?; instructions::make_offer::handle_make_offer(&mut ctx.accounts, id, receive, &ctx.bumps)?; instructions::make_offer::handle_deposit_tokens(&mut ctx.accounts, deposit) } diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index 651a6c9c1..b0b6c29b6 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -5,6 +5,7 @@ use { crate::{ cpi::{CancelOfferInstruction, MakeOfferInstruction, TakeOfferInstruction}, + error::EscrowError, state::{Offer, OfferData}, }, quasar_test::prelude::*, @@ -298,3 +299,50 @@ fn cancel_offer_rejects_a_signer_who_is_not_the_maker(test: &mut Test) { "cancel_offer must reject a signer who is not the offer's maker" ); } + +/// Send `make_offer` for the given amounts and wanted token, from a maker who +/// holds token A. +fn make_offer(test: &mut Test, deposit: u64, receive: u64, token_mint_b: Pubkey) -> Outcome { + test.send(MakeOfferInstruction { + maker: MAKER, + token_mint_a: TOKEN_MINT_A, + token_mint_b, + maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, + maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, + vault: VAULT, + id: OFFER_ID, + deposit, + receive, + }) +} + +fn maker_holding_token_a(test: &mut Test) { + base_world(test); + test.add( + TokenAccount::new(TOKEN_MINT_A, MAKER) + .at(MAKER_TOKEN_ACCOUNT_A) + .amount(1_000_000), + ); +} + +#[quasar_test] +fn make_offer_rejects_a_zero_deposit(test: &mut Test) { + maker_holding_token_a(test); + make_offer(test, 0, RECEIVE_AMOUNT, TOKEN_MINT_B).fails_with(EscrowError::ZeroAmount); +} + +#[quasar_test] +fn make_offer_rejects_a_zero_receive_amount(test: &mut Test) { + maker_holding_token_a(test); + make_offer(test, DEPOSIT_AMOUNT, 0, TOKEN_MINT_B).fails_with(EscrowError::ZeroAmount); +} + +#[quasar_test] +fn make_offer_rejects_an_offer_of_a_token_for_itself(test: &mut Test) { + maker_holding_token_a(test); + // The maker's second account also holds token A, so the program's own + // account checks pass and only the mint comparison can refuse the offer. + test.add(TokenAccount::new(TOKEN_MINT_A, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + make_offer(test, DEPOSIT_AMOUNT, RECEIVE_AMOUNT, TOKEN_MINT_A) + .fails_with(EscrowError::SameMint); +} From ad64407d5cb0c3c891330dea3eb331424f2300d5 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 29 Sep 2026 21:16:23 +0000 Subject: [PATCH 2/2] Escrow (quasar): pin the same-token refusal the loader already makes CI showed a same-token offer never reaches make_offer's handler: both mint slots hold the same account, and loading it twice fails with AccountBorrowFailed. Drop the SameMint check, which could never run, and have the test assert the refusal that actually happens, as the Anchor variants do. Claude-Session: https://claude.ai/code/session_01JGEoAUjMm7Evv69k46eNcn --- finance/escrow/quasar/CHANGELOG.md | 7 +++++-- finance/escrow/quasar/src/error.rs | 2 -- .../escrow/quasar/src/instructions/make_offer.rs | 16 +++++----------- finance/escrow/quasar/src/lib.rs | 2 +- finance/escrow/quasar/src/tests.rs | 7 +++---- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/finance/escrow/quasar/CHANGELOG.md b/finance/escrow/quasar/CHANGELOG.md index 196c16220..499d9a557 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -4,8 +4,11 @@ ### Changed -- `make_offer` refuses an offer with zero tokens on either side (`ZeroAmount`) - and an offer of a token for a different amount of itself (`SameMint`). +- `make_offer` refuses an offer with zero tokens on either side (`ZeroAmount`). + An offer of a token for a different amount of itself was already refused + before the handler runs, because both mint slots would hold the same + account and loading it twice fails with `AccountBorrowFailed`; a test now + pins that. ## [2026-07-22] diff --git a/finance/escrow/quasar/src/error.rs b/finance/escrow/quasar/src/error.rs index f693a42d0..9e27d9104 100644 --- a/finance/escrow/quasar/src/error.rs +++ b/finance/escrow/quasar/src/error.rs @@ -7,6 +7,4 @@ pub enum EscrowError { // program-specific error codes (Quasar's #[error_code] starts at 0 // unless told otherwise; framework errors occupy 3000+). ZeroAmount = 6000, - /// The offer swaps a token for a different amount of itself. - SameMint, } diff --git a/finance/escrow/quasar/src/instructions/make_offer.rs b/finance/escrow/quasar/src/instructions/make_offer.rs index b39efc806..0e1f0bc23 100644 --- a/finance/escrow/quasar/src/instructions/make_offer.rs +++ b/finance/escrow/quasar/src/instructions/make_offer.rs @@ -37,19 +37,13 @@ pub struct MakeOfferAccountConstraints { pub system_program: Program, } -/// Refuse an offer nobody could take fairly: zero tokens on either side, or a -/// token swapped for a different amount of itself. +/// Refuse an offer with nothing on one side. An offer of a token for a +/// different amount of itself never reaches this handler: both mint slots +/// would hold the same account, and loading it twice fails with +/// `AccountBorrowFailed`. #[inline(always)] -pub fn handle_validate_offer( - accounts: &MakeOfferAccountConstraints, - deposit: u64, - receive: u64, -) -> Result<(), ProgramError> { +pub fn handle_validate_offer(deposit: u64, receive: u64) -> Result<(), ProgramError> { require!(deposit > 0 && receive > 0, EscrowError::ZeroAmount); - require!( - accounts.token_mint_a.address() != accounts.token_mint_b.address(), - EscrowError::SameMint - ); Ok(()) } diff --git a/finance/escrow/quasar/src/lib.rs b/finance/escrow/quasar/src/lib.rs index 0cf7eb37f..4dc8904d2 100644 --- a/finance/escrow/quasar/src/lib.rs +++ b/finance/escrow/quasar/src/lib.rs @@ -25,7 +25,7 @@ mod quasar_escrow { deposit: u64, receive: u64, ) -> Result<(), ProgramError> { - instructions::make_offer::handle_validate_offer(&ctx.accounts, deposit, receive)?; + instructions::make_offer::handle_validate_offer(deposit, receive)?; instructions::make_offer::handle_make_offer(&mut ctx.accounts, id, receive, &ctx.bumps)?; instructions::make_offer::handle_deposit_tokens(&mut ctx.accounts, deposit) } diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index b0b6c29b6..9691a2116 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -340,9 +340,8 @@ fn make_offer_rejects_a_zero_receive_amount(test: &mut Test) { #[quasar_test] fn make_offer_rejects_an_offer_of_a_token_for_itself(test: &mut Test) { maker_holding_token_a(test); - // The maker's second account also holds token A, so the program's own - // account checks pass and only the mint comparison can refuse the offer. - test.add(TokenAccount::new(TOKEN_MINT_A, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + // Both mint slots hold the same account, and loading it twice fails + // before the handler runs. make_offer(test, DEPOSIT_AMOUNT, RECEIVE_AMOUNT, TOKEN_MINT_A) - .fails_with(EscrowError::SameMint); + .fails(ProgramError::Runtime("AccountBorrowFailed".into())); }