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..499d9a557 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [2026-09-29] + +### Changed + +- `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] ### Changed diff --git a/finance/escrow/quasar/src/error.rs b/finance/escrow/quasar/src/error.rs new file mode 100644 index 000000000..9e27d9104 --- /dev/null +++ b/finance/escrow/quasar/src/error.rs @@ -0,0 +1,10 @@ +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, +} diff --git a/finance/escrow/quasar/src/instructions/make_offer.rs b/finance/escrow/quasar/src/instructions/make_offer.rs index 7365060ab..0e1f0bc23 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,16 @@ 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: both mint slots +/// would hold the same account, and loading it twice fails with +/// `AccountBorrowFailed`. +#[inline(always)] +pub fn handle_validate_offer(deposit: u64, receive: u64) -> Result<(), ProgramError> { + require!(deposit > 0 && receive > 0, EscrowError::ZeroAmount); + 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..4dc8904d2 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(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..9691a2116 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,49 @@ 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); + // 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(ProgramError::Runtime("AccountBorrowFailed".into())); +}