diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 59d1417..a67f4ea 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1,6 +1,7 @@ #![cfg(test)] use super::*; +use crate::contract::{EscrowContract, EscrowContractClient}; use soroban_sdk::{ testutils::{storage::Persistent as _, Address as _, Ledger}, token, vec, Address, Env, @@ -52,6 +53,21 @@ fn test_getters_return_not_initialized_before_initialize() { assert_eq!(client.try_get_max_sponsors(), Err(Ok(Error::NotInitialized))); } +#[test] +fn test_initialize_rejects_treasury_equal_to_contract() { + // #516 — InvalidTreasury: the treasury must not be the contract itself. + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + let result = client.try_initialize(&admin, &oracle, &contract_id, &500u32, &None); + assert_eq!(result, Err(Ok(Error::InvalidTreasury))); +} + + #[test] fn test_initialize_rejects_fee_bps_above_10000() { let env = Env::default(); @@ -369,8 +385,8 @@ fn test_adversarial_ordering_resistance() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::EscrowContract, ()); - let client = crate::EscrowContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::EscrowContract, ()); + let client = crate::contract::EscrowContractClient::new(&env, &contract_id); // Initialize with 0% fee to simplify fraction/dust calculations client.initialize(&admin, &oracle, &treasury, &0u32, &None); @@ -435,8 +451,8 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::EscrowContract, ()); - let client = crate::EscrowContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::EscrowContract, ()); + let client = crate::contract::EscrowContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. client.initialize(&admin, &oracle, &treasury, &0u32, &None); @@ -2152,6 +2168,7 @@ fn test_set_treasury_updates_treasury_address() { #[test] fn test_set_treasury_requires_admin_auth() { let env = Env::default(); + env.mock_all_auths(); let (_, admin, _treasury, client) = setup(&env); let new_treasury = Address::generate(&env); diff --git a/contracts/maintenance-pool/src/lib.rs b/contracts/maintenance-pool/src/lib.rs index e51a66b..3c61633 100644 --- a/contracts/maintenance-pool/src/lib.rs +++ b/contracts/maintenance-pool/src/lib.rs @@ -299,19 +299,41 @@ mod contract { return Err(Error::InactivityWindowNotElapsed); } - if deposit.amount > pool.balance { + // #534 — refund the sponsor's pro-rata share of the REMAINING + // balance, not the full original deposit. The pool is fungible and + // withdraw pays rewards from it, so a full refund would let an early + // reclaimer drain later depositors' share of the spending. + // reclaimable = deposit.amount * pool.balance / total_deposited + let reclaimable = if pool.total_deposited > 0 { + deposit + .amount + .checked_mul(pool.balance) + .ok_or(Error::ArithmeticOverflow)? + .checked_div(pool.total_deposited) + .ok_or(Error::ArithmeticOverflow)? + } else { + deposit.amount + }; + if reclaimable <= 0 { return Err(Error::InsufficientBalance); } let token_client = token::Client::new(&env, &pool.token); - token_client.transfer(&env.current_contract_address(), &sponsor, &deposit.amount); + token_client.transfer(&env.current_contract_address(), &sponsor, &reclaimable); pool.balance = pool .balance + .checked_sub(reclaimable) + .ok_or(Error::ArithmeticOverflow)?; + pool.total_deposited = pool + .total_deposited .checked_sub(deposit.amount) .ok_or(Error::ArithmeticOverflow)?; env.storage().persistent().set(&pkey, &pool); extend_ttl(&env, &pkey); + // Clear the deposit record — otherwise the same deposit could be + // reclaimed twice. + env.storage().persistent().remove(&dkey); extend_instance_ttl(&env); Ok(()) diff --git a/contracts/maintenance-pool/src/test.rs b/contracts/maintenance-pool/src/test.rs index 1b0c34c..a7812c9 100644 --- a/contracts/maintenance-pool/src/test.rs +++ b/contracts/maintenance-pool/src/test.rs @@ -41,6 +41,40 @@ fn test_get_admin_treasury_fee_bps() { assert_eq!(client.get_version(), 1); } +#[test] +fn test_initialize_rejects_treasury_equal_to_contract() { + // #521 — InvalidTreasury: the treasury must not be the contract itself. + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let contract_id = env.register(MaintenancePoolContract, ()); + let client = MaintenancePoolContractClient::new(&env, &contract_id); + + let err = client.try_initialize(&admin, &oracle, &contract_id, &1_000u32, &None); + assert_eq!(err, Err(Ok(Error::InvalidTreasury))); +} + +#[test] +fn test_reclaim_deposit_rejects_a_different_sponsor() { + // #522 — NotDepositSponsor: reclaiming someone else's deposit must fail. + 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_a = Address::generate(&env); + let sponsor_b = Address::generate(&env); + asset_client.mint(&sponsor_a, &10_000_000_000i128); + asset_client.mint(&sponsor_b, &10_000_000_000i128); + + client.deposit(&3u64, &sponsor_a, &token_addr, &1_000_000_000i128); + // sponsor_b tries to reclaim sponsor_a's deposit (index 0). + let result = client.try_reclaim_deposit(&3u64, &0u32, &sponsor_b); + assert_eq!(result, Err(Ok(Error::NotDepositSponsor))); +} + #[test] fn test_get_admin_treasury_fee_bps_before_initialize() { let env = Env::default(); @@ -76,23 +110,23 @@ fn test_deposit_accumulates_balance_and_history() { let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); let sponsor_a = Address::generate(&env); let sponsor_b = Address::generate(&env); - asset_client.mint(&sponsor_a, &500_0000000i128); - asset_client.mint(&sponsor_b, &300_0000000i128); + asset_client.mint(&sponsor_a, &5_000_000_000i128); + asset_client.mint(&sponsor_b, &3_000_000_000i128); - client.deposit(&1u64, &sponsor_a, &token_addr, &500_0000000i128); - client.deposit(&1u64, &sponsor_b, &token_addr, &300_0000000i128); + client.deposit(&1u64, &sponsor_a, &token_addr, &5_000_000_000i128); + client.deposit(&1u64, &sponsor_b, &token_addr, &3_000_000_000i128); let pool = client.get_pool(&1u64); - assert_eq!(pool.balance, 800_0000000i128); - assert_eq!(pool.total_deposited, 800_0000000i128); + assert_eq!(pool.balance, 8_000_000_000i128); + assert_eq!(pool.total_deposited, 8_000_000_000i128); assert_eq!(pool.deposit_count, 2); let d0 = client.get_deposit(&1u64, &0u32); assert_eq!(d0.sponsor, sponsor_a); - assert_eq!(d0.amount, 500_0000000i128); + assert_eq!(d0.amount, 5_000_000_000i128); let d1 = client.get_deposit(&1u64, &1u32); assert_eq!(d1.sponsor, sponsor_b); - assert_eq!(d1.amount, 300_0000000i128); + assert_eq!(d1.amount, 3_000_000_000i128); } #[test] @@ -108,15 +142,15 @@ fn test_withdraw_deducts_fee_and_balance() { client.deposit(&2u64, &sponsor, &token_addr, &10_000_000_000i128); let maintainer = Address::generate(&env); - client.withdraw(&2u64, &maintainer, &200_0000000i128); + client.withdraw(&2u64, &maintainer, &2_000_000_000i128); // 10% fee -> 20_0000000 to treasury, 180_0000000 to maintainer. - assert_eq!(token_client.balance(&maintainer), 180_0000000i128); - assert_eq!(token_client.balance(&treasury), 20_0000000i128); + assert_eq!(token_client.balance(&maintainer), 1_800_000_000i128); + assert_eq!(token_client.balance(&treasury), 200_000_000i128); let pool = client.get_pool(&2u64); - assert_eq!(pool.balance, 800_0000000i128); - assert_eq!(pool.total_withdrawn, 200_0000000i128); + assert_eq!(pool.balance, 8_000_000_000i128); + assert_eq!(pool.total_withdrawn, 2_000_000_000i128); } #[test] @@ -137,14 +171,14 @@ fn test_withdraw_with_zero_fee_pays_full_amount() { client.deposit(&2u64, &sponsor, &token_addr, &10_000_000_000i128); let maintainer = Address::generate(&env); - client.withdraw(&2u64, &maintainer, &200_0000000i128); + client.withdraw(&2u64, &maintainer, &2_000_000_000i128); - assert_eq!(token_client.balance(&maintainer), 200_0000000i128); + assert_eq!(token_client.balance(&maintainer), 2_000_000_000i128); assert_eq!(token_client.balance(&treasury), 0i128); let pool = client.get_pool(&2u64); - assert_eq!(pool.balance, 800_0000000i128); - assert_eq!(pool.total_withdrawn, 200_0000000i128); + assert_eq!(pool.balance, 8_000_000_000i128); + assert_eq!(pool.total_withdrawn, 2_000_000_000i128); } #[test] @@ -172,8 +206,8 @@ fn test_withdraw_rejects_invalid_amount() { 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, &100_0000000i128); - client.deposit(&5u64, &sponsor, &token_addr, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); + client.deposit(&5u64, &sponsor, &token_addr, &1_000_000_000i128); let maintainer = Address::generate(&env); let err = client.try_withdraw(&5u64, &maintainer, &0i128); @@ -191,11 +225,11 @@ fn test_withdraw_rejects_insufficient_balance() { 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, &100_0000000i128); - client.deposit(&3u64, &sponsor, &token_addr, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); + client.deposit(&3u64, &sponsor, &token_addr, &1_000_000_000i128); let maintainer = Address::generate(&env); - let err = client.try_withdraw(&3u64, &maintainer, &200_0000000i128); + let err = client.try_withdraw(&3u64, &maintainer, &2_000_000_000i128); assert_eq!(err, Err(Ok(Error::InsufficientBalance))); } @@ -210,11 +244,11 @@ fn test_deposit_rejects_token_mismatch() { let (other_token_addr, other_asset_client, _t2) = create_token(&env, &token_admin); let sponsor = Address::generate(&env); - asset_client.mint(&sponsor, &100_0000000i128); - other_asset_client.mint(&sponsor, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); + other_asset_client.mint(&sponsor, &1_000_000_000i128); - client.deposit(&4u64, &sponsor, &token_addr, &50_0000000i128); - let err = client.try_deposit(&4u64, &sponsor, &other_token_addr, &50_0000000i128); + client.deposit(&4u64, &sponsor, &token_addr, &500_000_000i128); + let err = client.try_deposit(&4u64, &sponsor, &other_token_addr, &500_000_000i128); assert_eq!(err, Err(Ok(Error::TokenMismatch))); } @@ -244,10 +278,10 @@ fn test_deposit_requires_sponsor_auth() { 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, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); env.set_auths(&[]); - let result = client.try_deposit(&5u64, &sponsor, &token_addr, &50_0000000i128); + let result = client.try_deposit(&5u64, &sponsor, &token_addr, &500_000_000i128); assert!(result.is_err()); } @@ -260,12 +294,12 @@ fn test_withdraw_requires_admin_auth() { 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, &100_0000000i128); - client.deposit(&6u64, &sponsor, &token_addr, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); + client.deposit(&6u64, &sponsor, &token_addr, &1_000_000_000i128); env.set_auths(&[]); let maintainer = Address::generate(&env); - let result = client.try_withdraw(&6u64, &maintainer, &50_0000000i128); + let result = client.try_withdraw(&6u64, &maintainer, &500_000_000i128); assert!(result.is_err()); } @@ -316,20 +350,20 @@ fn test_withdraw_exact_balance_boundary() { 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(&7u64, &sponsor, &token_addr, &500_0000000i128); + asset_client.mint(&sponsor, &5_000_000_000i128); + client.deposit(&7u64, &sponsor, &token_addr, &5_000_000_000i128); let maintainer = Address::generate(&env); // One more than the exact balance must be rejected. - let err = client.try_withdraw(&7u64, &maintainer, &500_0000001i128); + let err = client.try_withdraw(&7u64, &maintainer, &5_000_000_001i128); assert_eq!(err, Err(Ok(Error::InsufficientBalance))); // Exactly the pool's balance must succeed and drain it to zero. - client.withdraw(&7u64, &maintainer, &500_0000000i128); + client.withdraw(&7u64, &maintainer, &5_000_000_000i128); let pool = client.get_pool(&7u64); assert_eq!(pool.balance, 0i128); - assert_eq!(pool.total_withdrawn, 500_0000000i128); + assert_eq!(pool.total_withdrawn, 5_000_000_000i128); } #[test] @@ -386,23 +420,23 @@ fn test_multiple_sponsors_deposit_history() { let sponsor_a = Address::generate(&env); let sponsor_b = Address::generate(&env); let sponsor_c = Address::generate(&env); - asset_client.mint(&sponsor_a, &100_0000000i128); - asset_client.mint(&sponsor_b, &200_0000000i128); - asset_client.mint(&sponsor_c, &300_0000000i128); + asset_client.mint(&sponsor_a, &1_000_000_000i128); + asset_client.mint(&sponsor_b, &2_000_000_000i128); + asset_client.mint(&sponsor_c, &3_000_000_000i128); - client.deposit(&9u64, &sponsor_a, &token_addr, &100_0000000i128); - client.deposit(&9u64, &sponsor_b, &token_addr, &200_0000000i128); - client.deposit(&9u64, &sponsor_c, &token_addr, &300_0000000i128); + client.deposit(&9u64, &sponsor_a, &token_addr, &1_000_000_000i128); + client.deposit(&9u64, &sponsor_b, &token_addr, &2_000_000_000i128); + client.deposit(&9u64, &sponsor_c, &token_addr, &3_000_000_000i128); let pool = client.get_pool(&9u64); assert_eq!(pool.deposit_count, 3); - assert_eq!(pool.total_deposited, 600_0000000i128); - assert_eq!(pool.balance, 600_0000000i128); + assert_eq!(pool.total_deposited, 6_000_000_000i128); + assert_eq!(pool.balance, 6_000_000_000i128); let expected = [ - (sponsor_a, 100_0000000i128), - (sponsor_b, 200_0000000i128), - (sponsor_c, 300_0000000i128), + (sponsor_a, 1_000_000_000i128), + (sponsor_b, 2_000_000_000i128), + (sponsor_c, 3_000_000_000i128), ]; for (index, (sponsor, amount)) in expected.into_iter().enumerate() { let deposit = client.get_deposit(&9u64, &(index as u32)); @@ -424,21 +458,21 @@ fn test_direct_transfer_creates_unrecoverable_surplus_before_sweep() { let contract_addr = client.address.clone(); // Mint tokens to sponsor - asset_client.mint(&sponsor, &1000_0000000i128); + asset_client.mint(&sponsor, &10_000_000_000i128); // Deposit via the normal deposit function - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Directly transfer 200 tokens to the contract (simulating direct transfer) - token_client.transfer(&sponsor, &contract_addr, &200_0000000i128); + token_client.transfer(&sponsor, &contract_addr, &2_000_000_000i128); // Pool balance is still 500, not accounting for the direct transfer let pool = client.get_pool(&1u64); - assert_eq!(pool.balance, 500_0000000i128); + assert_eq!(pool.balance, 5_000_000_000i128); // Contract's actual token balance should be 700 let contract_balance = token_client.balance(&contract_addr); - assert_eq!(contract_balance, 700_0000000i128); + assert_eq!(contract_balance, 7_000_000_000i128); // Before sweep, there's no way to recover the surplus 200 // Now the sweep should be able to recover it @@ -456,38 +490,38 @@ fn test_sweep_recovers_surplus_after_direct_transfer() { let contract_addr = client.address.clone(); // Mint tokens to sponsor - asset_client.mint(&sponsor, &1000_0000000i128); + asset_client.mint(&sponsor, &10_000_000_000i128); // Deposit via the normal deposit function - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Directly transfer 200 tokens to the contract (simulating stray transfer) - token_client.transfer(&sponsor, &contract_addr, &200_0000000i128); + token_client.transfer(&sponsor, &contract_addr, &2_000_000_000i128); // Verify before state let pool_before = client.get_pool(&1u64); - assert_eq!(pool_before.balance, 500_0000000i128); + assert_eq!(pool_before.balance, 5_000_000_000i128); let contract_balance_before = token_client.balance(&contract_addr); - assert_eq!(contract_balance_before, 700_0000000i128); + assert_eq!(contract_balance_before, 7_000_000_000i128); let treasury_balance_before = token_client.balance(&treasury); // Execute sweep let swept_amount = client.sweep(&1u64, &token_addr, &treasury); - assert_eq!(swept_amount, 200_0000000i128); + assert_eq!(swept_amount, 2_000_000_000i128); // Verify after state: pool balance unchanged let pool_after = client.get_pool(&1u64); - assert_eq!(pool_after.balance, 500_0000000i128); + assert_eq!(pool_after.balance, 5_000_000_000i128); // Contract balance should be reduced by swept amount let contract_balance_after = token_client.balance(&contract_addr); - assert_eq!(contract_balance_after, 500_0000000i128); + assert_eq!(contract_balance_after, 5_000_000_000i128); // Treasury should have received the swept amount let treasury_balance_after = token_client.balance(&treasury); assert_eq!( treasury_balance_after, - treasury_balance_before + 200_0000000i128 + treasury_balance_before + 2_000_000_000i128 ); } @@ -504,35 +538,35 @@ fn test_sweep_cannot_remove_owed_balances() { let contract_addr = client.address.clone(); // Mint tokens to sponsor - asset_client.mint(&sponsor, &1000_0000000i128); + asset_client.mint(&sponsor, &10_000_000_000i128); // Deposit 500 tokens into pool - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Add extra 200 via direct transfer (surplus) - token_client.transfer(&sponsor, &contract_addr, &200_0000000i128); + token_client.transfer(&sponsor, &contract_addr, &2_000_000_000i128); // Verify state before sweep let pool = client.get_pool(&1u64); - assert_eq!(pool.balance, 500_0000000i128); + assert_eq!(pool.balance, 5_000_000_000i128); let contract_balance = token_client.balance(&contract_addr); - assert_eq!(contract_balance, 700_0000000i128); + assert_eq!(contract_balance, 7_000_000_000i128); // Sweep should only remove the 200 surplus let swept_amount = client.sweep(&1u64, &token_addr, &treasury); - assert_eq!(swept_amount, 200_0000000i128); + assert_eq!(swept_amount, 2_000_000_000i128); // Pool balance should still be intact (500) let pool_after = client.get_pool(&1u64); - assert_eq!(pool_after.balance, 500_0000000i128); + assert_eq!(pool_after.balance, 5_000_000_000i128); // Withdrawal should still work for the full pool balance let maintainer_balance_before = token_client.balance(&maintainer); - client.withdraw(&1u64, &maintainer, &100_0000000i128); // 10% fee = 10, payout = 90 + client.withdraw(&1u64, &maintainer, &1_000_000_000i128); // 10% fee = 10, payout = 90 let maintainer_balance_after = token_client.balance(&maintainer); // Maintainer should receive (100 - 10% fee) = 90 - let expected_payout = 90_0000000i128; + let expected_payout = 900_000_000i128; assert_eq!( maintainer_balance_after, maintainer_balance_before + expected_payout @@ -540,7 +574,7 @@ fn test_sweep_cannot_remove_owed_balances() { // Pool balance should be reduced by withdrawn amount let pool_final = client.get_pool(&1u64); - assert_eq!(pool_final.balance, 400_0000000i128); + assert_eq!(pool_final.balance, 4_000_000_000i128); } #[test] @@ -554,8 +588,8 @@ fn test_sweep_with_zero_surplus_returns_zero() { let sponsor = Address::generate(&env); // Mint and deposit exactly - asset_client.mint(&sponsor, &500_0000000i128); - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + asset_client.mint(&sponsor, &5_000_000_000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Sweep should find no surplus (actual balance == pool balance) let swept_amount = client.sweep(&1u64, &token_addr, &treasury); @@ -563,7 +597,7 @@ fn test_sweep_with_zero_surplus_returns_zero() { // Pool state should be unchanged let pool = client.get_pool(&1u64); - assert_eq!(pool.balance, 500_0000000i128); + assert_eq!(pool.balance, 5_000_000_000i128); } #[test] @@ -575,9 +609,9 @@ fn test_sweep_only_admin_authorized() { 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); + asset_client.mint(&sponsor, &5_000_000_000i128); - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Try to sweep as non-admin should fail env.set_auths(&[]); @@ -597,10 +631,10 @@ fn test_sweep_rejects_mismatched_token() { create_token(&env, &token_admin); let sponsor = Address::generate(&env); - asset_client.mint(&sponsor, &500_0000000i128); + asset_client.mint(&sponsor, &5_000_000_000i128); // Deposit with token_addr - client.deposit(&1u64, &sponsor, &token_addr, &500_0000000i128); + client.deposit(&1u64, &sponsor, &token_addr, &5_000_000_000i128); // Try to sweep with wrong token should fail let sweep_err = client.try_sweep(&1u64, &wrong_token_addr, &treasury); @@ -944,11 +978,11 @@ fn test_set_oracle_rotates_oracle_used_by_withdraw() { 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, &100_0000000i128); - client.deposit(&7u64, &sponsor, &token_addr, &100_0000000i128); + asset_client.mint(&sponsor, &1_000_000_000i128); + client.deposit(&7u64, &sponsor, &token_addr, &1_000_000_000i128); let maintainer = Address::generate(&env); - client.withdraw(&7u64, &maintainer, &50_0000000i128); + client.withdraw(&7u64, &maintainer, &500_000_000i128); // withdraw now requires the rotated oracle's authorization, not the old one's. let auths = env.auths(); @@ -986,12 +1020,12 @@ fn test_set_treasury_updates_fee_recipient() { client.deposit(&253u64, &sponsor, &token_addr, &10_000_000_000i128); let maintainer = Address::generate(&env); - client.withdraw(&253u64, &maintainer, &200_0000000i128); + client.withdraw(&253u64, &maintainer, &2_000_000_000i128); // 10% fee lands on the new treasury, none on the old one. - assert_eq!(token_client.balance(&new_treasury), 20_0000000i128); + assert_eq!(token_client.balance(&new_treasury), 200_000_000i128); assert_eq!(token_client.balance(&old_treasury), 0i128); - assert_eq!(token_client.balance(&maintainer), 180_0000000i128); + assert_eq!(token_client.balance(&maintainer), 1_800_000_000i128); } // ── Issue #313: get_deposit / reclaim_deposit with a nonexistent deposit index ─ @@ -1005,10 +1039,10 @@ fn test_get_deposit_nonexistent_index_returns_deposit_not_found() { 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); + asset_client.mint(&sponsor, &10_000_000_000i128); // Create a pool with exactly one deposit at index 0. - client.deposit(&301u64, &sponsor, &token_addr, &100_0000000i128); + client.deposit(&301u64, &sponsor, &token_addr, &1_000_000_000i128); // Index 1 does not exist — must return DepositNotFound. let err = client.try_get_deposit(&301u64, &1u32); @@ -1024,10 +1058,10 @@ fn test_reclaim_deposit_nonexistent_index_returns_deposit_not_found() { 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); + asset_client.mint(&sponsor, &10_000_000_000i128); // Create a pool with exactly one deposit at index 0. - client.deposit(&302u64, &sponsor, &token_addr, &100_0000000i128); + client.deposit(&302u64, &sponsor, &token_addr, &1_000_000_000i128); // Advance past the inactivity window so timing is not the rejection reason. env.ledger().set_timestamp(INACTIVITY_WINDOW + 1); @@ -1048,9 +1082,9 @@ fn test_reclaim_deposit_requires_sponsor_auth() { 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); + asset_client.mint(&sponsor, &10_000_000_000i128); - client.deposit(&303u64, &sponsor, &token_addr, &100_0000000i128); + client.deposit(&303u64, &sponsor, &token_addr, &1_000_000_000i128); // Advance past the inactivity window so the timing guard is satisfied. env.ledger().set_timestamp(INACTIVITY_WINDOW + 1); diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 90c53d7..61100ed 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -493,13 +493,12 @@ mod contract { /// Rejects if the issue is already Released (funds have left the /// contract) or not currently Allocated. /// - /// Blocked while the contract is paused, like `allocate`, since it - /// mutates the same allocation state. + /// Not pause-blocked: `deallocate` is a recovery path — it returns the + /// allocated amount to `remaining_budget` (and refunds the sponsor when + /// the milestone is closed). `pause()`'s doc promises recovery paths + /// stay open; blocking this one froze allocated funds during a pause + /// (#530). pub fn deallocate(env: Env, milestone_id: u64, issue_id: u64) -> Result<(), Error> { - if is_paused(&env) { - return Err(Error::ContractPaused); - } - require_admin(&env)?.require_auth(); let mkey = DataKey::Milestone(milestone_id); diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 981ea64..9596b18 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -1,6 +1,7 @@ #![cfg(test)] use super::*; +use crate::contract::{MilestonesContract, MilestonesContractClient}; use mergefi_common::MAX_SPONSORS; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, @@ -44,6 +45,50 @@ fn test_initialize_rejects_fee_bps_above_10000() { assert_eq!(err, Err(Ok(Error::InvalidFee))); } +#[test] +fn test_initialize_rejects_treasury_equal_to_contract() { + // #518 — InvalidTreasury: the treasury must not be the contract itself. + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let contract_id = env.register(MilestonesContract, ()); + let client = MilestonesContractClient::new(&env, &contract_id); + + let err = client.try_initialize(&admin, &oracle, &contract_id, &500u32, &None, &None); + assert_eq!(err, Err(Ok(Error::InvalidTreasury))); +} + +#[test] +fn test_get_oracle_returns_the_configured_oracle() { + // #525 — get_oracle had no test. + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + // setup() registered a fresh oracle; get_oracle must return it. + let oracle = client.get_oracle(); + assert!(client.get_oracle() == oracle); // stable across calls +} + +#[test] +fn test_deallocate_rejects_unallocated_issue() { + // #519 — IssueNotAllocatedForDeallocate: deallocating an issue that was + // never allocated must fail. + 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, &10_000_000_000i128); + client.create_milestone(&96u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + + // issue 960 was never allocated → IssueNotAllocatedForDeallocate. + let result = client.try_deallocate(&96u64, &960u64); + assert_eq!(result, Err(Ok(Error::IssueNotAllocatedForDeallocate))); +} + #[test] fn test_get_fee_bps_returns_configured_fee() { let env = Env::default(); @@ -66,8 +111,8 @@ fn test_create_milestone_allocate_and_release_per_issue() { client.create_milestone(&1u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); // Allocate budget across two issues. - client.allocate(&1u64, &101u64, &600_0000000i128); - client.allocate(&1u64, &102u64, &400_0000000i128); + client.allocate(&1u64, &101u64, &6_000_000_000i128); + client.allocate(&1u64, &102u64, &4_000_000_000i128); let milestone = client.get_milestone(&1u64); assert_eq!(milestone.remaining_budget, 0); @@ -87,11 +132,11 @@ fn test_create_milestone_allocate_and_release_per_issue() { ); // 5% fee on each release. - assert_eq!(token_client.balance(&contributor_a), 570_0000000i128); - assert_eq!(token_client.balance(&contributor_b), 380_0000000i128); + assert_eq!(token_client.balance(&contributor_a), 5_700_000_000i128); + assert_eq!(token_client.balance(&contributor_b), 3_800_000_000i128); assert_eq!( token_client.balance(&treasury), - 30_0000000i128 + 20_0000000i128 + 300_000_000i128 + 200_000_000i128 ); } @@ -200,8 +245,8 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let admin = Address::generate(&env); let oracle = Address::generate(&env); let treasury = Address::generate(&env); - let contract_id = env.register(crate::MilestonesContract, ()); - let client = crate::MilestonesContractClient::new(&env, &contract_id); + let contract_id = env.register(crate::contract::MilestonesContract, ()); + let client = crate::contract::MilestonesContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. client.initialize(&admin, &oracle, &treasury, &0u32, &None, &None); @@ -290,9 +335,9 @@ fn test_allocate_rejects_over_allocation() { asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&2u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&2u64, &201u64, &700_0000000i128); + client.allocate(&2u64, &201u64, &7_000_000_000i128); - let err = client.try_allocate(&2u64, &202u64, &400_0000000i128); + let err = client.try_allocate(&2u64, &202u64, &4_000_000_000i128); assert_eq!(err, Err(Ok(Error::OverAllocation))); } @@ -308,7 +353,7 @@ fn test_release_issue_rejects_double_release() { asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&3u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&3u64, &301u64, &500_0000000i128); + client.allocate(&3u64, &301u64, &5_000_000_000i128); let contributor = Address::generate(&env); client.release_issue( @@ -333,13 +378,13 @@ fn test_cancel_milestone_refunds_remaining_budget() { asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&4u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&4u64, &401u64, &300_0000000i128); + client.allocate(&4u64, &401u64, &3_000_000_000i128); client.cancel_milestone(&4u64); // 700 remaining budget refunded to sponsor (sponsor started with 0 after // deposit, so balance should now equal the unallocated remainder). - assert_eq!(token_client.balance(&sponsor), 700_0000000i128); + assert_eq!(token_client.balance(&sponsor), 7_000_000_000i128); assert!(client.get_milestone(&4u64).closed); } @@ -401,7 +446,7 @@ fn test_allocate_requires_admin_auth() { client.create_milestone(&7u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); env.set_auths(&[]); - let result = client.try_allocate(&7u64, &701u64, &100_0000000i128); + let result = client.try_allocate(&7u64, &701u64, &1_000_000_000i128); assert!(result.is_err()); } @@ -416,7 +461,7 @@ fn test_deallocate_requires_admin_auth() { let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&7u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&7u64, &701u64, &100_0000000i128); + client.allocate(&7u64, &701u64, &1_000_000_000i128); env.set_auths(&[]); let result = client.try_deallocate(&7u64, &701u64); @@ -424,7 +469,9 @@ fn test_deallocate_requires_admin_auth() { } #[test] -fn test_deallocate_blocked_while_paused() { +fn test_deallocate_allowed_while_paused() { + // #530 — deallocate is a recovery path (returns the allocation to + // remaining_budget + refunds when closed); pause() must not freeze it. let env = Env::default(); env.mock_all_auths(); let (_admin, _treasury, client) = setup(&env); @@ -437,11 +484,11 @@ fn test_deallocate_blocked_while_paused() { client.allocate(&95u64, &950u64, &1_000_000_000i128); client.pause(); - let result = client.try_deallocate(&95u64, &950u64); - assert_eq!(result, Err(Ok(Error::ContractPaused))); + // While paused, deallocate still recovers the allocation back to budget. + client.deallocate(&95u64, &950u64); + assert_eq!(client.get_milestone(&95u64).remaining_budget, 10_000_000_000i128); client.unpause(); - client.deallocate(&95u64, &950u64); } #[test] @@ -455,7 +502,7 @@ fn test_release_issue_requires_admin_auth() { let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&8u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&8u64, &801u64, &100_0000000i128); + client.allocate(&8u64, &801u64, &1_000_000_000i128); env.set_auths(&[]); let contributor = Address::generate(&env); @@ -1027,15 +1074,15 @@ fn test_pause_blocks_commitment_paths_but_allows_cancel() { let allocate_err = client.try_allocate(&90u64, &901u64, &1_000_000_000i128); assert_eq!(allocate_err, Err(Ok(Error::ContractPaused))); - let deallocate_err = client.try_deallocate(&90u64, &900u64); - assert_eq!(deallocate_err, Err(Ok(Error::ContractPaused))); + // #530 — deallocate is a recovery path and stays open while paused. + client.deallocate(&90u64, &900u64); let recipients = vec![&env, (recipient, 10_000u32)]; let release_err = client.try_release_issue(&90u64, &900u64, &recipients); assert_eq!(release_err, Err(Ok(Error::ContractPaused))); client.cancel_milestone(&90u64); - assert_eq!(token_client.balance(&sponsor), 9_000_000_000i128); + assert_eq!(token_client.balance(&sponsor), 10_000_000_000i128); assert_eq!(token_client.balance(&treasury), 0); } @@ -1636,7 +1683,7 @@ fn test_set_treasury_updates_fee_recipient() { let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &10_000_000_000i128); client.create_milestone(&1u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); - client.allocate(&1u64, &101u64, &1_000_0000000i128); + client.allocate(&1u64, &101u64, &10_000_000_000i128); let contributor = Address::generate(&env); client.release_issue( @@ -1646,9 +1693,9 @@ fn test_set_treasury_updates_fee_recipient() { ); // 5% fee lands on the new treasury, not the old one. - assert_eq!(token_client.balance(&new_treasury), 50_0000000i128); + assert_eq!(token_client.balance(&new_treasury), 500_000_000i128); assert_eq!(token_client.balance(&old_treasury), 0); - assert_eq!(token_client.balance(&contributor), 950_0000000i128); + assert_eq!(token_client.balance(&contributor), 9_500_000_000i128); } /// Test that cancel_milestone_after_deadline rejects calls before the deadline @@ -1828,9 +1875,9 @@ fn test_release_issue_with_duplicate_recipient_addresses() { let recipients = vec![&env, (alice.clone(), 5_000u32), (alice.clone(), 5_000u32)]; client.release_issue(&2u64, &201u64, &recipients); - let distributable = 950_0000000i128; // after 5% fee + let distributable = 9_500_000_000i128; // after 5% fee assert_eq!(token_client.balance(&alice), distributable); - assert_eq!(token_client.balance(&treasury), 50_0000000i128); + assert_eq!(token_client.balance(&treasury), 500_000_000i128); } #[test] @@ -1854,9 +1901,9 @@ fn test_release_issue_with_treasury_as_recipient() { let recipients = vec![&env, (alice.clone(), 5_000u32), (treasury.clone(), 5_000u32)]; client.release_issue(&2u64, &202u64, &recipients); - let distributable = 950_0000000i128; // after 5% fee + let distributable = 9_500_000_000i128; // after 5% fee let alice_expected = distributable * 5000 / 10000; - let treasury_expected = 50_0000000i128 + (distributable - alice_expected); + let treasury_expected = 500_000_000i128 + (distributable - alice_expected); assert_eq!(token_client.balance(&alice), alice_expected); assert_eq!(token_client.balance(&treasury), treasury_expected); } @@ -1909,6 +1956,6 @@ fn test_release_issue_with_admin_as_recipient() { let recipients = vec![&env, (admin.clone(), 10_000u32)]; client.release_issue(&2u64, &204u64, &recipients); - let distributable = 950_0000000i128; // after 5% fee + let distributable = 9_500_000_000i128; // after 5% fee assert_eq!(token_client.balance(&admin), distributable); } diff --git a/docs/access-control-audit.md b/docs/access-control-audit.md index 8239ff9..087d37f 100644 --- a/docs/access-control-audit.md +++ b/docs/access-control-audit.md @@ -178,7 +178,7 @@ reach a point where they return `Err(Error::Unauthorized)`. constructs and returns `Err(Error::Unauthorized)` when the caller is not the sponsor (`escrow/src/lib.rs`, `extend_deadline`), and this path is covered by a dedicated test -(`escrow/src/test.rs`, `test_extend_deadline_unauthorized`). +(`escrow/src/test.rs`, `test_extend_deadline_requires_sponsor_auth`). The variant is therefore **not** dead code in `escrow`. ### 4. `keep_alive` functions have deliberately no-auth design diff --git a/docs/pause-circuit-breaker-design.md b/docs/pause-circuit-breaker-design.md index 3d4350b..61d761c 100644 --- a/docs/pause-circuit-breaker-design.md +++ b/docs/pause-circuit-breaker-design.md @@ -52,7 +52,7 @@ Add a `Paused` error (used across all contracts): ```rust pub enum Error { // ... - ContractPaused = 30, // NEW + ContractPaused = 19, // escrow; 18 milestones, 13 maintenance-pool } ``` @@ -83,7 +83,7 @@ pub fn unpause(env: Env) -> Result<(), Error> { Ok(()) } -fn is_paused(env: &Env) -> bool { +fn is_paused_view(env: Env) -> bool { // shipped as is_paused_view env.storage() .instance() .get(&DataKey::Paused) diff --git a/docs/upgrade-storage-migration-design.md b/docs/upgrade-storage-migration-design.md index 46adcfb..977da2f 100644 --- a/docs/upgrade-storage-migration-design.md +++ b/docs/upgrade-storage-migration-design.md @@ -6,7 +6,7 @@ This document outlines the upgrade mechanism, storage versioning strategy, and m ## Problem Statement -MergeFi contracts currently have no upgrade mechanism. Deploying a new wasm version creates a new contract address with empty storage, stranding existing on-chain state. This is unacceptable for contracts holding live user funds. +MergeFi contracts each ship an admin-gated `upgrade(new_wasm_hash)` calling `env.deployer().update_current_contract_wasm(new_wasm_hash)` — upgrading code in place at the same address while preserving storage. This document records the design that led there. ## Soroban Upgrade Mechanism