diff --git a/contracts/common/src/split.rs b/contracts/common/src/split.rs index 74babe0b..97999372 100644 --- a/contracts/common/src/split.rs +++ b/contracts/common/src/split.rs @@ -207,39 +207,6 @@ pub fn sort_remainders_desc(order: &mut Vec<(u32, i128, Address)>) { } } - #[cfg(test)] - mod tests { - use super::*; - use soroban_sdk::{contract, contractimpl}; - - #[contract] - struct SplitTestContract; - - #[contractimpl] - impl SplitTestContract {} - - #[test] - fn compute_split_enforces_recipient_limit() { - let env = Env::default(); - let contract_id = env.register(SplitTestContract, ()); - let mut recipients = Vec::new(&env); - for _ in 0..MAX_RECIPIENTS { - recipients.push_back((Address::generate(&env), 500u32)); - } - - let payouts = env.as_contract(&contract_id, || { - compute_split(&env, 1_000, 0, &recipients).unwrap() - }); - assert_eq!(payouts.shares.len(), MAX_RECIPIENTS); - - recipients.push_back((Address::generate(&env), 0)); - let result = env.as_contract(&contract_id, || { - compute_split(&env, 1_000, 0, &recipients) - }); - assert!(matches!(result, Err(SplitError::TooManyRecipients))); - } - } - // Repeatedly move the largest remaining element to the end of the array, // shrinking the heap until the array is sorted ascending by `less`. let mut end = n; @@ -252,3 +219,36 @@ pub fn sort_remainders_desc(order: &mut Vec<(u32, i128, Address)>) { sift_down_remainder_order(order, 0, end); } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{contract, contractimpl, testutils::Address as _}; + + #[contract] + struct SplitTestContract; + + #[contractimpl] + impl SplitTestContract {} + + #[test] + fn compute_split_enforces_recipient_limit() { + let env = Env::default(); + let contract_id = env.register(SplitTestContract, ()); + let mut recipients = Vec::new(&env); + for _ in 0..MAX_RECIPIENTS { + recipients.push_back((Address::generate(&env), 500u32)); + } + + let payouts = env.as_contract(&contract_id, || { + compute_split(&env, 1_000, 0, &recipients).unwrap() + }); + assert_eq!(payouts.shares.len(), MAX_RECIPIENTS); + + recipients.push_back((Address::generate(&env), 0)); + let result = env.as_contract(&contract_id, || { + compute_split(&env, 1_000, 0, &recipients) + }); + assert!(matches!(result, Err(SplitError::TooManyRecipients))); + } +} diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index a5ddaf32..87a014a7 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -3009,10 +3009,10 @@ fn test_extend_deadline_returns_contribution_not_found_when_archived() { fn test_release_with_duplicate_recipient_addresses() { let env = Env::default(); env.mock_all_auths(); - let (_, _admin, treasury, client) = setup(&env); + let (_, _admin, _treasury, client) = setup(&env); let token_admin = Address::generate(&env); - let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &10_000_000_000i128); @@ -3027,14 +3027,12 @@ fn test_release_with_duplicate_recipient_addresses() { &None, ); - // Same address twice with 5000 bps each — must produce two separate - // transfers summing to the full 10000-bps entitlement. + // compute_split rejects duplicate addresses — `InvalidSplit` is the correct + // outcome because allowing it would silently double-pay alice at the expense + // of every other recipient's share, which is never intentional. let recipients = vec![&env, (alice.clone(), 5_000u32), (alice.clone(), 5_000u32)]; - client.release(&200u64, &recipients); - - let distributable = 950_0000000i128; // after 5% fee - assert_eq!(token_client.balance(&alice), distributable); - assert_eq!(token_client.balance(&treasury), 50_0000000i128); + let err = client.try_release(&200u64, &recipients); + assert_eq!(err, Err(Ok(Error::InvalidSplit))); } #[test] diff --git a/contracts/maintenance-pool/src/lib.rs b/contracts/maintenance-pool/src/lib.rs index 1b6f1771..a17cbe54 100644 --- a/contracts/maintenance-pool/src/lib.rs +++ b/contracts/maintenance-pool/src/lib.rs @@ -274,41 +274,81 @@ mod contract { return Err(Error::TokenMismatch); } - if pool.deposit_count >= MAX_DEPOSITS { + let mut existing_index = None; + let search_limit = pool.deposit_count.min(MAX_DEPOSITS); + for i in 0..search_limit { + let dkey = DataKey::Deposit(pool_id, i); + if let Some(deposit) = env.storage().persistent().get::<_, Deposit>(&dkey) { + if deposit.sponsor == sponsor { + existing_index = Some(i); + break; + } + } + } + + if existing_index.is_none() && pool.deposit_count >= MAX_DEPOSITS { return Err(Error::TooManyDeposits); } let token_client = token::Client::new(&env, &token); - token_client.transfer(&sponsor, env.current_contract_address(), &amount); + let actual_received = mergefi_common::measure_transfer_delta( + &env, + &token, + &env.current_contract_address(), + || { + token_client.transfer(&sponsor, env.current_contract_address(), &amount); + }, + ); + + if actual_received <= 0 { + return Err(Error::InvalidAmount); + } pool.balance = pool .balance - .checked_add(amount) + .checked_add(actual_received) .ok_or(Error::ArithmeticOverflow)?; pool.total_deposited = pool .total_deposited - .checked_add(amount) + .checked_add(actual_received) .ok_or(Error::ArithmeticOverflow)?; - let index = pool.deposit_count; - pool.deposit_count = pool - .deposit_count - .checked_add(1) - .ok_or(Error::DepositCountOverflow)?; + + if let Some(index) = existing_index { + let dkey = DataKey::Deposit(pool_id, index); + let mut deposit: Deposit = env + .storage() + .persistent() + .get(&dkey) + .ok_or(Error::DepositNotFound)?; + deposit.amount = deposit + .amount + .checked_add(actual_received) + .ok_or(Error::ArithmeticOverflow)?; + deposit.timestamp = env.ledger().timestamp(); + env.storage().persistent().set(&dkey, &deposit); + extend_ttl(&env, &dkey); + } else { + let index = pool.deposit_count; + pool.deposit_count = pool + .deposit_count + .checked_add(1) + .ok_or(Error::DepositCountOverflow)?; + + let dkey = DataKey::Deposit(pool_id, index); + env.storage().persistent().set( + &dkey, + &Deposit { + sponsor: sponsor.clone(), + amount: actual_received, + timestamp: env.ledger().timestamp(), + }, + ); + extend_ttl(&env, &dkey); + } env.storage().persistent().set(&pkey, &pool); extend_ttl(&env, &pkey); - let dkey = DataKey::Deposit(pool_id, index); - env.storage().persistent().set( - &dkey, - &Deposit { - sponsor: sponsor.clone(), - amount, - timestamp: env.ledger().timestamp(), - }, - ); - extend_ttl(&env, &dkey); - // Refresh every prior deposit sub-record so older entries don't // archive while the pool stays active with recurring new deposits. // The newly-written record at `index` was already extended above; @@ -321,7 +361,7 @@ mod contract { pool_id, sponsor, token, - amount, + amount: actual_received, balance: pool.balance, } .publish(&env); diff --git a/contracts/maintenance-pool/src/test.rs b/contracts/maintenance-pool/src/test.rs index a3bcef9a..155ee2c9 100644 --- a/contracts/maintenance-pool/src/test.rs +++ b/contracts/maintenance-pool/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, + testutils::{Address as _, Events as _, Ledger as _}, token, vec, Address, Env, Event, }; @@ -246,7 +246,8 @@ fn test_get_pool_and_withdraw_reject_nonexistent_pool() { let get_err = client.try_get_pool(&404u64); assert_eq!(get_err, Err(Ok(Error::PoolNotFound))); - let withdraw_err = client.try_withdraw(&404u64, &maintainer, &1i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let withdraw_err = client.try_withdraw(&404u64, &recipients, &1i128); assert_eq!(withdraw_err, Err(Ok(Error::PoolNotFound))); } @@ -360,9 +361,10 @@ fn test_withdraw_rejects_invalid_amount() { client.deposit(&5u64, &sponsor, &token_addr, &100_0000000i128); let maintainer = Address::generate(&env); - let err = client.try_withdraw(&5u64, &maintainer, &0i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let err = client.try_withdraw(&5u64, &recipients, &0i128); assert_eq!(err, Err(Ok(Error::InvalidAmount))); - let err = client.try_withdraw(&5u64, &maintainer, &-1i128); + let err = client.try_withdraw(&5u64, &recipients, &-1i128); assert_eq!(err, Err(Ok(Error::InvalidAmount))); } @@ -379,7 +381,8 @@ fn test_withdraw_rejects_insufficient_balance() { client.deposit(&3u64, &sponsor, &token_addr, &100_0000000i128); let maintainer = Address::generate(&env); - let err = client.try_withdraw(&3u64, &maintainer, &200_0000000i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let err = client.try_withdraw(&3u64, &recipients, &200_0000000i128); assert_eq!(err, Err(Ok(Error::InsufficientBalance))); } @@ -449,7 +452,8 @@ fn test_withdraw_requires_admin_auth() { env.set_auths(&[]); let maintainer = Address::generate(&env); - let result = client.try_withdraw(&6u64, &maintainer, &50_0000000i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let result = client.try_withdraw(&6u64, &recipients, &50_0000000i128); assert!(result.is_err()); } @@ -504,13 +508,14 @@ fn test_withdraw_exact_balance_boundary() { client.deposit(&7u64, &sponsor, &token_addr, &500_0000000i128); let maintainer = Address::generate(&env); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; // One more than the exact balance must be rejected. - let err = client.try_withdraw(&7u64, &maintainer, &500_0000001i128); + let err = client.try_withdraw(&7u64, &recipients, &500_0000001i128); assert_eq!(err, Err(Ok(Error::InsufficientBalance))); // Exactly the pool's balance must succeed and drain it to zero. - client.withdraw(&7u64, &vec![&env, (maintainer.clone(), 10_000u32)], &500_0000000i128); + client.withdraw(&7u64, &recipients, &500_0000000i128); let pool = client.get_pool(&7u64); assert_eq!(pool.balance, 0i128); assert_eq!(pool.total_withdrawn, 500_0000000i128); @@ -821,7 +826,8 @@ fn test_recover_withdraw_frozen_before_recoverable_after() { // Simulate lost admin: clear auths env.set_auths(&[]); let maintainer = Address::generate(&env); - let err = client.try_withdraw(&42u64, &maintainer, &1_000_000_000i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let err = client.try_withdraw(&42u64, &recipients, &1_000_000_000i128); assert!(err.is_err()); // Recovery installs a new admin via the contract entrypoint. @@ -830,7 +836,7 @@ fn test_recover_withdraw_frozen_before_recoverable_after() { client.recover_admin(&new_admin); // New admin withdraws successfully (mocked auth enables it) env.mock_all_auths(); - client.withdraw(&42u64, &vec![&env, (maintainer.clone(), 10_000u32)], &1_000_000_000i128); + client.withdraw(&42u64, &recipients, &1_000_000_000i128); assert_eq!(token_client.balance(&maintainer), 900_000_000i128); // after 10% fee } @@ -853,7 +859,8 @@ fn test_pause_blocks_deposit_and_withdraw_but_allows_reclaim_after_inactivity() let deposit_err = client.try_deposit(&78u64, &sponsor, &token_addr, &1_000_000_000i128); assert_eq!(deposit_err, Err(Ok(Error::ContractPaused))); - let withdraw_err = client.try_withdraw(&77u64, &maintainer, &1_000_000_000i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let withdraw_err = client.try_withdraw(&77u64, &recipients, &1_000_000_000i128); assert_eq!(withdraw_err, Err(Ok(Error::ContractPaused))); env.ledger().set_timestamp(INACTIVITY_WINDOW + 1); @@ -1001,20 +1008,77 @@ fn test_deposit_rejects_beyond_max_deposits() { let token_admin = Address::generate(&env); let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); - let sponsor = Address::generate(&env); - asset_client.mint(&sponsor, &1_000_000i128); for _ in 0..crate::MAX_DEPOSITS { + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1i128); client.deposit(&11u64, &sponsor, &token_addr, &1i128); } assert_eq!(client.get_pool(&11u64).deposit_count, crate::MAX_DEPOSITS); - let err = client.try_deposit(&11u64, &sponsor, &token_addr, &1i128); + let one_too_many = Address::generate(&env); + asset_client.mint(&one_too_many, &1i128); + let err = client.try_deposit(&11u64, &one_too_many, &token_addr, &1i128); assert_eq!(err, Err(Ok(Error::TooManyDeposits))); assert_eq!(client.get_pool(&11u64).deposit_count, crate::MAX_DEPOSITS); // Other pools are unaffected. - client.deposit(&12u64, &sponsor, &token_addr, &1i128); + client.deposit(&12u64, &one_too_many, &token_addr, &1i128); +} + +#[test] +fn test_existing_sponsor_can_top_up_when_at_max_deposits_cap() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + + let first_sponsor = Address::generate(&env); + asset_client.mint(&first_sponsor, &1_000i128); + client.deposit(&15u64, &first_sponsor, &token_addr, &100i128); + + for _ in 1..crate::MAX_DEPOSITS { + let extra = Address::generate(&env); + asset_client.mint(&extra, &10i128); + client.deposit(&15u64, &extra, &token_addr, &10i128); + } + assert_eq!(client.get_pool(&15u64).deposit_count, crate::MAX_DEPOSITS); + + // Existing sponsor topping up must succeed and not increase deposit_count + client.deposit(&15u64, &first_sponsor, &token_addr, &200i128); + assert_eq!(client.get_pool(&15u64).deposit_count, crate::MAX_DEPOSITS); + assert_eq!(client.get_deposit(&15u64, &0u32).amount, 300i128); + assert_eq!(client.get_pool(&15u64).balance, 100i128 + 49 * 10i128 + 200i128); +} + +#[test] +fn test_deposit_aggregates_multiple_deposits_from_same_sponsor() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000i128); + + client.deposit(&16u64, &sponsor, &token_addr, &100i128); + let pool = client.get_pool(&16u64); + assert_eq!(pool.deposit_count, 1); + assert_eq!(pool.balance, 100i128); + assert_eq!(pool.total_deposited, 100i128); + + client.deposit(&16u64, &sponsor, &token_addr, &250i128); + let pool = client.get_pool(&16u64); + assert_eq!(pool.deposit_count, 1); + assert_eq!(pool.balance, 350i128); + assert_eq!(pool.total_deposited, 350i128); + + let deposit = client.get_deposit(&16u64, &0u32); + assert_eq!(deposit.amount, 350i128); + assert_eq!(deposit.sponsor, sponsor); } // ─── upgrade (issue #246) ──────────────────────────────────────────────────── @@ -1080,9 +1144,18 @@ fn test_migrate_requires_admin_and_matching_source_version() { env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); + // Simulate a mid-upgrade state: stored version is 1 (post-upgrade WASM + // hasn't called migrate yet). This lets us test the version-mismatch + // guard without hitting the idempotent "already at CONTRACT_VERSION" path. + env.as_contract(&client.address, || { + env.storage().instance().set(&DataKey::Version, &1u32); + }); + + // No auth: admin's require_auth must fire. env.set_auths(&[]); assert!(client.try_migrate(&1u32).is_err()); + // Wrong from_version (0 ≠ stored 1): must be rejected. env.mock_all_auths(); assert_eq!( client.try_migrate(&0u32), @@ -1299,21 +1372,56 @@ fn test_reclaim_deposit_requires_sponsor_auth() { assert!(err.is_err(), "reclaim_deposit must require the sponsor's own authorization"); } +// ── Regression #479: reclaim_deposit second call ───────────────────────────── + +#[test] +fn test_reclaim_deposit_second_call_rejected_with_already_reclaimed() { + // Regression for #479: reclaim_deposit must mark the deposit as reclaimed + // so a second call on the same index is rejected rather than draining other + // sponsors' funds. + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000_0000000i128); + + client.deposit(&500u64, &sponsor, &token_addr, &100_0000000i128); + + env.ledger().set_timestamp(INACTIVITY_WINDOW + 1); + + // First reclaim must succeed and transfer the deposit amount. + client.reclaim_deposit(&500u64, &0u32, &sponsor); + assert_eq!(token_client.balance(&sponsor), 1_000_0000000i128); + + // Second reclaim on the same index must be rejected. + let err = client.try_reclaim_deposit(&500u64, &0u32, &sponsor); + assert_eq!(err, Err(Ok(Error::AlreadyReclaimed))); +} + // ── Regression #472 (closed #308) ───────────────────────────────────────── // initialize must reject treasury == contract address with InvalidTreasury. // No mock_all_auths() — admin + oracle auth is meaningful to the test. #[test] fn test_initialize_rejects_treasury_equal_to_contract_address() { let env = Env::default(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let contract_id = env.register(MaintenancePoolContract, ()); + let client = MaintenancePoolContractClient::new(&env, &contract_id); + + env.mock_all_auths(); + // Pass the contract's own address as treasury — must be rejected. + let err = client.try_initialize(&admin, &oracle, &contract_id, &1_000u32, &None); + assert_eq!(err, Err(Ok(Error::InvalidTreasury))); +} + // ── Regression #329: sweep must reject the contract's own address as recipient ─ #[test] fn test_sweep_rejects_contract_as_recipient() { -#[test] -fn test_reclaim_deposit_second_call_rejected_with_already_reclaimed() { - // Regression for #479: reclaim_deposit must mark the deposit as reclaimed - // so a second call on the same index is rejected rather than draining other - // sponsors' funds. let env = Env::default(); env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); @@ -1378,24 +1486,24 @@ fn test_set_admin_requires_new_admin_auth() { #[test] fn test_withdraw_rejects_zero_amount() { - asset_client.mint(&sponsor, &1_000_0000000i128); - - client.deposit(&500u64, &sponsor, &token_addr, &100_0000000i128); - - env.ledger().set_timestamp(INACTIVITY_WINDOW + 1); + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); - // First reclaim must succeed and transfer the deposit amount. - client.reclaim_deposit(&500u64, &0u32, &sponsor); - assert_eq!(token_client.balance(&sponsor), 1_000_0000000i128); + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &500_0000000i128); + client.deposit(&401u64, &sponsor, &token_addr, &500_0000000i128); - // Second reclaim on the same index must be rejected. - let err = client.try_reclaim_deposit(&500u64, &0u32, &sponsor); - assert_eq!(err, Err(Ok(Error::AlreadyReclaimed))); + let maintainer = Address::generate(&env); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let err = client.try_withdraw(&401u64, &recipients, &0i128); + assert_eq!(err, Err(Ok(Error::InvalidAmount))); } #[test] -fn test_keep_alive_succeeds_on_existing_pool() { - // Regression for #476: keep_alive must not error on a valid pool. +fn test_withdraw_rejects_negative_amount() { let env = Env::default(); env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); @@ -1404,15 +1512,22 @@ fn test_keep_alive_succeeds_on_existing_pool() { let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &500_0000000i128); - client.deposit(&401u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&402u64, &sponsor, &token_addr, &500_0000000i128); let maintainer = Address::generate(&env); - let err = client.try_withdraw(&401u64, &maintainer, &0i128); + let recipients = vec![&env, (maintainer.clone(), 10_000u32)]; + let err = client.try_withdraw(&402u64, &recipients, &-1i128); assert_eq!(err, Err(Ok(Error::InvalidAmount))); } #[test] -fn test_withdraw_rejects_negative_amount() { +fn test_keep_alive_succeeds_on_existing_pool() { + // Regression for #476: keep_alive must not error on a valid pool. + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); let (token_addr, asset_client, _) = create_token(&env, &token_admin); let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &100_0000000i128); @@ -1429,15 +1544,8 @@ fn test_keep_alive_fails_on_nonexistent_pool() { env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); - let token_admin = Address::generate(&env); - let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); - let sponsor = Address::generate(&env); - asset_client.mint(&sponsor, &500_0000000i128); - client.deposit(&402u64, &sponsor, &token_addr, &500_0000000i128); - - let maintainer = Address::generate(&env); - let err = client.try_withdraw(&402u64, &maintainer, &-1i128); - assert_eq!(err, Err(Ok(Error::InvalidAmount))); + let err = client.try_keep_alive(&9999u64); + assert_eq!(err, Err(Ok(Error::PoolNotFound))); } // ── Regression #341: initialize must reject treasury == contract address ────── @@ -1451,11 +1559,6 @@ fn test_initialize_rejects_treasury_as_contract_address() { let contract_id = env.register(MaintenancePoolContract, ()); let client = MaintenancePoolContractClient::new(&env, &contract_id); - env.mock_all_auths(); - // Pass the contract's own address as treasury — must be rejected. - let err = client.try_initialize(&admin, &oracle, &contract_id, &1_000u32, &None); - assert_eq!(err, Err(Ok(Error::InvalidTreasury))); -} // treasury == contract_id should return InvalidTreasury. let err = client.try_initialize(&admin, &oracle, &contract_id, &1_000u32, &None); assert_eq!(err, Err(Ok(Error::InvalidTreasury))); @@ -1477,3 +1580,106 @@ fn test_initialize_rejects_max_fee_bps() { let err = client.try_keep_alive(&9999u64); assert_eq!(err, Err(Ok(Error::PoolNotFound))); } + +#[contract] +pub struct MockFeeOnTransferToken; + +#[contractimpl] +impl MockFeeOnTransferToken { + pub fn balance(env: Env, id: Address) -> i128 { + let bal_key = soroban_sdk::Symbol::new(&env, "bal"); + env.storage().persistent().get(&(bal_key, id)).unwrap_or(0) + } + + pub fn mint(env: Env, to: Address, amount: i128) { + let bal_key = soroban_sdk::Symbol::new(&env, "bal"); + let balance: i128 = env + .storage() + .persistent() + .get(&(bal_key.clone(), to.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&(bal_key, to), &(balance + amount)); + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + let fee_rate_key = soroban_sdk::Symbol::new(&env, "fee_rate"); + let fee_rate: i128 = env.storage().instance().get(&fee_rate_key).unwrap_or(10); // default 10% + let fee = (amount * fee_rate) / 100; + let received = amount - fee; + + let from_key = soroban_sdk::Symbol::new(&env, "bal"); + let to_key = soroban_sdk::Symbol::new(&env, "bal"); + + let from_bal: i128 = env + .storage() + .persistent() + .get(&(from_key.clone(), from.clone())) + .unwrap_or(0); + let to_bal: i128 = env + .storage() + .persistent() + .get(&(to_key.clone(), to.clone())) + .unwrap_or(0); + + env.storage() + .persistent() + .set(&(from_key, from), &(from_bal - amount)); + if received > 0 { + env.storage() + .persistent() + .set(&(to_key, to), &(to_bal + received)); + } + } + + pub fn set_fee_rate(env: Env, fee_rate: i128) { + let fee_rate_key = soroban_sdk::Symbol::new(&env, "fee_rate"); + env.storage().instance().set(&fee_rate_key, &fee_rate); + } +} + +#[test] +fn test_deposit_credits_actual_received_with_fee_on_transfer_token() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_addr = env.register(MockFeeOnTransferToken, ()); + let token_client = MockFeeOnTransferTokenClient::new(&env, &token_addr); + + let sponsor = Address::generate(&env); + token_client.mint(&sponsor, &1_000i128); + + // Default fee rate is 10%, so depositing 1000 sends 1000 but contract receives 900. + client.deposit(&100u64, &sponsor, &token_addr, &1_000i128); + + // Bookkeeping must record actual_received (900), not requested amount (1000). + let pool = client.get_pool(&100u64); + assert_eq!(pool.balance, 900i128); + assert_eq!(pool.total_deposited, 900i128); + assert_eq!(token_client.balance(&client.address), 900i128); + + let deposit = client.get_deposit(&100u64, &0u32); + assert_eq!(deposit.amount, 900i128); + assert_eq!(deposit.sponsor, sponsor); +} + +#[test] +fn test_deposit_rejects_zero_received_with_fee_on_transfer_token() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_addr = env.register(MockFeeOnTransferToken, ()); + let token_client = MockFeeOnTransferTokenClient::new(&env, &token_addr); + + let sponsor = Address::generate(&env); + token_client.mint(&sponsor, &1_000i128); + + // Set 100% transfer fee -> 0 received + token_client.set_fee_rate(&100i128); + + let err = client.try_deposit(&101u64, &sponsor, &token_addr, &500i128); + assert_eq!(err, Err(Ok(Error::InvalidAmount))); +} diff --git a/docs/pause-circuit-breaker-design.md b/docs/pause-circuit-breaker-design.md index 3d4350b3..99e14a6c 100644 --- a/docs/pause-circuit-breaker-design.md +++ b/docs/pause-circuit-breaker-design.md @@ -47,15 +47,23 @@ pub enum DataKey { ### New Error Variant -Add a `Paused` error (used across all contracts): +Add a `ContractPaused` error across contracts. Because each contract maintains its own error enum with contract-specific variants, the assigned numeric code varies per contract: + +- **Escrow**: `ContractPaused = 19` ([`contracts/escrow/src/error.rs`](../contracts/escrow/src/error.rs)) +- **Milestones**: `ContractPaused = 17` ([`contracts/milestones/src/error.rs`](../contracts/milestones/src/error.rs)) +- **Maintenance Pool**: `ContractPaused = 12` ([`contracts/maintenance-pool/src/error.rs`](../contracts/maintenance-pool/src/error.rs)) ```rust +// Example: Escrow contract (contracts/escrow/src/error.rs) pub enum Error { // ... - ContractPaused = 30, // NEW + ContractPaused = 19, } ``` +> [!NOTE] +> Integrators and client tooling should consult [`scripts/error-codes.mjs`](../scripts/error-codes.mjs) (or the contract-specific `error.rs` files) as the canonical source of truth when decoding numeric Soroban error codes. + ### Pause/Unpause Functions Each contract implements: @@ -153,18 +161,15 @@ pub fn cancel_milestone(env: Env, milestone_id: u64) -> Result<(), Error> { ### Query Function -Add a getter to check pause status: +Add a public view getter to check pause status: ```rust -pub fn is_paused(env: Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) +pub fn is_paused_view(env: Env) -> bool { + is_paused(&env) } ``` -This lets clients check before attempting operations and provide UX feedback. +This lets clients check before attempting operations and provide UX feedback. Note that `is_paused_view(env: Env) -> bool` is the public view entrypoint exposed on the contract client, while `is_paused(env: &Env) -> bool` is the private internal helper used in method guards. ## Rationale: Why Release/Withdraw Can Be Blocked diff --git a/package.json b/package.json index dbfbc85f..0d64ebbc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "deploy": "node scripts/deploy.mjs", "invoke": "node scripts/invoke.mjs", - "test": "node --test scripts/*.test.mjs" + "test": "node --test scripts/*.test.mjs scripts/lib/*.test.mjs" }, "dependencies": { "@stellar/stellar-sdk": "^16.0.1" diff --git a/scripts/lib/submit.test.mjs b/scripts/lib/submit.test.mjs new file mode 100644 index 00000000..f20f1b3d --- /dev/null +++ b/scripts/lib/submit.test.mjs @@ -0,0 +1,270 @@ +// Tests for scripts/lib/submit.mjs. +// +// Uses plain JS objects as fake RPC servers — no real network calls. +// Covers all sendTransaction status branches (PENDING→SUCCESS, +// TRY_AGAIN_LATER retry, TRY_AGAIN_LATER exhaustion, DUPLICATE, ERROR) +// and all getTransaction outcomes (NOT_FOUND poll loop, SUCCESS, failure). +// +// Run with: node --test scripts/lib/submit.test.mjs +// or via: npm test (picks up scripts/lib/*.test.mjs via the glob) +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { submitAndWait } from "./submit.mjs"; + +// --------------------------------------------------------------------------- +// Minimal fake keypair — submit.mjs only calls kp via prepared.sign(kp). +// --------------------------------------------------------------------------- +const fakeKp = {}; + +// --------------------------------------------------------------------------- +// Helper: build a fake server from a sequence of sendTransaction responses +// and a sequence of getTransaction responses. +// --------------------------------------------------------------------------- +function makeServer({ sends, gets }) { + let sendIndex = 0; + let getIndex = 0; + return { + async prepareTransaction(tx) { + // Returns a fake prepared tx that tracks sign calls. + return { + _tx: tx, + _signed: false, + sign(_kp) { + this._signed = true; + }, + }; + }, + async sendTransaction(_prepared) { + if (sendIndex >= sends.length) throw new Error("Unexpected sendTransaction call"); + return sends[sendIndex++]; + }, + async getTransaction(_hash) { + if (getIndex >= gets.length) throw new Error("Unexpected getTransaction call"); + return gets[getIndex++]; + }, + }; +} + +// Fake tx object — submit.mjs treats it as opaque (just passes to prepareTransaction). +const fakeTx = { _name: "fakeTx" }; + +// Use zero delays for all tests so the suite runs instantly. +const noDelay = { retryDelayMs: 0, pollDelayMs: 0 }; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("submitAndWait — PENDING → SUCCESS (happy path)", () => { + test("prepares, signs, sends, polls and resolves with sendResult + getResult", async () => { + const server = makeServer({ + sends: [{ status: "PENDING", hash: "abc123" }], + gets: [{ status: "SUCCESS", resultMetaXdr: "xdr" }], + }); + + const result = await submitAndWait(server, fakeKp, fakeTx, noDelay); + + assert.equal(result.sendResult.status, "PENDING"); + assert.equal(result.sendResult.hash, "abc123"); + assert.equal(result.getResult.status, "SUCCESS"); + }); +}); + +describe("submitAndWait — NOT_FOUND polling loop", () => { + test("polls getTransaction until status leaves NOT_FOUND", async () => { + const server = makeServer({ + sends: [{ status: "PENDING", hash: "poll1" }], + gets: [ + { status: "NOT_FOUND" }, + { status: "NOT_FOUND" }, + { status: "SUCCESS", resultMetaXdr: "xdr" }, + ], + }); + + const result = await submitAndWait(server, fakeKp, fakeTx, noDelay); + assert.equal(result.getResult.status, "SUCCESS"); + }); +}); + +describe("submitAndWait — TRY_AGAIN_LATER retry", () => { + test("retries send on TRY_AGAIN_LATER then succeeds", async () => { + const warnMessages = []; + const originalWarn = console.warn; + console.warn = (...args) => warnMessages.push(args.join(" ")); + + try { + const server = makeServer({ + sends: [ + { status: "TRY_AGAIN_LATER", hash: "t1" }, + { status: "TRY_AGAIN_LATER", hash: "t1" }, + { status: "PENDING", hash: "t1" }, + ], + gets: [{ status: "SUCCESS", resultMetaXdr: "xdr" }], + }); + + const result = await submitAndWait(server, fakeKp, fakeTx, { + maxSendRetries: 3, + ...noDelay, + }); + + assert.equal(result.sendResult.status, "PENDING"); + assert.equal(result.getResult.status, "SUCCESS"); + // Should have warned twice (once per TRY_AGAIN_LATER). + assert.equal(warnMessages.length, 2); + assert.match(warnMessages[0], /TRY_AGAIN_LATER/); + } finally { + console.warn = originalWarn; + } + }); + + test("throws after maxSendRetries is exhausted", async () => { + // Suppress the console.warn noise in this test. + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const server = makeServer({ + // maxSendRetries defaults to 5, so we need 6 TRY_AGAIN_LATER responses. + sends: Array(6).fill({ status: "TRY_AGAIN_LATER", hash: "tx" }), + gets: [], + }); + + await assert.rejects( + () => submitAndWait(server, fakeKp, fakeTx, { maxSendRetries: 5, ...noDelay }), + (err) => { + assert.match(err.message, /TRY_AGAIN_LATER/); + assert.match(err.message, /5 retries/); + return true; + }, + ); + } finally { + console.warn = originalWarn; + } + }); + + test("throws on the first attempt when maxSendRetries is 0", async () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const server = makeServer({ + sends: [{ status: "TRY_AGAIN_LATER", hash: "tx" }], + gets: [], + }); + + await assert.rejects( + () => submitAndWait(server, fakeKp, fakeTx, { maxSendRetries: 0, ...noDelay }), + /TRY_AGAIN_LATER/, + ); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe("submitAndWait — DUPLICATE", () => { + test("skips straight to polling when server returns DUPLICATE", async () => { + const warnMessages = []; + const originalWarn = console.warn; + console.warn = (...args) => warnMessages.push(args.join(" ")); + + try { + const server = makeServer({ + sends: [{ status: "DUPLICATE", hash: "dup99" }], + gets: [{ status: "SUCCESS", resultMetaXdr: "xdr" }], + }); + + const result = await submitAndWait(server, fakeKp, fakeTx, noDelay); + + assert.equal(result.sendResult.status, "DUPLICATE"); + assert.equal(result.getResult.status, "SUCCESS"); + // Should have emitted exactly one warning mentioning the hash. + assert.equal(warnMessages.length, 1); + assert.match(warnMessages[0], /DUPLICATE/); + assert.match(warnMessages[0], /dup99/); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe("submitAndWait — ERROR on send", () => { + test("throws immediately without polling when sendTransaction returns ERROR", async () => { + const server = makeServer({ + sends: [ + { + status: "ERROR", + errorResult: { code: "txBAD_SEQ", message: "bad sequence number" }, + }, + ], + // gets is empty — should never be reached. + gets: [], + }); + + await assert.rejects( + () => submitAndWait(server, fakeKp, fakeTx, noDelay), + (err) => { + assert.match(err.message, /Send failed/); + assert.match(err.message, /txBAD_SEQ/); + return true; + }, + ); + }); +}); + +describe("submitAndWait — getTransaction non-SUCCESS terminal status", () => { + test("throws when getTransaction resolves with FAILED status", async () => { + const server = makeServer({ + sends: [{ status: "PENDING", hash: "fail1" }], + gets: [{ status: "FAILED", resultXdr: "errorXdr" }], + }); + + await assert.rejects( + () => submitAndWait(server, fakeKp, fakeTx, noDelay), + (err) => { + assert.match(err.message, /Tx failed/); + assert.match(err.message, /FAILED/); + return true; + }, + ); + }); + + test("throws when getTransaction resolves with unknown status", async () => { + const server = makeServer({ + sends: [{ status: "PENDING", hash: "unk1" }], + gets: [{ status: "UNKNOWN_STATUS" }], + }); + + await assert.rejects( + () => submitAndWait(server, fakeKp, fakeTx, noDelay), + /Tx failed/, + ); + }); +}); + +describe("submitAndWait — transaction is signed before sending", () => { + test("prepared.sign is called with the supplied keypair", async () => { + let receivedKp = null; + const server = { + async prepareTransaction(tx) { + return { + _tx: tx, + sign(kp) { + receivedKp = kp; + }, + }; + }, + async sendTransaction(_p) { + return { status: "PENDING", hash: "sig1" }; + }, + async getTransaction(_h) { + return { status: "SUCCESS", resultMetaXdr: "xdr" }; + }, + }; + + const myKp = { _name: "realKp" }; + await submitAndWait(server, myKp, fakeTx, noDelay); + assert.strictEqual(receivedKp, myKp); + }); +});