Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions finance/escrow/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions finance/escrow/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions finance/escrow/anchor-v1/programs/escrow/src/error.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use anchor_spl::{
token_interface::{Mint, TokenAccount, TokenInterface},
};

use crate::Offer;
use crate::{error::EscrowError, Offer};

use super::transfer_tokens;

Expand Down Expand Up @@ -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<MakeOfferAccountConstraints>,
Expand Down
4 changes: 4 additions & 0 deletions finance/escrow/anchor-v1/programs/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
4 changes: 4 additions & 0 deletions finance/escrow/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions finance/escrow/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions finance/escrow/anchor/programs/escrow/src/error.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anchor_spl::{
token_interface::{Mint, TokenAccount, TokenInterface},
};

use crate::Offer;
use crate::{error::EscrowError, Offer};

use super::transfer_tokens;

Expand Down Expand Up @@ -69,6 +69,21 @@ pub struct MakeOfferAccountConstraints {
pub system_program: Program<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: &mut Context<MakeOfferAccountConstraints>,
Expand Down
4 changes: 4 additions & 0 deletions finance/escrow/anchor/programs/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading