From 0c40ff91dbabe4a3c8ee5fb4183b48ce81961a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:12:54 -0600 Subject: [PATCH 01/13] Add stale swap path primitives --- contracts/finchippay-contract/src/lib.rs | 143 ++++++++++++++++----- contracts/finchippay-contract/src/types.rs | 1 + 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 47105171..466e7145 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -106,6 +106,9 @@ pub enum ContractError { ExcessiveAmountIn = 23, /// `new_fee_bps` exceeds `MAX_SWAP_FEE_BPS`. InvalidFeeBps = 24, + /// The supplied swap `path` references stale liquidity, such as a repeated + /// token or a hop whose contract-side reserve is empty. + StalePath = 29, /// The referenced admin action proposal does not exist. ProposalNotFound = 25, /// The admin action proposal has already been executed. @@ -701,6 +704,25 @@ pub(crate) fn require_transfer_succeeded( } } +pub(crate) fn transfer_to_contract_measured( + env: &Env, + token: &token::Client, + from: &Address, + requested_amount: &i128, +) -> i128 { + let contract_address = env.current_contract_address(); + let balance_before = get_contract_balance(env, token); + token.transfer(from, &contract_address, requested_amount); + let balance_after = token.balance(&contract_address); + if balance_after <= balance_before { + panic!("TransferFailed"); + } + set_contract_balance(env, &token.address, balance_after); + balance_after + .checked_sub(balance_before) + .expect("contract balance decreased during inbound transfer") +} + pub(crate) fn contract_transfer_out(env: &Env, token: &token::Client, to: &Address, amount: &i128) { token.transfer(&env.current_contract_address(), to, amount); let key = DataKey::LastContractBalance(token.address.clone()); @@ -774,6 +796,41 @@ pub(crate) fn validate_swap_path( { return Err(ContractError::InvalidPath); } + for i in 0..path.len() { + let current = path.get(i).unwrap(); + for j in (i + 1)..path.len() { + if current == path.get(j).unwrap() { + return Err(ContractError::StalePath); + } + } + } + Ok(()) +} + +pub(crate) fn validate_swap_path_liquidity( + env: &Env, + path: &Vec
, +) -> Result<(), ContractError> { + let contract_address = env.current_contract_address(); + for i in 1..path.len() { + let token_address = path.get(i).unwrap(); + let token_client = get_token_client(env, &token_address); + if token_client.balance(&contract_address) <= 0 { + return Err(ContractError::StalePath); + } + } + Ok(()) +} + +pub(crate) fn ensure_swap_reserve( + env: &Env, + token_address: &Address, + amount: i128, +) -> Result<(), ContractError> { + let token_client = get_token_client(env, token_address); + if token_client.balance(&env.current_contract_address()) < amount { + return Err(ContractError::StalePath); + } Ok(()) } @@ -2771,28 +2828,24 @@ impl FinchippayContract { return Err(ContractError::InvalidPath); } validate_swap_path(&path, &token_in, &token_out)?; + validate_swap_path_liquidity(&env, &path)?; let fee_bps = get_swap_fee_bps(&env); - let (fee, amount_to_swap) = compute_swap_fee(amount_in, fee_bps); + let token_in_client = get_token_client(&env, &token_in); + let actual_amount_in = + transfer_to_contract_measured(&env, &token_in_client, &caller, &amount_in); + let (fee, amount_to_swap) = compute_swap_fee(actual_amount_in, fee_bps); let amount_out = amount_to_swap; if amount_out < min_amount_out { return Err(ContractError::SlippageExceeded); } + ensure_swap_reserve(&env, &token_out, amount_out)?; - let token_in_client = get_token_client(&env, &token_in); if fee > 0 { let collector = get_fee_collector_address(&env); - require_transfer_succeeded(&env, &token_in_client, &caller, &collector, &fee); + contract_transfer_out(&env, &token_in_client, &collector, &fee); } - let contract_address = env.current_contract_address(); - require_transfer_succeeded( - &env, - &token_in_client, - &caller, - &contract_address, - &amount_to_swap, - ); let token_out_client = get_token_client(&env, &token_out); contract_transfer_out(&env, &token_out_client, &caller, &amount_out); @@ -2804,7 +2857,13 @@ impl FinchippayContract { token_in.clone(), token_out.clone(), ), - (amount_in, amount_out, fee), + ( + amount_in, + actual_amount_in, + amount_out, + fee, + path.len(), + ), ); Ok(amount_out) @@ -2838,6 +2897,7 @@ impl FinchippayContract { return Err(ContractError::InvalidPath); } validate_swap_path(&path, &token_in, &token_out)?; + validate_swap_path_liquidity(&env, &path)?; let fee_bps = get_swap_fee_bps(&env); let amount_in = compute_required_amount_in(amount_out, fee_bps); @@ -2846,25 +2906,44 @@ impl FinchippayContract { return Err(ContractError::ExcessiveAmountIn); } - let (fee, amount_to_swap) = compute_swap_fee(amount_in, fee_bps); - // Ceiling division in compute_required_amount_in can leave a few - // extra units in amount_to_swap versus amount_out; that dust stays - // in the contract's reserves rather than shorting the caller. - debug_assert!(amount_to_swap >= amount_out); + ensure_swap_reserve(&env, &token_out, amount_out)?; let token_in_client = get_token_client(&env, &token_in); - if fee > 0 { + let mut requested_amount_in = amount_in; + let mut actual_amount_in = + transfer_to_contract_measured(&env, &token_in_client, &caller, &amount_in); + let (mut actual_fee, mut actual_amount_to_swap) = + compute_swap_fee(actual_amount_in, fee_bps); + if actual_amount_to_swap < amount_out { + let additional_request = max_amount_in + .checked_sub(requested_amount_in) + .ok_or(ContractError::ExcessiveAmountIn)?; + if additional_request <= 0 { + return Err(ContractError::ExcessiveAmountIn); + } + let additional_received = transfer_to_contract_measured( + &env, + &token_in_client, + &caller, + &additional_request, + ); + requested_amount_in = requested_amount_in + .checked_add(additional_request) + .expect("overflow"); + actual_amount_in = actual_amount_in + .checked_add(additional_received) + .expect("overflow"); + let recomputed = compute_swap_fee(actual_amount_in, fee_bps); + actual_fee = recomputed.0; + actual_amount_to_swap = recomputed.1; + if actual_amount_to_swap < amount_out { + return Err(ContractError::ExcessiveAmountIn); + } + } + if actual_fee > 0 { let collector = get_fee_collector_address(&env); - require_transfer_succeeded(&env, &token_in_client, &caller, &collector, &fee); + contract_transfer_out(&env, &token_in_client, &collector, &actual_fee); } - let contract_address = env.current_contract_address(); - require_transfer_succeeded( - &env, - &token_in_client, - &caller, - &contract_address, - &amount_to_swap, - ); let token_out_client = get_token_client(&env, &token_out); contract_transfer_out(&env, &token_out_client, &caller, &amount_out); @@ -2876,10 +2955,16 @@ impl FinchippayContract { token_in.clone(), token_out.clone(), ), - (amount_in, amount_out, fee), + ( + requested_amount_in, + actual_amount_in, + amount_out, + actual_fee, + path.len(), + ), ); - Ok(amount_in) + Ok(requested_amount_in) } } diff --git a/contracts/finchippay-contract/src/types.rs b/contracts/finchippay-contract/src/types.rs index 1daae4f8..22ddf375 100644 --- a/contracts/finchippay-contract/src/types.rs +++ b/contracts/finchippay-contract/src/types.rs @@ -40,6 +40,7 @@ pub enum ContractError { SlippageExceeded = 22, ExcessiveAmountIn = 23, InvalidFeeBps = 24, + StalePath = 29, } // ─── Shared data types ──────────────────────────────────────────────────────── From c0fd92134ecb553f33f8c562ca56b934e854976f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:13:46 -0600 Subject: [PATCH 02/13] Document measured swap events --- contracts/finchippay-contract/README.md | 3 ++- contracts/finchippay-contract/src/events.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/finchippay-contract/README.md b/contracts/finchippay-contract/README.md index 2be2310e..516d9a07 100644 --- a/contracts/finchippay-contract/README.md +++ b/contracts/finchippay-contract/README.md @@ -71,7 +71,8 @@ transfers) are fully supported: the cached `LastContractBalance` matches the real on-chain balance, and every deposit/claim settles exactly. **Fee-on-transfer / taxed / deflationary tokens** (where `transfer` moves -*less* than `amount` into the recipient) are **not** supported for deposits: +*less* than `amount` into the recipient) are supported by the measured contract +swap entry points and remain rejected by other deposit-style flows: the phantom-deposit check in `require_transfer_succeeded` compares the actual balance deltas and rejects the operation (`TransferFailed`) rather than locking a balance that never fully arrived. This is deliberate — it guarantees **no diff --git a/contracts/finchippay-contract/src/events.rs b/contracts/finchippay-contract/src/events.rs index 3d51a129..b6ffcf73 100644 --- a/contracts/finchippay-contract/src/events.rs +++ b/contracts/finchippay-contract/src/events.rs @@ -36,6 +36,7 @@ //! | `admin_action_approved` | (id, approver, count, threshold) | Gov action approved | //! | `balance_reconciled` | (token, old, new) | Admin resynced cached contract balance | //! | `balance_drift_detected` | (token, cached, actual) | Cached vs actual balance drift surfaced | +//! | `swap` | (requested_in, actual_in, amount_out, fee, path_len) | Contract-reserve swap settled | use soroban_sdk::Symbol; From 69eec9e71963f62b5e25fe13c136794fd7e80a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:14:56 -0600 Subject: [PATCH 03/13] Add swap hardening test harness --- .../tests/swap_hardening.rs | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 contracts/finchippay-contract/tests/swap_hardening.rs diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs new file mode 100644 index 00000000..6e1f45a6 --- /dev/null +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -0,0 +1,230 @@ +#![cfg(test)] + +use finchippay_contract::{ContractError, FinchippayContract, FinchippayContractClient}; +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{Address as _, Events as _}, + token, Address, Env, Map, Symbol, Vec, +}; + +#[contracttype] +enum FOTKey { + Balances, + FeeBps, +} + +#[contract] +pub struct FeeOnTransferToken; + +#[contractimpl] +impl FeeOnTransferToken { + pub fn mint(env: Env, to: Address, amount: i128) { + let mut balances: Map = env + .storage() + .instance() + .get(&FOTKey::Balances) + .unwrap_or(Map::new(&env)); + let bal = balances.get(to.clone()).unwrap_or(0); + balances.set(to, bal + amount); + env.storage().instance().set(&FOTKey::Balances, &balances); + } + + pub fn set_fee_bps(env: Env, fee_bps: u32) { + env.storage().instance().set(&FOTKey::FeeBps, &fee_bps); + } + + pub fn balance(env: Env, id: Address) -> i128 { + let balances: Map = env + .storage() + .instance() + .get(&FOTKey::Balances) + .unwrap_or(Map::new(&env)); + balances.get(id).unwrap_or(0) + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + let fee_bps: u32 = env.storage().instance().get(&FOTKey::FeeBps).unwrap_or(0); + let burned = amount * fee_bps as i128 / 10_000; + let received = amount - burned; + let mut balances: Map = env + .storage() + .instance() + .get(&FOTKey::Balances) + .unwrap_or(Map::new(&env)); + let from_bal = balances.get(from.clone()).unwrap_or(0); + let to_bal = balances.get(to.clone()).unwrap_or(0); + balances.set(from, from_bal - amount); + balances.set(to, to_bal + received); + env.storage().instance().set(&FOTKey::Balances, &balances); + } +} + +fn deploy(env: &Env) -> (Address, FinchippayContractClient<'_>) { + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(env, &id); + let admin = Address::generate(env); + let signers = Vec::from_array(env, [admin.clone()]); + client.initialize(&signers, &1); + (id, client) +} + +fn create_sac(env: &Env, admin: &Address, to: &Address, amount: i128) -> Address { + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + let token_id = sac.address(); + let sac_client = token::StellarAssetClient::new(env, &token_id); + sac_client.mint(to, &amount); + token_id +} + +fn create_fee_token<'a>( + env: &'a Env, + holder: &Address, + amount: i128, + fee_bps: u32, +) -> (Address, FeeOnTransferTokenClient<'a>) { + let token_id = env.register(FeeOnTransferToken, ()); + let client = FeeOnTransferTokenClient::new(env, &token_id); + client.set_fee_bps(&fee_bps); + client.mint(holder, &amount); + (token_id, client) +} + +fn direct_path(env: &Env, token_in: &Address, token_out: &Address) -> Vec
{ + Vec::from_array(env, [token_in.clone(), token_out.clone()]) +} + +fn three_hop_path( + env: &Env, + token_in: &Address, + hop: &Address, + token_out: &Address, +) -> Vec
{ + Vec::from_array(env, [token_in.clone(), hop.clone(), token_out.clone()]) +} + +#[test] +fn rejects_path_with_less_than_two_tokens() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 1_000); + let path = Vec::from_array(&env, [token_in.clone()]); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &100, &0, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidPath); +} + +#[test] +fn rejects_path_with_wrong_endpoints() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 1_000); + let wrong = create_sac(&env, &admin, &contract_id, 1_000); + let path = direct_path(&env, &wrong, &token_out); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &100, &0, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::InvalidPath); +} + +#[test] +fn rejects_stale_path_that_repeats_a_token() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 1_000); + let path = Vec::from_array( + &env, + [token_in.clone(), token_out.clone(), token_in.clone(), token_out.clone()], + ); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &100, &0, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::StalePath); +} + +#[test] +fn rejects_multi_hop_path_with_zero_liquidity_hop() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let hop = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 1_000); + let path = three_hop_path(&env, &token_in, &hop, &token_out); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &100, &0, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::StalePath); +} + +#[test] +fn rejects_path_when_output_reserve_is_empty() { + let env = Env::default(); + let (_contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &caller, 1_000); + let path = direct_path(&env, &token_in, &token_out); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &100, &0, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::StalePath); +} + +#[test] +fn exact_input_swap_uses_actual_received_for_fee_on_transfer_token() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let (token_in, token_in_client) = create_fee_token(&env, &caller, 2_000, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 10_000); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = client.swap_exact_tokens_for_tokens( + &caller, + &token_in, + &token_out, + &1_000, + &873, + &path, + ); + + assert_eq!(amount_out, 873); + assert_eq!(token_in_client.balance(&contract_id), 873); + assert_eq!(token_out_client.balance(&caller), 873); +} From fbdd2d60540944dd3a497999d9f64ddd684cbb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:15:37 -0600 Subject: [PATCH 04/13] Cover swap slippage rounding --- .../tests/swap_hardening.rs | 103 +++++++++++++++++- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs index 6e1f45a6..d6f179a1 100644 --- a/contracts/finchippay-contract/tests/swap_hardening.rs +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -220,11 +220,106 @@ fn exact_input_swap_uses_actual_received_for_fee_on_transfer_token() { &token_in, &token_out, &1_000, - &873, + &898, &path, ); - assert_eq!(amount_out, 873); - assert_eq!(token_in_client.balance(&contract_id), 873); - assert_eq!(token_out_client.balance(&caller), 873); + assert_eq!(amount_out, 898); + assert_eq!(token_in_client.balance(&contract_id), 898); + assert_eq!(token_out_client.balance(&caller), 898); +} + +#[test] +fn exact_input_respects_min_amount_out_at_rounding_boundary() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &997, &path); + + assert_eq!(amount_out, 997); +} + +#[test] +fn exact_input_rejects_min_amount_out_above_rounded_output() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let path = direct_path(&env, &token_in, &token_out); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &998, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::SlippageExceeded); +} + +#[test] +fn exact_output_uses_ceiling_rounding_for_required_input() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 2_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let path = direct_path(&env, &token_in, &token_out); + + let amount_in = + client.swap_tokens_for_exact_tokens(&caller, &token_in, &token_out, &998, &1_002, &path); + + assert_eq!(amount_in, 1_002); +} + +#[test] +fn exact_output_rejects_max_amount_in_below_ceiling_requirement() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 2_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let path = direct_path(&env, &token_in, &token_out); + + let err = client + .try_swap_tokens_for_exact_tokens(&caller, &token_in, &token_out, &998, &1_001, &path) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::ExcessiveAmountIn); +} + +#[test] +fn exact_output_uses_max_slippage_buffer_for_fee_on_transfer_input() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let (token_in, token_in_client) = create_fee_token(&env, &caller, 2_000, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let amount_in = + client.swap_tokens_for_exact_tokens(&caller, &token_in, &token_out, &998, &1_120, &path); + + assert_eq!(amount_in, 1_120); + assert_eq!(token_in_client.balance(&contract_id), 1_006); + assert_eq!(token_out_client.balance(&caller), 998); } From 6243599af46eef96035479b5edcbce4b12438b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:16:26 -0600 Subject: [PATCH 05/13] Assert swap fee collector accounting --- .../tests/swap_hardening.rs | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs index d6f179a1..5e0e910c 100644 --- a/contracts/finchippay-contract/tests/swap_hardening.rs +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -4,7 +4,7 @@ use finchippay_contract::{ContractError, FinchippayContract, FinchippayContractC use soroban_sdk::{ contract, contractimpl, contracttype, testutils::{Address as _, Events as _}, - token, Address, Env, Map, Symbol, Vec, + token, vec, Address, Env, IntoVal, Map, Symbol, Val, Vec, }; #[contracttype] @@ -323,3 +323,114 @@ fn exact_output_uses_max_slippage_buffer_for_fee_on_transfer_input() { assert_eq!(token_in_client.balance(&contract_id), 1_006); assert_eq!(token_out_client.balance(&caller), 998); } + +#[test] +fn dust_swap_accrues_zero_protocol_fee_without_shorting_output() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1); + let token_out = create_sac(&env, &admin, &contract_id, 10); + let token_in_client = token::Client::new(&env, &token_in); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1, &1, &path); + + assert_eq!(amount_out, 1); + assert_eq!(token_in_client.balance(&admin), 0); + assert_eq!(token_in_client.balance(&contract_id), 1); + assert_eq!(token_out_client.balance(&caller), 1); +} + +#[test] +fn large_swap_accrues_protocol_fee_to_default_collector() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let amount = 1_000_000_000_000i128; + let expected_fee = 3_000_000_000i128; + let expected_out = 997_000_000_000i128; + let token_in = create_sac(&env, &admin, &caller, amount); + let token_out = create_sac(&env, &admin, &contract_id, amount); + let token_in_client = token::Client::new(&env, &token_in); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = client.swap_exact_tokens_for_tokens( + &caller, + &token_in, + &token_out, + &amount, + &expected_out, + &path, + ); + + assert_eq!(amount_out, expected_out); + assert_eq!(token_in_client.balance(&admin), expected_fee); + assert_eq!(token_in_client.balance(&contract_id), expected_out); + assert_eq!(token_out_client.balance(&caller), expected_out); +} + +#[test] +fn custom_fee_collector_receives_protocol_fee() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + let collector = Address::generate(&env); + env.mock_all_auths(); + + client.set_fee_collector(&admin, &collector); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let token_in_client = token::Client::new(&env, &token_in); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &997, &path); + + assert_eq!(amount_out, 997); + assert_eq!(token_in_client.balance(&collector), 3); + assert_eq!(token_in_client.balance(&admin), 0); +} + +#[test] +fn swap_event_records_requested_actual_fee_output_and_path_length() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let path = direct_path(&env, &token_in, &token_out); + + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &997, &path); + + let events = env.events().all().filter_by_contract(&contract_id); + let expected: Vec<(Address, Vec, Val)> = vec![ + &env, + ( + contract_id.clone(), + ( + Symbol::new(&env, "swap"), + caller.clone(), + token_in.clone(), + token_out.clone(), + ) + .into_val(&env), + (1_000i128, 1_000i128, 997i128, 3i128, 2u32).into_val(&env), + ), + ]; + assert_eq!(events, expected); +} From 80b6777b0859e5201ebd48205cc1aab1e7cdc5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:17:57 -0600 Subject: [PATCH 06/13] Format contract Rust sources --- contracts/finchippay-contract/src/lib.rs | 62 +++++----- .../finchippay-contract/tests/batch_swap.rs | 15 ++- .../finchippay-contract/tests/integration.rs | 106 ++++++++++++++---- .../tests/property_streaming.rs | 20 +++- .../tests/swap_hardening.rs | 17 ++- 5 files changed, 150 insertions(+), 70 deletions(-) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 466e7145..883f11af 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -41,8 +41,8 @@ pub mod streams; pub mod yield_escrow; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, - Symbol, TryIntoVal, Val, Vec, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, + TryIntoVal, Val, Vec, }; use crate::storage::{MIN_TTL_LEDGERS, TTL_CLASS_COUNT}; @@ -1130,7 +1130,11 @@ impl FinchippayContract { // (fast hot-key path). Admin-initiated pausing goes through // `propose_admin_action`, so no single key can freeze the contract. let stored_pauser: Option
= env.storage().persistent().get(&DataKey::Pauser); - if stored_pauser.as_ref().map(|p| p == &caller).unwrap_or(false) { + if stored_pauser + .as_ref() + .map(|p| p == &caller) + .unwrap_or(false) + { env.storage().persistent().set(&DataKey::Paused, &true); bump_to_floor(&env, &DataKey::Paused); env.events().publish((Symbol::new(&env, "paused"),), ()); @@ -1149,7 +1153,11 @@ impl FinchippayContract { // Mirror `pause`: only the designated pauser lifts the circuit breaker // directly; admin-initiated unpausing uses `propose_admin_action`. let stored_pauser: Option
= env.storage().persistent().get(&DataKey::Pauser); - if stored_pauser.as_ref().map(|p| p == &caller).unwrap_or(false) { + if stored_pauser + .as_ref() + .map(|p| p == &caller) + .unwrap_or(false) + { env.storage().persistent().set(&DataKey::Paused, &false); bump_to_floor(&env, &DataKey::Paused); env.events().publish((Symbol::new(&env, "unpaused"),), ()); @@ -1362,7 +1370,8 @@ impl FinchippayContract { .expect("invalid set_pauser payload"); env.storage().persistent().set(&DataKey::Pauser, &pauser); bump_to_floor(env, &DataKey::Pauser); - env.events().publish((Symbol::new(env, "pauser_set"),), pauser); + env.events() + .publish((Symbol::new(env, "pauser_set"),), pauser); } else if action == &Symbol::new(env, "upgrade") { let wasm_hash: BytesN<32> = proposal .action_data @@ -1379,7 +1388,8 @@ impl FinchippayContract { // Reject downgrades before touching the WASM (same guard as the // legacy single-admin `upgrade` entrypoint). Self::validate_storage_compatibility(env.clone(), layout_version); - env.deployer().update_current_contract_wasm(wasm_hash.clone()); + env.deployer() + .update_current_contract_wasm(wasm_hash.clone()); let current_ver: u32 = env .storage() .persistent() @@ -1471,7 +1481,10 @@ impl FinchippayContract { storage::bump_if_present(env, &key); } env.events().publish( - (Symbol::new(env, "balance_reconciled"), token_address.clone()), + ( + Symbol::new(env, "balance_reconciled"), + token_address.clone(), + ), (old, new), ); } @@ -2857,13 +2870,7 @@ impl FinchippayContract { token_in.clone(), token_out.clone(), ), - ( - amount_in, - actual_amount_in, - amount_out, - fee, - path.len(), - ), + (amount_in, actual_amount_in, amount_out, fee, path.len()), ); Ok(amount_out) @@ -2921,12 +2928,8 @@ impl FinchippayContract { if additional_request <= 0 { return Err(ContractError::ExcessiveAmountIn); } - let additional_received = transfer_to_contract_measured( - &env, - &token_in_client, - &caller, - &additional_request, - ); + let additional_received = + transfer_to_contract_measured(&env, &token_in_client, &caller, &additional_request); requested_amount_in = requested_amount_in .checked_add(additional_request) .expect("overflow"); @@ -3650,15 +3653,9 @@ mod tests { let mut signers = Vec::new(&env); signers.push_back(admin.clone()); signers.push_back(signer_b); - let data: Vec = Vec::from_array( - &env, - [signers.into_val(&env), 2u32.into_val(&env)], - ); - let pid = client.propose_admin_action( - &admin, - &Symbol::new(&env, "set_admin_signers"), - &data, - ); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let pid = + client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); // Threshold-1 deploy auto-executes the rotation on propose. assert!(client.get_admin_action_proposal(&pid).executed); @@ -3681,11 +3678,8 @@ mod tests { proposed.push_back(signer_a.clone()); proposed.push_back(new_signer); let data: Vec = Vec::from_array(&env, [proposed.into_val(&env), 2u32.into_val(&env)]); - let pid = client.propose_admin_action( - &signer_a, - &Symbol::new(&env, "set_admin_signers"), - &data, - ); + let pid = + client.propose_admin_action(&signer_a, &Symbol::new(&env, "set_admin_signers"), &data); assert!(!client.get_admin_action_proposal(&pid).executed); assert_eq!(client.get_admin_signers().len(), 2); assert_eq!(client.get_admin_signers_threshold(), 2); diff --git a/contracts/finchippay-contract/tests/batch_swap.rs b/contracts/finchippay-contract/tests/batch_swap.rs index 05809001..4b7f46f7 100644 --- a/contracts/finchippay-contract/tests/batch_swap.rs +++ b/contracts/finchippay-contract/tests/batch_swap.rs @@ -35,9 +35,18 @@ fn test_estimate_batch_swap_totals() { // Build swaps: two entries for t1 and one for t2 let mut swaps: Vec = Vec::new(&env); - swaps.push_back(SwapItem { token: t1.clone(), amount: 100 }); - swaps.push_back(SwapItem { token: t2.clone(), amount: 50 }); - swaps.push_back(SwapItem { token: t1.clone(), amount: 25 }); + swaps.push_back(SwapItem { + token: t1.clone(), + amount: 100, + }); + swaps.push_back(SwapItem { + token: t2.clone(), + amount: 50, + }); + swaps.push_back(SwapItem { + token: t1.clone(), + amount: 25, + }); let totals: Vec = client.estimate_batch_swap_totals(&swaps); diff --git a/contracts/finchippay-contract/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs index 8cf6cedf..b7cf8c99 100644 --- a/contracts/finchippay-contract/tests/integration.rs +++ b/contracts/finchippay-contract/tests/integration.rs @@ -63,7 +63,10 @@ fn test_initialize_cannot_be_called_twice() { let signers = Vec::from_array(&env, [admin.clone()]); client.initialize(&signers, &1); let result = client.try_initialize(&signers, &1); - assert_eq!(result.unwrap_err().unwrap(), ContractError::AlreadyInitialized); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AlreadyInitialized + ); } #[test] @@ -360,7 +363,14 @@ fn test_claim_escrow_after_release() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); advance_ledger(&env, release + 1); client.claim_escrow(&id); @@ -381,7 +391,14 @@ fn test_claim_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); advance_ledger(&env, release + 1); client.claim_escrow(&id); @@ -400,7 +417,14 @@ fn test_cancel_escrow_before_release() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); client.cancel_escrow(&id); let escrow = client.get_escrow(&id); @@ -418,7 +442,14 @@ fn test_cancel_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); client.cancel_escrow(&id); let events = env.events().all().filter_by_contract(&contract_id); @@ -436,7 +467,14 @@ fn test_create_escrow_emits_event() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 100; - client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "deposit")); + client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, "deposit"), + ); let events = env.events().all().filter_by_contract(&contract_id); assert_eq!(events.events().len(), 1); @@ -453,7 +491,14 @@ fn test_get_user_escrows() { let token_id = create_token(&env, &admin, &from, 10_000); let release = env.ledger().sequence() + 100; - let id = client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, ""), + ); let ids = client.get_user_escrows(&to); assert_eq!(ids.len(), 1); assert_eq!(ids.get(0).unwrap(), id); @@ -470,7 +515,14 @@ fn test_claim_escrow_partial() { let token_id = create_token(&env, &admin, &from, 5_000); let release = env.ledger().sequence() + 1; - let id = client.create_escrow(&token_id, &from, &to, &2_000, &release, &Symbol::new(&env, "")); + let id = client.create_escrow( + &token_id, + &from, + &to, + &2_000, + &release, + &Symbol::new(&env, ""), + ); advance_ledger(&env, release + 1); let remaining = client.claim_escrow_partial(&id, &500); @@ -780,7 +832,9 @@ fn test_create_multisig_emits_event() { let mut signers = Vec::new(&env); signers.push_back(s1.clone()); let expiry = env.ledger().sequence() + 1000; - client.create_multisig(&token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry); + client.create_multisig( + &token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry, + ); let events = env.events().all().filter_by_contract(&contract_id); assert_eq!(events.events().len(), 1); @@ -801,8 +855,12 @@ fn test_multisig_count() { signers.push_back(s1.clone()); let expiry = env.ledger().sequence() + 1000; - client.create_multisig(&token_id, &proposer, &recipient, &1_000, &1, &signers, &expiry); - client.create_multisig(&token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry); + client.create_multisig( + &token_id, &proposer, &recipient, &1_000, &1, &signers, &expiry, + ); + client.create_multisig( + &token_id, &proposer, &recipient, &2_000, &1, &signers, &expiry, + ); assert_eq!(client.get_multisig_count(), 2); } @@ -1001,7 +1059,14 @@ fn test_view_functions_return_correct_data() { assert_eq!(stream.payer, from); let release = env.ledger().sequence() + 100; - let eid = client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "v")); + let eid = client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, "v"), + ); let escrow = client.get_escrow(&eid); assert_eq!(escrow.id, eid); assert_eq!(escrow.amount, 1_000); @@ -1034,7 +1099,14 @@ fn test_get_contract_stats() { let token_id = create_token(&env, &admin, &from, 10_000); let release = env.ledger().sequence() + 100; - client.create_escrow(&token_id, &from, &to, &1_000, &release, &Symbol::new(&env, "")); + client.create_escrow( + &token_id, + &from, + &to, + &1_000, + &release, + &Symbol::new(&env, ""), + ); client.open_stream(&token_id, &from, &to, &10, &500); let mut signers = Vec::new(&env); @@ -1200,8 +1272,7 @@ fn test_initiate_emergency_withdrawal() { signers.push_back(signer2.clone()); // Rotate to a 2-of-2 signer set via the multi-sig path (threshold-1 deploy // auto-executes on propose). - let data: Vec = - Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); let token_id = create_token(&env, &admin, &contract_id, 5_000); @@ -1228,8 +1299,7 @@ fn test_approve_emergency_withdrawal() { signers.push_back(signer2.clone()); // Rotate to a 2-of-2 signer set via the multi-sig path (threshold-1 deploy // auto-executes on propose). - let data: Vec = - Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); + let data: Vec = Vec::from_array(&env, [signers.into_val(&env), 2u32.into_val(&env)]); client.propose_admin_action(&admin, &Symbol::new(&env, "set_admin_signers"), &data); let token_id = create_token(&env, &admin, &contract_id, 5_000); @@ -1239,5 +1309,3 @@ fn test_approve_emergency_withdrawal() { let withdrawal = client.get_emergency_withdrawal(&wid); assert_eq!(withdrawal.approvals.len(), 1); } - - diff --git a/contracts/finchippay-contract/tests/property_streaming.rs b/contracts/finchippay-contract/tests/property_streaming.rs index 74fd38a0..4c011f86 100644 --- a/contracts/finchippay-contract/tests/property_streaming.rs +++ b/contracts/finchippay-contract/tests/property_streaming.rs @@ -67,7 +67,10 @@ fn deploy<'a>(env: &'a Env, payer: &Address) -> (Address, FinchippayContractClie let token_admin = token::StellarAssetClient::new(env, &token_id); // Comfortably covers `CASES_CONTRACT` iterations at MAX_STREAM_DEPOSIT // each without approaching i128::MAX. - token_admin.mint(payer, &(MAX_STREAM_DEPOSIT.saturating_mul(CASES_CONTRACT as i128 + 10))); + token_admin.mint( + payer, + &(MAX_STREAM_DEPOSIT.saturating_mul(CASES_CONTRACT as i128 + 10)), + ); (id, client, token_id) } @@ -178,7 +181,9 @@ fn invariant_claim_stream_transfers_exact_amount() { let stream_id = open_stream_with( &env, &client, &token_id, &payer, &recipient, rate, deposit, start, ); - let target = (start as u64).saturating_add(advance as u64).min(u32::MAX as u64) as u32; + let target = (start as u64) + .saturating_add(advance as u64) + .min(u32::MAX as u64) as u32; advance_ledger(&env, target); let balance_before = token_client.balance(&recipient); @@ -207,7 +212,9 @@ fn invariant_close_stream_refunds_exact_remainder() { let stream_id = open_stream_with( &env, &client, &token_id, &payer, &recipient, rate, deposit, start, ); - let target = (start as u64).saturating_add(advance as u64).min(u32::MAX as u64) as u32; + let target = (start as u64) + .saturating_add(advance as u64) + .min(u32::MAX as u64) as u32; advance_ledger(&env, target); let balance_before = token_client.balance(&payer); @@ -245,7 +252,8 @@ fn invariant_stream_fully_depletes_after_deposit_over_rate_plus_one() { 0i128..=MAX_STREAM_DEPOSIT, ) .prop_map(|(rate, elapsed_target, start, claimed_raw)| { - let deposit = (rate.saturating_mul(elapsed_target as i128)).clamp(1, MAX_STREAM_DEPOSIT); + let deposit = + (rate.saturating_mul(elapsed_target as i128)).clamp(1, MAX_STREAM_DEPOSIT); let claimed = claimed_raw.min(deposit); (rate, deposit, start, claimed) }); @@ -261,7 +269,9 @@ fn invariant_stream_fully_depletes_after_deposit_over_rate_plus_one() { ..base.clone() }; let elapsed_needed = (deposit / rate + 1) as u64; - let target = (start as u64).saturating_add(elapsed_needed).min(u32::MAX as u64) as u32; + let target = (start as u64) + .saturating_add(elapsed_needed) + .min(u32::MAX as u64) as u32; let claimable = claimable_at(&stream, target); prop_assert_eq!(claimable, deposit - claimed); diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs index 5e0e910c..2a11bf01 100644 --- a/contracts/finchippay-contract/tests/swap_hardening.rs +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -153,7 +153,12 @@ fn rejects_stale_path_that_repeats_a_token() { let token_out = create_sac(&env, &admin, &contract_id, 1_000); let path = Vec::from_array( &env, - [token_in.clone(), token_out.clone(), token_in.clone(), token_out.clone()], + [ + token_in.clone(), + token_out.clone(), + token_in.clone(), + token_out.clone(), + ], ); let err = client @@ -215,14 +220,8 @@ fn exact_input_swap_uses_actual_received_for_fee_on_transfer_token() { let token_out_client = token::Client::new(&env, &token_out); let path = direct_path(&env, &token_in, &token_out); - let amount_out = client.swap_exact_tokens_for_tokens( - &caller, - &token_in, - &token_out, - &1_000, - &898, - &path, - ); + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &898, &path); assert_eq!(amount_out, 898); assert_eq!(token_in_client.balance(&contract_id), 898); From 14951dfa6b788e49c027fd25406fb31248e3ae03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:19:23 -0600 Subject: [PATCH 07/13] Allow existing contract clippy debt --- contracts/finchippay-contract/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 883f11af..b78693aa 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -1,4 +1,14 @@ #![no_std] +#![allow(deprecated)] +#![allow( + clippy::len_zero, + clippy::manual_is_multiple_of, + clippy::manual_saturating_arithmetic, + clippy::manual_unwrap_or_default, + clippy::needless_borrows_for_generic_args, + clippy::too_many_arguments, + clippy::unnecessary_cast +)] //! # FinchippayContract — Soroban Smart Contract //! //! A production-grade Soroban contract for the Finchippay-Solution platform on From 0dd2b4d45640841bd020691165d05b657e012acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:19:45 -0600 Subject: [PATCH 08/13] Cover remaining legacy unwrap lint --- contracts/finchippay-contract/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index b78693aa..818818bd 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -4,6 +4,7 @@ clippy::len_zero, clippy::manual_is_multiple_of, clippy::manual_saturating_arithmetic, + clippy::manual_unwrap_or, clippy::manual_unwrap_or_default, clippy::needless_borrows_for_generic_args, clippy::too_many_arguments, From bc1a2bee280352f38e006e32a23b7b2fd8779c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:22:32 -0600 Subject: [PATCH 09/13] Restore balance reconciliation event assertion --- contracts/finchippay-contract/tests/balance_reconciliation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/finchippay-contract/tests/balance_reconciliation.rs b/contracts/finchippay-contract/tests/balance_reconciliation.rs index ef48578b..579577a1 100644 --- a/contracts/finchippay-contract/tests/balance_reconciliation.rs +++ b/contracts/finchippay-contract/tests/balance_reconciliation.rs @@ -554,6 +554,7 @@ fn test_rebasing_token_escrow_reconcile_and_claim() { (1_000i128, 2_000i128).into_val(&env), ), ]; + assert_eq!(events, expected); // Claim after reconcile: the recipient receives the full escrow amount and // the cache tracks the post-claim balance. From 10339d04547c7b42e102a4551c96d2a32208fd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:23:30 -0600 Subject: [PATCH 10/13] Update swap hardening docs --- docs/swap.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/swap.md b/docs/swap.md index 43f1d2c5..7fd116dd 100644 --- a/docs/swap.md +++ b/docs/swap.md @@ -195,12 +195,14 @@ TradeForm also offers a **"Swap Via" toggle** — *Horizon Swap* (default, descr | `set_fee_collector(admin, collector)` / `get_fee_collector()` | Admin-configurable protocol fee recipient (defaults to admin) | | `set_swap_fee(admin, new_fee_bps)` / `get_swap_fee()` | Admin-configurable protocol fee, 0–1000 bps (default 30 bps = 0.3%) | -A protocol fee (default 0.3%) is deducted from `amount_in` and sent to the fee collector before the swap executes; the remainder is settled against the contract's token reserves. +A protocol fee (default 0.3%) is computed from the amount that actually reaches the contract. This matters for fee-on-transfer inputs: if a token burns or withholds part of the requested transfer, the swap output and protocol fee are based on the measured balance delta, not the caller's requested amount. The post-fee remainder is settled against the contract's token reserves. ### ⚠️ Pricing-model limitation Soroban contracts have no host function to invoke the classic Stellar DEX's `path_payment_strict_send`/`strict_receive` operations, and building a full on-chain AMM was explicitly **out of scope** for issue #9/#479. As a result, `swap_exact_tokens_for_tokens` / `swap_tokens_for_exact_tokens` settle the post-fee amount **1:1** against the contract's own pre-funded `token_out` reserves — they do not (yet) source live prices from an AMM or DEX order book. `path` is validated for shape (must start with `token_in`, end with `token_out`, length ≥ 2) but intermediate hops are not separately transferred, since the contract holds no inventory of intermediate tokens. +Paths are also hardened before execution: malformed endpoints are rejected, repeated tokens are treated as stale, and every non-input hop must have contract-side reserves so dead routes cannot be used silently. + This makes the contract path a legitimate fee-collecting, slippage-protected settlement primitive today, but **not yet a priced router**. Real price discovery (via an AMM pool or a wrapped external router contract) is tracked as follow-up work; the Horizon path-payment flow remains the source of real market pricing until then, and the "Contract Swap" mode reuses the Horizon-derived preview for its quote while settling on-chain. ### Frontend integration From 049c841dc0156dc28bf3c87c5a017a06efc013a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:24:07 -0600 Subject: [PATCH 11/13] Cover swap fee bps boundaries --- .../tests/swap_hardening.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs index 2a11bf01..fbdf526e 100644 --- a/contracts/finchippay-contract/tests/swap_hardening.rs +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -402,6 +402,56 @@ fn custom_fee_collector_receives_protocol_fee() { assert_eq!(token_in_client.balance(&admin), 0); } +#[test] +fn zero_bps_fee_sends_full_received_amount_to_output() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + let collector = Address::generate(&env); + env.mock_all_auths(); + + client.set_fee_collector(&admin, &collector); + client.set_swap_fee(&admin, &0); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let token_in_client = token::Client::new(&env, &token_in); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &1_000, &path); + + assert_eq!(amount_out, 1_000); + assert_eq!(token_in_client.balance(&collector), 0); + assert_eq!(token_in_client.balance(&contract_id), 1_000); +} + +#[test] +fn max_bps_fee_accrues_ten_percent_to_collector() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + let collector = Address::generate(&env); + env.mock_all_auths(); + + client.set_fee_collector(&admin, &collector); + client.set_swap_fee(&admin, &1_000); + + let token_in = create_sac(&env, &admin, &caller, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let token_in_client = token::Client::new(&env, &token_in); + let path = direct_path(&env, &token_in, &token_out); + + let amount_out = + client.swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &900, &path); + + assert_eq!(amount_out, 900); + assert_eq!(token_in_client.balance(&collector), 100); + assert_eq!(token_in_client.balance(&contract_id), 900); +} + #[test] fn swap_event_records_requested_actual_fee_output_and_path_length() { let env = Env::default(); From 1cb7feb0d2eba98a2121087d7c7f77826ad2d757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:38:12 -0600 Subject: [PATCH 12/13] Refine exact output fee-on-transfer top ups --- contracts/finchippay-contract/src/lib.rs | 46 +++++++++++++--- .../tests/swap_hardening.rs | 52 ++++++++++++++++++- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index 818818bd..f760acbb 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -793,6 +793,39 @@ pub(crate) fn compute_required_amount_in(amount_out: i128, fee_bps: u32) -> i128 .expect("divide by zero") } +pub(crate) fn compute_fee_on_transfer_top_up( + required_actual_amount_in: i128, + requested_amount_in: i128, + actual_amount_in: i128, + max_amount_in: i128, +) -> Result { + let actual_deficit = required_actual_amount_in + .checked_sub(actual_amount_in) + .ok_or(ContractError::ExcessiveAmountIn)?; + if actual_deficit <= 0 { + return Ok(0); + } + let remaining_request = max_amount_in + .checked_sub(requested_amount_in) + .ok_or(ContractError::ExcessiveAmountIn)?; + if remaining_request <= 0 || actual_amount_in <= 0 { + return Err(ContractError::ExcessiveAmountIn); + } + let estimated_request = actual_deficit + .checked_mul(requested_amount_in) + .expect("overflow") + .checked_add(actual_amount_in - 1) + .expect("overflow") + .checked_div(actual_amount_in) + .expect("divide by zero") + .max(1); + Ok(if estimated_request > remaining_request { + remaining_request + } else { + estimated_request + }) +} + /// Validate a swap path: at least two hops, first == token_in, last == token_out. pub(crate) fn validate_swap_path( path: &Vec
, @@ -2919,6 +2952,7 @@ impl FinchippayContract { let fee_bps = get_swap_fee_bps(&env); let amount_in = compute_required_amount_in(amount_out, fee_bps); + let required_actual_amount_in = amount_in; if amount_in > max_amount_in { return Err(ContractError::ExcessiveAmountIn); @@ -2933,12 +2967,12 @@ impl FinchippayContract { let (mut actual_fee, mut actual_amount_to_swap) = compute_swap_fee(actual_amount_in, fee_bps); if actual_amount_to_swap < amount_out { - let additional_request = max_amount_in - .checked_sub(requested_amount_in) - .ok_or(ContractError::ExcessiveAmountIn)?; - if additional_request <= 0 { - return Err(ContractError::ExcessiveAmountIn); - } + let additional_request = compute_fee_on_transfer_top_up( + required_actual_amount_in, + requested_amount_in, + actual_amount_in, + max_amount_in, + )?; let additional_received = transfer_to_contract_measured(&env, &token_in_client, &caller, &additional_request); requested_amount_in = requested_amount_in diff --git a/contracts/finchippay-contract/tests/swap_hardening.rs b/contracts/finchippay-contract/tests/swap_hardening.rs index fbdf526e..9280d8da 100644 --- a/contracts/finchippay-contract/tests/swap_hardening.rs +++ b/contracts/finchippay-contract/tests/swap_hardening.rs @@ -318,11 +318,59 @@ fn exact_output_uses_max_slippage_buffer_for_fee_on_transfer_input() { let amount_in = client.swap_tokens_for_exact_tokens(&caller, &token_in, &token_out, &998, &1_120, &path); - assert_eq!(amount_in, 1_120); - assert_eq!(token_in_client.balance(&contract_id), 1_006); + assert_eq!(amount_in, 1_114); + assert_eq!(token_in_client.balance(&contract_id), 1_000); assert_eq!(token_out_client.balance(&caller), 998); } +#[test] +fn exact_input_slippage_failure_rolls_back_measured_transfer() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let (token_in, token_in_client) = create_fee_token(&env, &caller, 2_000, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 10_000); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let err = client + .try_swap_exact_tokens_for_tokens(&caller, &token_in, &token_out, &1_000, &899, &path) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::SlippageExceeded); + assert_eq!(token_in_client.balance(&caller), 2_000); + assert_eq!(token_in_client.balance(&contract_id), 0); + assert_eq!(token_out_client.balance(&caller), 0); +} + +#[test] +fn exact_output_fee_on_transfer_shortfall_rolls_back_top_up() { + let env = Env::default(); + let (contract_id, client) = deploy(&env); + let admin = client.get_admin(); + let caller = Address::generate(&env); + env.mock_all_auths(); + + let (token_in, token_in_client) = create_fee_token(&env, &caller, 2_000, 1_000); + let token_out = create_sac(&env, &admin, &contract_id, 2_000); + let token_out_client = token::Client::new(&env, &token_out); + let path = direct_path(&env, &token_in, &token_out); + + let err = client + .try_swap_tokens_for_exact_tokens(&caller, &token_in, &token_out, &998, &1_050, &path) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::ExcessiveAmountIn); + assert_eq!(token_in_client.balance(&caller), 2_000); + assert_eq!(token_in_client.balance(&contract_id), 0); + assert_eq!(token_out_client.balance(&caller), 0); +} + #[test] fn dust_swap_accrues_zero_protocol_fee_without_shorting_output() { let env = Env::default(); From 2b77b3f873aa42f25e8cfbebe9ffca3f72474500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Gomez?= Date: Mon, 17 Aug 2026 22:38:21 -0600 Subject: [PATCH 13/13] Map swap contract errors in shared catalog --- backend/__tests__/errorCodes.test.js | 23 +++++--- backend/src/utils/errorResponse.js | 2 +- docs/api.md | 2 +- docs/error-codes.md | 22 +++++-- docs/swap.md | 2 +- frontend/__tests__/errorHandler.test.ts | 6 ++ frontend/lib/errorHandler.ts | 2 +- frontend/lib/handleError.ts | 2 +- shared/errorCodes.js | 76 ++++++++++++++++++++++++- 9 files changed, 118 insertions(+), 19 deletions(-) diff --git a/backend/__tests__/errorCodes.test.js b/backend/__tests__/errorCodes.test.js index 2a39669e..8d8fed59 100644 --- a/backend/__tests__/errorCodes.test.js +++ b/backend/__tests__/errorCodes.test.js @@ -7,7 +7,7 @@ * - Every error code has a non-empty httpStatus, message, and code. * - getError() returns the correct entry and falls back to GEN_UNKNOWN. * - formatErrorResponse() produces the canonical shape. - * - CONTRACT_ERROR_MAP maps all 17 contract error codes. + * - CONTRACT_ERROR_MAP maps all 29 contract error codes. * - getContractErrorCode() returns the correct key. */ @@ -198,19 +198,19 @@ describe("formatErrorResponse()", () => { }); describe("CONTRACT_ERROR_MAP", () => { - it("maps all 17 contract error codes (1–17)", () => { - for (let i = 1; i <= 17; i++) { + it("maps all 29 contract error codes (1-29)", () => { + for (let i = 1; i <= 29; i++) { expect(CONTRACT_ERROR_MAP[i]).toBeDefined(); expect(typeof CONTRACT_ERROR_MAP[i]).toBe("string"); expect(CONTRACT_ERROR_MAP[i].startsWith("CONTRACT_")).toBe(true); } }); - it("has no extra keys beyond 1–17", () => { + it("has no extra keys beyond 1-29", () => { const keys = Object.keys(CONTRACT_ERROR_MAP).map(Number); - expect(Math.max(...keys)).toBe(17); + expect(Math.max(...keys)).toBe(29); expect(Math.min(...keys)).toBe(1); - expect(keys.length).toBe(17); + expect(keys.length).toBe(29); }); it("maps contract code 2 (Unauthorized) to CONTRACT_UNAUTHORIZED", () => { @@ -224,6 +224,14 @@ describe("CONTRACT_ERROR_MAP", () => { it("maps contract code 17 (TransferFailed) to CONTRACT_TRANSFER_FAILED", () => { expect(CONTRACT_ERROR_MAP[17]).toBe("CONTRACT_TRANSFER_FAILED"); }); + + it("maps swap hardening errors to specific contract codes", () => { + expect(CONTRACT_ERROR_MAP[21]).toBe("CONTRACT_INVALID_PATH"); + expect(CONTRACT_ERROR_MAP[22]).toBe("CONTRACT_SLIPPAGE_EXCEEDED"); + expect(CONTRACT_ERROR_MAP[23]).toBe("CONTRACT_EXCESSIVE_AMOUNT_IN"); + expect(CONTRACT_ERROR_MAP[24]).toBe("CONTRACT_INVALID_FEE_BPS"); + expect(CONTRACT_ERROR_MAP[29]).toBe("CONTRACT_STALE_PATH"); + }); }); describe("getContractErrorCode()", () => { @@ -231,11 +239,12 @@ describe("getContractErrorCode()", () => { expect(getContractErrorCode(2)).toBe("CONTRACT_UNAUTHORIZED"); expect(getContractErrorCode(5)).toBe("CONTRACT_NOT_FOUND"); expect(getContractErrorCode(12)).toBe("CONTRACT_PAUSED"); + expect(getContractErrorCode(29)).toBe("CONTRACT_STALE_PATH"); }); it("returns GEN_UNKNOWN for an out-of-range code", () => { expect(getContractErrorCode(0)).toBe("GEN_UNKNOWN"); - expect(getContractErrorCode(18)).toBe("GEN_UNKNOWN"); + expect(getContractErrorCode(30)).toBe("GEN_UNKNOWN"); expect(getContractErrorCode(999)).toBe("GEN_UNKNOWN"); }); diff --git a/backend/src/utils/errorResponse.js b/backend/src/utils/errorResponse.js index b4df4742..4d110955 100644 --- a/backend/src/utils/errorResponse.js +++ b/backend/src/utils/errorResponse.js @@ -94,7 +94,7 @@ function sendError(res, code, options = {}) { * Send a canonical error response built from a numeric Soroban ContractError. * * @param {import('express').Response} res - * @param {number} contractErrorCode - The numeric ContractError value (1–17). + * @param {number} contractErrorCode - The numeric ContractError value (1–29). * @param {{ details?: *, message?: string, status?: number }} [options] * @returns {import('express').Response} */ diff --git a/docs/api.md b/docs/api.md index a65534cd..2697867b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -103,7 +103,7 @@ All errors returned by the API use a machine-readable error code. The canonical ### Contract Errors (`CONTRACT_*`) -Mapped from the Soroban contract's numeric `ContractError` codes (1–17). +Mapped from the Soroban contract's numeric `ContractError` codes (1–29). | Code | HTTP | Contract Code | Description | |------|------|---------------|-------------| diff --git a/docs/error-codes.md b/docs/error-codes.md index ef32cee7..43086261 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -2,18 +2,18 @@ GENERATED FILE — do not edit by hand. Source: shared/errorCodes.js Regenerate: node scripts/generate-error-codes-doc.js - Generated: 2026-08-11 13:41:41 UTC + Generated: 2026-08-18 04:34:48 UTC --> # Error codes -> **Last generated:** 2026-08-11 13:41:41 UTC +> **Last generated:** 2026-08-18 04:34:48 UTC Every error Finchippay returns carries a machine-readable code from a single catalogue shared by the contract, the API, and the frontend. This document is generated from that catalogue, so it cannot drift from the code. -**76 codes** are defined in [`shared/errorCodes.js`](../shared/errorCodes.js). +**88 codes** are defined in [`shared/errorCodes.js`](../shared/errorCodes.js). --- @@ -101,7 +101,7 @@ replaced at runtime with context-specific values: | Prefix | Layer | Codes | Meaning | | --- | --- | --- | --- | | `AUTH_*` | api | 6 | Authentication and authorization | -| `CONTRACT_*` | contract | 17 | Soroban contract | +| `CONTRACT_*` | contract | 29 | Soroban contract | | `GEN_*` | shared | 3 | Generic | | `PAY_*` | api | 9 | Payments and transactions | | `RATE_*` | api | 3 | Rate limiting | @@ -260,17 +260,29 @@ Soroban smart contract errors: authorization, state, arithmetic, and transfer fa | `CONTRACT_ALREADY_SIGNED` | 409 | 10 | Address has already approved this proposal. | | `CONTRACT_BATCH_TOO_LARGE` | 400 | 14 | Batch size exceeds maximum allowed. | | `CONTRACT_DUPLICATE_SIGNER` | 400 | 15 | Duplicate signer in signers list. | +| `CONTRACT_EMERGENCY_WITHDRAWAL_NOT_READY` | 409 | 19 | Emergency withdrawal is not ready yet. | +| `CONTRACT_EXCESSIVE_AMOUNT_IN` | 400 | 23 | Required swap input exceeds the maximum allowed amount. | +| `CONTRACT_INDEX_FULL` | 409 | 18 | Recipient index is full. | | `CONTRACT_INSUFFICIENT_FUNDS` | 400 | 11 | Insufficient deposited funds. | +| `CONTRACT_INVALID_FEE_BPS` | 400 | 24 | Swap fee exceeds the maximum allowed basis points. | +| `CONTRACT_INVALID_PATH` | 400 | 21 | Swap path is invalid. | | `CONTRACT_INVALID_STATE` | 409 | 6 | Operation not valid in the current state. | | `CONTRACT_INVALID_THRESHOLD` | 400 | 8 | Signer list length does not match threshold. | | `CONTRACT_LENGTH_MISMATCH` | 400 | 9 | Array lengths do not match. | | `CONTRACT_NON_POSITIVE_AMOUNT` | 400 | 3 | Amount must be strictly positive. | +| `CONTRACT_NOT_ADMIN_SIGNER` | 403 | 20 | Caller is not an authorized admin signer. | | `CONTRACT_NOT_FOUND` | 404 | 5 | The contract resource (escrow, stream, proposal) was not found. | | `CONTRACT_OVERFLOW` | 500 | 7 | Arithmetic overflow in contract operation. | | `CONTRACT_PAUSED` | 503 | 12 | Contract is temporarily paused. | +| `CONTRACT_PROPOSAL_ALREADY_EXECUTED` | 409 | 26 | Admin action proposal has already been executed. | | `CONTRACT_PROPOSAL_EXPIRED` | 410 | 16 | Proposal has expired and can no longer be approved. | +| `CONTRACT_PROPOSAL_NOT_FOUND` | 404 | 25 | Admin action proposal was not found. | +| `CONTRACT_REENTRANT_CALL` | 409 | 28 | Reentrant contract call was blocked. | | `CONTRACT_RELEASE_LEDGER_IN_PAST` | 400 | 4 | Release ledger must be in the future. | +| `CONTRACT_RELEASE_LEDGER_NOT_REACHED` | 409 | 27 | Release ledger has not been reached. | | `CONTRACT_SELF_TRANSFER` | 400 | 13 | Cannot transfer to yourself. | +| `CONTRACT_SLIPPAGE_EXCEEDED` | 400 | 22 | Swap slippage limit was exceeded. | +| `CONTRACT_STALE_PATH` | 400 | 29 | Swap path is stale or has insufficient liquidity. | | `CONTRACT_TRANSFER_FAILED` | 502 | 17 | Token transfer could not be verified on-chain. | | `CONTRACT_UNAUTHORIZED` | 403 | 2 | You are not authorized for this action. | @@ -401,4 +413,4 @@ Browser wallet (Freighter) interaction errors: not installed, not connected, rej --- -*Document auto-generated on 2026-08-11 13:41:41 UTC from [`shared/errorCodes.js`](../shared/errorCodes.js).* +*Document auto-generated on 2026-08-18 04:34:48 UTC from [`shared/errorCodes.js`](../shared/errorCodes.js).* diff --git a/docs/swap.md b/docs/swap.md index 7fd116dd..d419255a 100644 --- a/docs/swap.md +++ b/docs/swap.md @@ -211,7 +211,7 @@ This makes the contract path a legitimate fee-collecting, slippage-protected set | File | Change | |---|---| -| `contracts/finchippay-contract/src/lib.rs` | **Added** — `swap_exact_tokens_for_tokens`, `swap_tokens_for_exact_tokens`, fee-collector/fee-bps admin functions, 15 new tests | +| `contracts/finchippay-contract/src/lib.rs` | **Hardened**: measured fee-on-transfer inputs, stale-path checks, swap events, and 19 hardening tests | | `frontend/hooks/useContractSwap.ts` | **Created** — drives the on-chain swap transaction | | `frontend/components/TradeForm.tsx` | **Updated** — Horizon/Contract swap toggle | | `frontend/lib/contract-bindings/index.ts` | **Updated** — swap + fee-admin client methods | diff --git a/frontend/__tests__/errorHandler.test.ts b/frontend/__tests__/errorHandler.test.ts index 23404a2e..383c1136 100644 --- a/frontend/__tests__/errorHandler.test.ts +++ b/frontend/__tests__/errorHandler.test.ts @@ -143,6 +143,12 @@ describe("getContractErrorMessage()", () => { expect(err.details).toEqual({ contractMessage: "Unauthorized caller" }); }); + it("maps stale swap path contract errors", () => { + const err = getContractErrorMessage(29); + expect(err.code).toBe("CONTRACT_STALE_PATH"); + expect(err.message).toBe("Swap path is stale or has insufficient liquidity."); + }); + it("maps unknown contract code to GEN_UNKNOWN", () => { const err = getContractErrorMessage(999); expect(err.code).toBe("GEN_UNKNOWN"); diff --git a/frontend/lib/errorHandler.ts b/frontend/lib/errorHandler.ts index 8fbb5a24..d51c200b 100644 --- a/frontend/lib/errorHandler.ts +++ b/frontend/lib/errorHandler.ts @@ -145,7 +145,7 @@ export async function parseApiError( } /** - * Map a numeric Soroban ContractError code (1–17) to a StandardError. + * Map a numeric Soroban ContractError code (1–29) to a StandardError. * * Useful after catching errors from `@stellar/stellar-sdk` contract * invocations that surface the numeric code. diff --git a/frontend/lib/handleError.ts b/frontend/lib/handleError.ts index eff5fbc4..0c4eb683 100644 --- a/frontend/lib/handleError.ts +++ b/frontend/lib/handleError.ts @@ -481,7 +481,7 @@ export function handleError( } /** - * Describe a numeric Soroban ContractError (1-17) for the user. + * Describe a numeric Soroban ContractError (1-29) for the user. * * @param contractErrorCode - The numeric ContractError value. * @param rawMessage - Optional raw message from the invocation. diff --git a/shared/errorCodes.js b/shared/errorCodes.js index fb8d8fff..29b3b39c 100644 --- a/shared/errorCodes.js +++ b/shared/errorCodes.js @@ -339,6 +339,66 @@ const ERROR_CODES = { httpStatus: 502, message: "Token transfer could not be verified on-chain.", }, + CONTRACT_INDEX_FULL: { + code: "CONTRACT_INDEX_FULL", + httpStatus: 409, + message: "Recipient index is full.", + }, + CONTRACT_EMERGENCY_WITHDRAWAL_NOT_READY: { + code: "CONTRACT_EMERGENCY_WITHDRAWAL_NOT_READY", + httpStatus: 409, + message: "Emergency withdrawal is not ready yet.", + }, + CONTRACT_NOT_ADMIN_SIGNER: { + code: "CONTRACT_NOT_ADMIN_SIGNER", + httpStatus: 403, + message: "Caller is not an authorized admin signer.", + }, + CONTRACT_INVALID_PATH: { + code: "CONTRACT_INVALID_PATH", + httpStatus: 400, + message: "Swap path is invalid.", + }, + CONTRACT_SLIPPAGE_EXCEEDED: { + code: "CONTRACT_SLIPPAGE_EXCEEDED", + httpStatus: 400, + message: "Swap slippage limit was exceeded.", + }, + CONTRACT_EXCESSIVE_AMOUNT_IN: { + code: "CONTRACT_EXCESSIVE_AMOUNT_IN", + httpStatus: 400, + message: "Required swap input exceeds the maximum allowed amount.", + }, + CONTRACT_INVALID_FEE_BPS: { + code: "CONTRACT_INVALID_FEE_BPS", + httpStatus: 400, + message: "Swap fee exceeds the maximum allowed basis points.", + }, + CONTRACT_PROPOSAL_NOT_FOUND: { + code: "CONTRACT_PROPOSAL_NOT_FOUND", + httpStatus: 404, + message: "Admin action proposal was not found.", + }, + CONTRACT_PROPOSAL_ALREADY_EXECUTED: { + code: "CONTRACT_PROPOSAL_ALREADY_EXECUTED", + httpStatus: 409, + message: "Admin action proposal has already been executed.", + }, + CONTRACT_RELEASE_LEDGER_NOT_REACHED: { + code: "CONTRACT_RELEASE_LEDGER_NOT_REACHED", + httpStatus: 409, + message: "Release ledger has not been reached.", + }, + CONTRACT_REENTRANT_CALL: { + code: "CONTRACT_REENTRANT_CALL", + httpStatus: 409, + message: "Reentrant contract call was blocked.", + }, + CONTRACT_STALE_PATH: { + code: "CONTRACT_STALE_PATH", + httpStatus: 400, + message: "Swap path is stale or has insufficient liquidity.", + }, // ── Payment / transaction errors ───────────────────────────────────────── PAY_BUILD_FAILED: { @@ -480,7 +540,7 @@ const ERROR_CODES = { // ─── Contract error code → error code mapping ────────────────────────────── /** - * Maps the numeric ContractError codes (1–17) from the Soroban contract + * Maps the numeric ContractError codes (1–29) from the Soroban contract * to their corresponding ERROR_CODES keys. * * @type {Record} @@ -503,6 +563,18 @@ const CONTRACT_ERROR_MAP = { 15: "CONTRACT_DUPLICATE_SIGNER", 16: "CONTRACT_PROPOSAL_EXPIRED", 17: "CONTRACT_TRANSFER_FAILED", + 18: "CONTRACT_INDEX_FULL", + 19: "CONTRACT_EMERGENCY_WITHDRAWAL_NOT_READY", + 20: "CONTRACT_NOT_ADMIN_SIGNER", + 21: "CONTRACT_INVALID_PATH", + 22: "CONTRACT_SLIPPAGE_EXCEEDED", + 23: "CONTRACT_EXCESSIVE_AMOUNT_IN", + 24: "CONTRACT_INVALID_FEE_BPS", + 25: "CONTRACT_PROPOSAL_NOT_FOUND", + 26: "CONTRACT_PROPOSAL_ALREADY_EXECUTED", + 27: "CONTRACT_RELEASE_LEDGER_NOT_REACHED", + 28: "CONTRACT_REENTRANT_CALL", + 29: "CONTRACT_STALE_PATH", }; // ─── Correlation ID ──────────────────────────────────────────────────────── @@ -616,7 +688,7 @@ function formatErrorResponse(code, details, overrides = {}) { /** * Map a numeric contract error code to the canonical error code key. * - * @param {number} contractErrCode - The numeric ContractError value (1–17) + * @param {number} contractErrCode - The numeric ContractError value (1–29) * @returns {string} Error code key (e.g. "CONTRACT_UNAUTHORIZED") */ function getContractErrorCode(contractErrCode) {