diff --git a/contracts/amm-liquidity-pools/Cargo.toml b/contracts/amm-liquidity-pools/Cargo.toml index b5d0c03..80eb0da 100644 --- a/contracts/amm-liquidity-pools/Cargo.toml +++ b/contracts/amm-liquidity-pools/Cargo.toml @@ -15,12 +15,3 @@ soroban-sdk = { workspace = true, features = ["testutils"] } [lib] crate-type = ["cdylib"] -[profile.release] -opt-level = "z" -overflow-checks = true -debug = 0 -strip = "symbols" -debug-assertions = false -panic = "abort" -codegen-units = 1 -lto = true diff --git a/contracts/escrow/src/founder_vesting.rs b/contracts/escrow/src/founder_vesting.rs new file mode 100644 index 0000000..e75d5df --- /dev/null +++ b/contracts/escrow/src/founder_vesting.rs @@ -0,0 +1,70 @@ +use crate::storage::{get_vesting_schedule, set_vesting_schedule, remove_vesting_schedule}; +use shared::types::{Amount, VestingSchedule}; +use shared::errors::Error; +use soroban_sdk::{Address, Env}; + +pub fn calculate_unlocked(env: &Env, schedule: &VestingSchedule) -> Amount { + let now = env.ledger().timestamp(); + if now <= schedule.start_time { + return 0; + } + + let elapsed = now - schedule.start_time; + if elapsed >= schedule.duration { + return schedule.total_amount; + } + + // Linear vesting: total_amount * elapsed / duration + (schedule.total_amount * elapsed as i128) / schedule.duration as i128 +} + +pub fn create_vesting( + env: &Env, + project_id: u64, + milestone_id: u64, + beneficiary: Address, + amount: Amount, + duration: u64, +) { + let schedule = VestingSchedule { + project_id, + milestone_id, + beneficiary, + total_amount: amount, + claimed_amount: 0, + start_time: env.ledger().timestamp(), + duration, + }; + set_vesting_schedule(env, project_id, milestone_id, &schedule); +} + +pub fn internal_claim_unlocked( + env: &Env, + project_id: u64, + milestone_id: u64, +) -> Result<(Address, Amount), Error> { + let mut schedule = get_vesting_schedule(env, project_id, milestone_id)?; + schedule.beneficiary.require_auth(); + + let unlocked = calculate_unlocked(env, &schedule); + let claimable = unlocked - schedule.claimed_amount; + + if claimable <= 0 { + return Err(Error::NoClaim); + } + + schedule.claimed_amount += claimable; + let beneficiary = schedule.beneficiary.clone(); + + if schedule.claimed_amount >= schedule.total_amount { + remove_vesting_schedule(env, project_id, milestone_id); + } else { + set_vesting_schedule(env, project_id, milestone_id, &schedule); + } + + Ok((beneficiary, claimable)) +} + +pub fn get_vesting(env: &Env, project_id: u64, milestone_id: u64) -> Result { + get_vesting_schedule(env, project_id, milestone_id) +} diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 59a2b74..e1391a3 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -28,12 +28,16 @@ pub trait ProfitDistributionTrait { mod storage; mod validation; +mod founder_vesting; + mod yield_router; #[cfg(test)] mod tests; use storage::*; +use founder_vesting::*; + use yield_router::{ configure_yield_router as configure_router, disable_yield_for_escrow as disable_yield_router, emergency_withdraw_from_pool, enable_yield_for_escrow as enable_yield_router, @@ -87,8 +91,10 @@ impl EscrowContract { token: Address, validators: Vec
, approval_threshold: u32, + vesting_duration: u64, management_fee_bps: u32, ) -> Result<(), Error> { + creator.require_auth(); // Validate inputs @@ -118,9 +124,11 @@ impl EscrowContract { released_amount: 0, validators, approval_threshold, + vesting_duration, management_fee_bps, }; + // Store escrow set_escrow(&env, project_id, &escrow); @@ -347,17 +355,20 @@ impl EscrowContract { if milestone.approval_count as u32 >= required_approvals { milestone.status = MilestoneStatus::Approved; - // Release funds + // Release funds (this updates escrow.released_amount) release_milestone_funds(&env, &mut escrow, &milestone)?; - // Perform token transfer to creator - let token_client = TokenClient::new(&env, &escrow.token); - token_client.transfer( - &env.current_contract_address(), - &escrow.creator, - &milestone.amount, + // Instead of direct transfer, create a vesting schedule + create_vesting( + &env, + project_id, + milestone_id, + escrow.creator.clone(), + milestone.amount, + escrow.vesting_duration, ); + // Store updated escrow set_escrow(&env, project_id, &escrow); @@ -1296,6 +1307,14 @@ impl EscrowContract { release_milestone_funds(env, &mut virtual_escrow, &virtual_milestone)?; escrow.released_amount = virtual_escrow.released_amount; + // Instead of direct transfer, create vesting + create_vesting( + &env, + dispute.project_id, + dispute.milestone_id, + escrow.creator.clone(), + release_amount, + escrow.vesting_duration, let token_client = TokenClient::new(env, &escrow.token); token_client.transfer( &env.current_contract_address(), @@ -1304,6 +1323,7 @@ impl EscrowContract { ); } + set_milestone(env, dispute.project_id, dispute.milestone_id, &milestone); set_escrow(env, dispute.project_id, &escrow); @@ -1445,6 +1465,35 @@ impl EscrowContract { storage::get_pending_upgrade(&env) } + /// Claim unlocked funds from a vesting schedule + pub fn claim_unlocked( + env: Env, + project_id: u64, + milestone_id: u64, + ) -> Result { + let (beneficiary, amount) = internal_claim_unlocked(&env, project_id, milestone_id)?; + + // Get escrow for the project to get token address + let escrow = get_escrow(&env, project_id)?; + + let token_client = TokenClient::new(&env, &escrow.token); + token_client.transfer( + &env.current_contract_address(), + &beneficiary, + &amount, + ); + + Ok(amount) + } + + /// Get vesting schedule information + pub fn get_vesting_info( + env: Env, + project_id: u64, + milestone_id: u64, + ) -> Result { + get_vesting(&env, project_id, milestone_id) + /// Claim excess yield from the escrow contract /// Yield = Balance - (TotalDeposited - ReleasedAmount) pub fn claim_yield( @@ -1515,9 +1564,11 @@ impl EscrowContract { ); Ok(()) + main } } + /// Helper function to release milestone funds fn release_milestone_funds( _env: &Env, diff --git a/contracts/escrow/src/storage.rs b/contracts/escrow/src/storage.rs index 0a63bba..3ab75f6 100644 --- a/contracts/escrow/src/storage.rs +++ b/contracts/escrow/src/storage.rs @@ -22,6 +22,8 @@ const JUROR_PREFIX: &str = "juror"; const DISPUTE_VOTE_PREFIX: &str = "d_vote"; const JUROR_ASSIGNMENTS_PREFIX: &str = "j_assign"; const ACTIVE_JURORS_KEY: &str = "act_jurors"; +const VESTING_PREFIX: &str = "vesting"; + const EMERGENCY_WITHDRAW_PREFIX: &str = "emg_withdraw"; /// Store platform admin @@ -344,6 +346,27 @@ pub fn has_pending_upgrade(env: &Env) -> bool { env.storage().instance().has(&PENDING_UPGRADE_KEY) } +/// Store a vesting schedule for a project/milestone +pub fn set_vesting_schedule(env: &Env, project_id: u64, milestone_id: u64, vesting: &VestingSchedule) { + let key = (VESTING_PREFIX, project_id, milestone_id); + env.storage().persistent().set(&key, vesting); +} + +/// Retrieve a vesting schedule +pub fn get_vesting_schedule(env: &Env, project_id: u64, milestone_id: u64) -> Result { + let key = (VESTING_PREFIX, project_id, milestone_id); + env.storage() + .persistent() + .get::<(&str, u64, u64), VestingSchedule>(&key) + .ok_or(Error::NotFound) +} + +/// Remove a vesting schedule +pub fn remove_vesting_schedule(env: &Env, project_id: u64, milestone_id: u64) { + let key = (VESTING_PREFIX, project_id, milestone_id); + env.storage().persistent().remove(&key); +} + pub fn get_emergency_withdraw_state(env: &Env, project_id: u64) -> EmergencyWithdrawState { let key = (EMERGENCY_WITHDRAW_PREFIX, project_id); env.storage() diff --git a/contracts/escrow/src/tests.rs b/contracts/escrow/src/tests.rs index 1382c2c..9fd1e07 100644 --- a/contracts/escrow/src/tests.rs +++ b/contracts/escrow/src/tests.rs @@ -1,989 +1,2780 @@ -#![cfg(test)] - -use crate::{EmergencyWithdrawStatus, EscrowContract, EscrowContractClient}; -use shared::types::{DisputeResolution, MilestoneStatus}; -use soroban_sdk::{ - contract, contractimpl, - testutils::{Address as _, Ledger}, - Address, BytesN, Env, Vec, -}; - -#[contract] -pub struct MockToken; - -#[contractimpl] -impl MockToken { - pub fn transfer(_env: Env, _from: Address, _to: Address, _amount: i128) {} - pub fn balance(_env: Env, _id: Address) -> i128 { - 1200 - } -} - -#[contract] -pub struct MockProfitDist; - -#[contractimpl] -impl MockProfitDist { - pub fn deposit_profits( - _env: Env, - _project_id: u64, - _depositor: Address, - _amount: i128, - ) -> Result<(), shared::errors::Error> { - Ok(()) - } -} - -#[contract] -pub struct MockYieldPool; - -#[contractimpl] -impl MockYieldPool { - pub fn deposit(_env: Env, _from: Address, _amount: i128) {} - - pub fn withdraw(_env: Env, _to: Address, _amount: i128) {} - - pub fn get_balance(_env: Env, _account: Address) -> i128 { - 750 - } -} - -fn create_mock_token(env: &Env) -> Address { - env.register_contract(None, MockToken) -} - -fn create_mock_yield_pool(env: &Env) -> Address { - env.register_contract(None, MockYieldPool) -} - -fn create_test_env() -> (Env, Address, Address, Address, Vec
) { - let env = Env::default(); - env.ledger().set_timestamp(1000); - - let creator = Address::generate(&env); - let token = create_mock_token(&env); - let validator1 = Address::generate(&env); - let validator2 = Address::generate(&env); - let validator3 = Address::generate(&env); - - let mut validators = Vec::new(&env); - validators.push_back(validator1); - validators.push_back(validator2); - validators.push_back(validator3.clone()); - - (env, creator, token, validator3, validators) -} - -fn create_client(env: &Env) -> EscrowContractClient<'_> { - EscrowContractClient::new(env, &env.register_contract(None, EscrowContract)) -} - -fn setup_with_admin( - env: &Env, -) -> ( - Address, - Address, - Address, - Vec
, - EscrowContractClient<'_>, -) { - let admin = Address::generate(env); - let creator = Address::generate(env); - let token = Address::generate(env); - - let mut validators = Vec::new(env); - validators.push_back(Address::generate(env)); - validators.push_back(Address::generate(env)); - validators.push_back(Address::generate(env)); - - let contract_id = env.register_contract(None, EscrowContract); - let client = EscrowContractClient::new(env, &contract_id); - - client.initialize_admin(&admin); - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); - - (admin, creator, token, validators, client) -} - -/// Default threshold used by all existing tests (67%). -const DEFAULT_THRESHOLD: u32 = 6700; - -// ── existing tests ── - -#[test] -fn test_initialize_escrow() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); - - let escrow = client.get_escrow(&1); - assert_eq!(escrow.project_id, 1); - assert_eq!(escrow.creator, creator); - assert_eq!(escrow.token, token); - assert_eq!(escrow.total_deposited, 0); - assert_eq!(escrow.released_amount, 0); - assert_eq!(escrow.approval_threshold, DEFAULT_THRESHOLD); - assert_eq!(escrow.management_fee_bps, 500); -} - -#[test] -fn test_initialize_with_insufficient_validators() { - let env = Env::default(); - let creator = Address::generate(&env); - let token = Address::generate(&env); - - let mut validators = Vec::new(&env); - validators.push_back(Address::generate(&env)); - - let client = create_client(&env); - let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - assert!(result.is_err()); -} - -#[test] -fn test_initialize_with_invalid_fee() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let result = client.try_initialize( - &1, - &creator, - &token, - &validators, - &DEFAULT_THRESHOLD, - &10001, - ); - assert!(result.is_err(), "fee above 100% should be rejected"); -} - -#[test] -fn test_initialize_duplicate_escrow() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - assert!(result.is_err()); -} - -#[test] -fn test_deposit_funds() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let deposit_amount: i128 = 1000; - let result = client.try_deposit(&1, &deposit_amount); - - assert!(result.is_ok()); - - let escrow = client.get_escrow(&1); - assert_eq!(escrow.total_deposited, deposit_amount); -} - -#[test] -fn test_deposit_invalid_amount() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let result = client.try_deposit(&1, &0); - assert!(result.is_err()); - - let result = client.try_deposit(&1, &-100); - assert!(result.is_err()); -} - -#[test] -fn test_create_milestone() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - - env.mock_all_auths(); - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &1000); - - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let milestone = client.get_milestone(&1, &0); - assert_eq!(milestone.id, 0); - assert_eq!(milestone.project_id, 1); - assert_eq!(milestone.amount, 500); - assert_eq!(milestone.status, MilestoneStatus::Pending); - assert_eq!(milestone.description_hash, description_hash); -} - -#[test] -fn test_create_milestone_exceeds_escrow() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - - env.mock_all_auths(); - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &500); - - let description_hash = BytesN::from_array(&env, &[2u8; 32]); - let result = client.try_create_milestone(&1, &description_hash, &1000); - - assert!(result.is_err()); -} - -#[test] -fn test_create_multiple_milestones() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - - env.mock_all_auths(); - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &3000); - - let desc1 = BytesN::from_array(&env, &[1u8; 32]); - let desc2 = BytesN::from_array(&env, &[2u8; 32]); - let desc3 = BytesN::from_array(&env, &[3u8; 32]); - - client.create_milestone(&1, &desc1, &1000); - client.create_milestone(&1, &desc2, &1000); - client.create_milestone(&1, &desc3, &1000); - - assert!(client.get_milestone(&1, &0).id == 0); - assert!(client.get_milestone(&1, &1).id == 1); - assert!(client.get_milestone(&1, &2).id == 2); - - let total = client.get_total_milestone_amount(&1); - assert_eq!(total, 3000); -} - -#[test] -fn test_submit_milestone() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - - env.mock_all_auths(); - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &1000); - - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - - let milestone = client.get_milestone(&1, &0); - assert_eq!(milestone.status, MilestoneStatus::Submitted); - assert_eq!(milestone.proof_hash, proof_hash); -} - -#[test] -fn test_submit_milestone_invalid_status() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &1000); - - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - - let proof_hash2 = BytesN::from_array(&env, &[10u8; 32]); - let result = client.try_submit_milestone(&1, &0, &proof_hash2); - - assert!(result.is_err()); -} - -#[test] -fn test_get_available_balance() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - client.deposit(&1, &1000); - let balance = client.get_available_balance(&1); - assert_eq!(balance, 1000); - - client.deposit(&1, &500); - let balance = client.get_available_balance(&1); - assert_eq!(balance, 1500); -} - -#[test] -fn test_escrow_not_found() { - let env = Env::default(); - let client = create_client(&env); - - let result = client.try_get_escrow(&999); - assert!(result.is_err()); -} - -#[test] -fn test_milestone_not_found() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let result = client.try_get_milestone(&1, &999); - assert!(result.is_err()); -} - -#[test] -fn test_milestone_status_transitions() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - client.deposit(&1, &1000); - - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let milestone = client.get_milestone(&1, &0); - assert_eq!(milestone.status, MilestoneStatus::Pending); - assert_eq!(milestone.approval_count, 0); - assert_eq!(milestone.rejection_count, 0); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - - let milestone = client.get_milestone(&1, &0); - assert_eq!(milestone.status, MilestoneStatus::Submitted); - assert_eq!(milestone.proof_hash, proof_hash); -} - -#[test] -fn test_deposit_updates_correctly() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - client.deposit(&1, &500); - assert_eq!(client.get_escrow(&1).total_deposited, 500); - - client.deposit(&1, &300); - assert_eq!(client.get_escrow(&1).total_deposited, 800); - - client.deposit(&1, &200); - assert_eq!(client.get_escrow(&1).total_deposited, 1000); -} - -#[test] -fn test_multiple_projects_isolated() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1000); - - let creator = Address::generate(&env); - let token = Address::generate(&env); - let validator1 = Address::generate(&env); - let validator2 = Address::generate(&env); - let validator3 = Address::generate(&env); - - let mut validators = Vec::new(&env); - validators.push_back(validator1); - validators.push_back(validator2); - validators.push_back(validator3); - - let client = create_client(&env); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let escrow1 = client.get_escrow(&1); - assert_eq!(escrow1.project_id, 1); -} - -#[test] -fn test_juror_registration() { - let (env, _, _, _, _) = create_test_env(); - let client = create_client(&env); - let admin = Address::generate(&env); - env.mock_all_auths(); - client.initialize_admin(&admin); - - let juror_token = create_mock_token(&env); - client.configure_dispute_token(&juror_token); - - let juror = Address::generate(&env); - let stake: i128 = 500_0000000; - - client.register_as_juror(&juror, &stake); - assert!(client.try_register_as_juror(&juror, &stake).is_err()); - - client.deregister_as_juror(&juror); - assert!(client.try_deregister_as_juror(&juror).is_err()); -} - -// ── NEW tests for Issue #39: Customizable Validator Thresholds ──────────── - -#[test] -fn test_initialize_with_low_threshold() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let result = client.try_initialize(&1, &creator, &token, &validators, &5000, &0); - assert!(result.is_err(), "threshold below 51% should be rejected"); -} - -#[test] -fn test_initialize_with_threshold_above_100() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let result = client.try_initialize(&1, &creator, &token, &validators, &10100, &0); - assert!(result.is_err(), "threshold above 100% should be rejected"); -} - -#[test] -fn test_minimum_valid_threshold_accepted() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let result = client.try_initialize(&1, &creator, &token, &validators, &5100, &0); - assert!(result.is_ok(), "5100 basis points (51%) should be accepted"); -} - -#[test] -fn test_maximum_valid_threshold_accepted() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let result = client.try_initialize(&1, &creator, &token, &validators, &10000, &0); - assert!( - result.is_ok(), - "10000 basis points (100%) should be accepted" - ); -} - -#[test] -fn test_different_projects_have_independent_thresholds() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - - let creator = Address::generate(&env); - let token = Address::generate(&env); - - let mut validators = Vec::new(&env); - validators.push_back(Address::generate(&env)); - validators.push_back(Address::generate(&env)); - validators.push_back(Address::generate(&env)); - - let client = create_client(&env); - - client.initialize(&1, &creator, &token, &validators, &6700, &0); - client.initialize(&2, &creator, &token, &validators, &10000, &1000); - - assert_eq!(client.get_escrow(&1).approval_threshold, 6700); - assert_eq!(client.get_escrow(&2).approval_threshold, 10000); -} - -#[test] -fn test_dispute_happy_path() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - let admin = Address::generate(&env); - env.mock_all_auths(); - client.initialize_admin(&admin); - - let juror_token = create_mock_token(&env); - client.configure_dispute_token(&juror_token); - - // Use 10000 (100%) threshold — requires ALL 3 validators to approve - client.initialize(&1, &creator, &token, &validators, &10000, &0); - - let v1 = validators.get(0).unwrap(); - let v2 = validators.get(1).unwrap(); - let v3 = validators.get(2).unwrap(); - - let mut jurors = Vec::new(&env); - for _ in 0..7 { - let j = Address::generate(&env); - client.register_as_juror(&j, &500_0000000); - jurors.push_back(j); - } - - client.deposit(&1, &1000); - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - - // With 100% threshold, 1 vote is not enough - client.vote_milestone(&1, &0, &v1, &true); - assert_eq!( - client.get_milestone(&1, &0).status, - MilestoneStatus::Submitted, - "one approval should not be enough with 100% threshold" - ); - - // With 100% threshold, 2 votes are still not enough - client.vote_milestone(&1, &0, &v2, &true); - assert_eq!( - client.get_milestone(&1, &0).status, - MilestoneStatus::Submitted, - "two approvals should not be enough with 100% threshold" - ); - - // With 100% threshold, all 3 votes trigger approval - client.vote_milestone(&1, &0, &v3, &true); - assert_eq!( - client.get_milestone(&1, &0).status, - MilestoneStatus::Approved, - "all three approvals should trigger approval with 100% threshold" - ); -} - -#[test] -fn test_dispute_appeals_path() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - let admin = Address::generate(&env); - env.mock_all_auths(); - client.initialize_admin(&admin); - - let juror_token = create_mock_token(&env); - client.configure_dispute_token(&juror_token); - - // Use DEFAULT_THRESHOLD (67%) — with 3 validators, 2 votes meets the threshold - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); - - let v1 = validators.get(0).unwrap(); - let v2 = validators.get(1).unwrap(); - - let mut jurors = Vec::new(&env); - for _ in 0..20 { - let j = Address::generate(&env); - client.register_as_juror(&j, &500_0000000); - jurors.push_back(j); - } - - client.deposit(&1, &1000); - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - - // Two rejections to trigger dispute - client.vote_milestone(&1, &0, &validators.get(0).unwrap(), &false); - client.vote_milestone(&1, &0, &validators.get(1).unwrap(), &false); - - let project_contract = Address::generate(&env); - let dispute_id = client.initiate_dispute(&1, &0, &creator, &project_contract); - - client.select_jury(&dispute_id); - - let mut salt_buf = [0u8; 32]; - salt_buf.fill(123); - let salt = soroban_sdk::Bytes::from_array(&env, &salt_buf); - - let assigned_jurors = client.get_juror_assignments(&dispute_id); - for i in 0..7 { - let juror_addr = assigned_jurors.get(i).unwrap(); - - let mut b = soroban_sdk::Bytes::new(&env); - b.push_back(0); // RelFunds maps to variant byte 0 in reveal_vote - b.append(&salt); - let h = env.crypto().sha256(&b); - - client.commit_vote(&dispute_id, &juror_addr, &h.into()); - } - - env.ledger() - .set_timestamp(env.ledger().timestamp() + 259201); - - for j in 0..7 { - let juror_addr = assigned_jurors.get(j).unwrap(); - client.reveal_vote( - &dispute_id, - &juror_addr, - &DisputeResolution::RelFunds, - &0, - &salt, - ); - } - - env.ledger() - .set_timestamp(env.ledger().timestamp() + 172801); - client.tally_votes(&dispute_id); - - // File appeal - let appellant = Address::generate(&env); - client.file_appeal(&dispute_id, &appellant); - - // Re-submit milestone so validators can vote on it again after appeal - client.submit_milestone(&1, &0, &proof_hash); - - // With 67% threshold and 3 validators, 1 vote (33%) is not enough - client.vote_milestone(&1, &0, &v1, &true); - assert_eq!( - client.get_milestone(&1, &0).status, - MilestoneStatus::Submitted, - "one approval should not meet the 67% threshold with 3 validators" - ); - - // With 67% threshold and 3 validators, 2 votes (67%) meets the threshold - client.vote_milestone(&1, &0, &v2, &true); - assert_eq!( - client.get_milestone(&1, &0).status, - MilestoneStatus::Approved, - "two approvals should meet the 67% threshold with 3 validators" - ); -} - -// ====== NEW tests for Emergency Pause/Resume ====== -#[test] -fn test_is_paused_defaults_to_false() { - let env = Env::default(); - env.mock_all_auths(); - let (_, _, _, _, client) = setup_with_admin(&env); - - assert!(!client.get_is_paused()); -} - -#[test] -fn test_pause_sets_paused_state() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - assert!(client.get_is_paused()); -} - -#[test] -fn test_pause_blocks_deposit() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - - let result = client.try_deposit(&1, &500); - assert!(result.is_err(), "deposit should be blocked when paused"); -} - -#[test] -fn test_pause_blocks_create_milestone() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.deposit(&1, &1000); - client.pause(&admin); - - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - let result = client.try_create_milestone(&1, &description_hash, &500); - assert!( - result.is_err(), - "create_milestone should be blocked when paused" - ); -} - -#[test] -fn test_pause_blocks_submit_milestone() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.deposit(&1, &1000); - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - client.pause(&admin); - - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - let result = client.try_submit_milestone(&1, &0, &proof_hash); - assert!( - result.is_err(), - "submit_milestone should be blocked when paused" - ); -} - -#[test] -fn test_pause_blocks_vote_milestone() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, validators, client) = setup_with_admin(&env); - - client.deposit(&1, &1000); - let description_hash = BytesN::from_array(&env, &[1u8; 32]); - client.create_milestone(&1, &description_hash, &500); - let proof_hash = BytesN::from_array(&env, &[9u8; 32]); - client.submit_milestone(&1, &0, &proof_hash); - client.pause(&admin); - - let voter = validators.get(0).unwrap(); - let result = client.try_vote_milestone(&1, &0, &voter, &true); - assert!( - result.is_err(), - "vote_milestone should be blocked when paused" - ); -} - -#[test] -fn test_resume_before_time_delay_fails() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - - env.ledger().set_timestamp(1000 + 3600); - let result = client.try_resume(&admin); - assert!( - result.is_err(), - "resume should fail before time delay expires" - ); -} - -#[test] -fn test_resume_after_time_delay_succeeds() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - - env.ledger().set_timestamp(1000 + 86400 + 1); - let result = client.try_resume(&admin); - assert!(result.is_ok(), "resume should succeed after time delay"); - assert!(!client.get_is_paused()); -} - -#[test] -fn test_operations_work_after_resume() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - env.ledger().set_timestamp(1000 + 86400 + 1); - client.resume(&admin); - - let result = client.try_deposit(&1, &500); - assert!(result.is_ok(), "deposit should work after resume"); -} - -#[test] -fn test_only_admin_can_pause() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (_, _, _, _, client) = setup_with_admin(&env); - - let random = Address::generate(&env); - let result = client.try_pause(&random); - assert!(result.is_err(), "non-admin should not be able to pause"); -} - -#[test] -fn test_only_admin_can_resume() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - client.pause(&admin); - env.ledger().set_timestamp(1000 + 86400 + 1); - - let random = Address::generate(&env); - let result = client.try_resume(&random); - assert!(result.is_err(), "non-admin should not be able to resume"); -} - -// ---------- Upgrade (time-lock, requires pause) ---------- -#[test] -fn test_schedule_upgrade_succeeds() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); - let result = client.try_schedule_upgrade(&admin, &wasm_hash); - assert!(result.is_ok()); - - let pending = client.get_pending_upgrade(); - assert!(pending.is_some()); - let p = pending.unwrap(); - assert_eq!(p.wasm_hash, wasm_hash); - assert_eq!(p.execute_not_before, 1000 + shared::UPGRADE_TIME_LOCK_SECS); -} - -#[test] -fn test_execute_upgrade_too_early_fails() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); - client.schedule_upgrade(&admin, &wasm_hash); - client.pause(&admin); - - env.ledger().set_timestamp(1000 + 3600); - let result = client.try_execute_upgrade(&admin); - assert!(result.is_err(), "execute_upgrade should fail before 48h"); -} - -#[test] -fn test_execute_upgrade_without_pause_fails() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); - client.schedule_upgrade(&admin, &wasm_hash); - - env.ledger() - .set_timestamp(1000 + shared::UPGRADE_TIME_LOCK_SECS + 1); - let result = client.try_execute_upgrade(&admin); - assert!( - result.is_err(), - "execute_upgrade should fail when not paused" - ); -} - -#[test] -fn test_cancel_upgrade_clears_pending() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, _, _, client) = setup_with_admin(&env); - - let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); - client.schedule_upgrade(&admin, &wasm_hash); - assert!(client.get_pending_upgrade().is_some()); - - client.cancel_upgrade(&admin); - assert!(client.get_pending_upgrade().is_none()); -} - -#[test] -fn test_only_admin_can_schedule_upgrade() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (_, _, _, _, client) = setup_with_admin(&env); - - let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); - let random = Address::generate(&env); - let result = client.try_schedule_upgrade(&random, &wasm_hash); - assert!(result.is_err()); -} - -#[test] -fn test_claim_yield_happy_path() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - let profit_dist_id = env.register_contract(None, MockProfitDist); - - // 5% management fee - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); - - // Initial deposit of 1000 - client.deposit(&1, &1000); - - // MockToken::balance will return 1200 for the balance check - // Since MockToken::transfer does nothing, we just verify the call doesn't panic - client.claim_yield(&1, &profit_dist_id); -} - -#[test] -fn test_claim_yield_no_yield() { - let (env, creator, token, _, validators) = create_test_env(); - let client = create_client(&env); - env.mock_all_auths(); - - client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); - let profit_dist_id = Address::generate(&env); - - // No tokens in contract, should fail - let result = client.try_claim_yield(&1, &profit_dist_id); - assert!(result.is_err()); -} - -#[test] -fn test_admin_emergency_withdraw_requires_validator_multisig() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, token, validators, client) = setup_with_admin(&env); - - let pool_id = create_mock_yield_pool(&env); - client.configure_yield_router(&admin, &pool_id, &token, &2000); - client.pause(&admin); - - let first_validator = validators.get(0).unwrap(); - let second_validator = validators.get(1).unwrap(); - - assert_eq!(client.approve_emergency_withdraw(&1, &first_validator), 1); - let early_result = client.try_admin_emergency_withdraw(&1, &admin); - assert!( - early_result.is_err(), - "admin execution should require multisig approvals" - ); - - assert_eq!(client.approve_emergency_withdraw(&1, &second_validator), 2); - let rescued_amount = client.admin_emergency_withdraw(&1, &admin); - assert_eq!(rescued_amount, 750); - - let state = client.get_emergency_withdraw_state(&1); - assert_eq!(state.status, EmergencyWithdrawStatus::Executed); - assert_eq!(state.rescued_amount, 750); - assert_eq!(state.approvals.len(), 2); -} - -#[test] -fn test_emergency_withdraw_rejects_non_validator_approval() { - let env = Env::default(); - env.ledger().set_timestamp(1000); - env.mock_all_auths(); - let (admin, _, token, _, client) = setup_with_admin(&env); - - let pool_id = create_mock_yield_pool(&env); - client.configure_yield_router(&admin, &pool_id, &token, &2000); - client.pause(&admin); - - let random = Address::generate(&env); - let result = client.try_approve_emergency_withdraw(&1, &random); - assert!( - result.is_err(), - "only escrow validators may co-sign emergency rescue" - ); -} +#![cfg(test)] + +mod tests { + use crate::{EscrowContract, EscrowContractClient}; + use shared::types::{DisputeResolution, MilestoneStatus}; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, BytesN, Env, Vec, + }; + + #[contract] + pub struct MockToken; + + #[contractimpl] + impl MockToken { + pub fn transfer(_env: Env, _from: Address, _to: Address, _amount: i128) {} + } + + fn create_mock_token(env: &Env) -> Address { + env.register_contract(None, MockToken) + } + + fn create_test_env() -> (Env, Address, Address, Address, Vec
) { + let env = Env::default(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = create_mock_token(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3.clone()); + + (env, creator, token, validator3, validators) + } + + fn create_client(env: &Env) -> EscrowContractClient<'_> { + EscrowContractClient::new(env, &env.register_contract(None, EscrowContract)) + } + + fn setup_with_admin( + env: &Env, + ) -> (Address, Address, Address, Vec
, EscrowContractClient) { + let admin = Address::generate(env); + let creator = Address::generate(env); + let token = Address::generate(env); + + let mut validators = Vec::new(env); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(env, &contract_id); + + client.initialize_admin(&admin); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + (admin, creator, token, validators, client) + } + + const VESTING_DURATION: u64 = 3600; + + + /// Default threshold used by all existing tests (67%). + const DEFAULT_THRESHOLD: u32 = 6700; + + // ── existing tests ── + + #[test] + fn test_initialize_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.project_id, 1); + assert_eq!(escrow.creator, creator); + assert_eq!(escrow.token, token); + assert_eq!(escrow.total_deposited, 0); + assert_eq!(escrow.released_amount, 0); + assert_eq!(escrow.approval_threshold, DEFAULT_THRESHOLD); + } + + #[test] + fn test_initialize_with_insufficient_validators() { + let env = Env::default(); + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + assert!(result.is_err()); + } + + #[test] + fn test_initialize_duplicate_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + assert!(result.is_err()); + } + + #[test] + fn test_deposit_funds() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let deposit_amount: i128 = 1000; + let result = client.try_deposit(&1, &deposit_amount); + + assert!(result.is_ok()); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.total_deposited, deposit_amount); + } + + #[test] + fn test_deposit_invalid_amount() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let result = client.try_deposit(&1, &0); + assert!(result.is_err()); + + let result = client.try_deposit(&1, &-100); + assert!(result.is_err()); + } + + #[test] + fn test_create_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.id, 0); + assert_eq!(milestone.project_id, 1); + assert_eq!(milestone.amount, 500); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.description_hash, description_hash); + } + + #[test] + fn test_create_milestone_exceeds_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &500); + + let description_hash = BytesN::from_array(&env, &[2u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &1000); + + assert!(result.is_err()); + } + + #[test] + fn test_create_multiple_milestones() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &3000); + + let desc1 = BytesN::from_array(&env, &[1u8; 32]); + let desc2 = BytesN::from_array(&env, &[2u8; 32]); + let desc3 = BytesN::from_array(&env, &[3u8; 32]); + + client.create_milestone(&1, &desc1, &1000); + client.create_milestone(&1, &desc2, &1000); + client.create_milestone(&1, &desc3, &1000); + + assert!(client.get_milestone(&1, &0).id == 0); + assert!(client.get_milestone(&1, &1).id == 1); + assert!(client.get_milestone(&1, &2).id == 2); + + let total = client.get_total_milestone_amount(&1); + assert_eq!(total, 3000); + } + + #[test] + fn test_submit_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); + } + + #[test] + fn test_submit_milestone_invalid_status() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let proof_hash2 = BytesN::from_array(&env, &[10u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash2); + + assert!(result.is_err()); + } + + #[test] + fn test_get_available_balance() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + client.deposit(&1, &1000); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1000); + + client.deposit(&1, &500); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1500); + } + + #[test] + fn test_escrow_not_found() { + let env = Env::default(); + let client = create_client(&env); + + let result = client.try_get_escrow(&999); + assert!(result.is_err()); + } + + #[test] + fn test_milestone_not_found() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let result = client.try_get_milestone(&1, &999); + assert!(result.is_err()); + } + + #[test] + fn test_milestone_status_transitions() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.approval_count, 0); + assert_eq!(milestone.rejection_count, 0); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); + } + + #[test] + fn test_deposit_updates_correctly() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + client.deposit(&1, &500); + assert_eq!(client.get_escrow(&1).total_deposited, 500); + + client.deposit(&1, &300); + assert_eq!(client.get_escrow(&1).total_deposited, 800); + + client.deposit(&1, &200); + assert_eq!(client.get_escrow(&1).total_deposited, 1000); + } + + #[test] + fn test_multiple_projects_isolated() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let escrow1 = client.get_escrow(&1); + assert_eq!(escrow1.project_id, 1); + } + + #[test] + fn test_juror_registration() { + let (env, _, _, _, _) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + let juror = Address::generate(&env); + let stake: i128 = 500_0000000; + + client.register_as_juror(&juror, &stake); + assert!(client.try_register_as_juror(&juror, &stake).is_err()); + + client.deregister_as_juror(&juror); + assert!(client.try_deregister_as_juror(&juror).is_err()); + } + + // ── NEW tests for Issue #39: Customizable Validator Thresholds ──────────── + + #[test] + fn test_initialize_with_low_threshold() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5000, &VESTING_DURATION); + assert!(result.is_err(), "threshold below 51% should be rejected"); + } + + #[test] + fn test_initialize_with_threshold_above_100() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10100, &VESTING_DURATION); + assert!(result.is_err(), "threshold above 100% should be rejected"); + } + + #[test] + fn test_minimum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5100, &VESTING_DURATION); + assert!(result.is_ok(), "5100 basis points (51%) should be accepted"); + } + + #[test] + fn test_maximum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10000, &VESTING_DURATION); + assert!(result.is_ok(), "10000 basis points (100%) should be accepted"); + } + + #[test] + fn test_different_projects_have_independent_thresholds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &6700, &VESTING_DURATION); + client.initialize(&2, &creator, &token, &validators, &10000, &VESTING_DURATION); + + assert_eq!(client.get_escrow(&1).approval_threshold, 6700); + assert_eq!(client.get_escrow(&2).approval_threshold, 10000); + } + + #[test] + fn test_dispute_happy_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use 10000 (100%) threshold — requires ALL 3 validators to approve + client.initialize(&1, &creator, &token, &validators, &10000, &VESTING_DURATION); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + let v3 = validators.get(2).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..7 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // With 100% threshold, 1 vote is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not be enough with 100% threshold" + ); + + // With 100% threshold, 2 votes are still not enough + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "two approvals should not be enough with 100% threshold" + ); + + // With 100% threshold, all 3 votes trigger approval + client.vote_milestone(&1, &0, &v3, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "all three approvals should trigger approval with 100% threshold" + ); + } + + #[test] + fn test_dispute_appeals_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use DEFAULT_THRESHOLD (67%) — with 3 validators, 2 votes meets the threshold + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &VESTING_DURATION); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..20 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // Two rejections to trigger dispute + client.vote_milestone(&1, &0, &validators.get(0).unwrap(), &false); + client.vote_milestone(&1, &0, &validators.get(1).unwrap(), &false); + + let project_contract = Address::generate(&env); + let dispute_id = client.initiate_dispute(&1, &0, &creator, &project_contract); + + client.select_jury(&dispute_id); + + let mut salt_buf = [0u8; 32]; + for i in 0..32 { + salt_buf[i] = 123; + } + let salt = soroban_sdk::Bytes::from_array(&env, &salt_buf); + + let assigned_jurors = client.get_juror_assignments(&dispute_id); + for i in 0..7 { + let juror_addr = assigned_jurors.get(i).unwrap(); + + let mut b = soroban_sdk::Bytes::new(&env); + b.push_back(0); // RelFunds maps to variant byte 0 in reveal_vote + b.append(&salt); + let h = env.crypto().sha256(&b); + + client.commit_vote(&dispute_id, &juror_addr, &h.into()); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 259201); + + for j in 0..7 { + let juror_addr = assigned_jurors.get(j).unwrap(); + client.reveal_vote( + &dispute_id, + &juror_addr, + &DisputeResolution::RelFunds, + &0, + &salt, + ); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 172801); + client.tally_votes(&dispute_id); + + // File appeal + let appellant = Address::generate(&env); + client.file_appeal(&dispute_id, &appellant); + + // Re-submit milestone so validators can vote on it again after appeal + client.submit_milestone(&1, &0, &proof_hash); + + // With 67% threshold and 3 validators, 1 vote (33%) is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not meet the 67% threshold with 3 validators" + ); + + // With 67% threshold and 3 validators, 2 votes (67%) meets the threshold + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "two approvals should meet the 67% threshold with 3 validators" + ); + } + + // ====== NEW tests for Emergency Pause/Resume ====== + #[test] + fn test_is_paused_defaults_to_false() { + let env = Env::default(); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + assert!(!client.get_is_paused()); + } + + #[test] + fn test_pause_sets_paused_state() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + assert!(client.get_is_paused()); + } + + #[test] + fn test_pause_blocks_deposit() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_err(), "deposit should be blocked when paused"); + } + + #[test] + fn test_pause_blocks_create_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + client.pause(&admin); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &500); + assert!(result.is_err(), "create_milestone should be blocked when paused"); + } + + #[test] + fn test_pause_blocks_submit_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + client.pause(&admin); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash); + assert!(result.is_err(), "submit_milestone should be blocked when paused"); + } + + #[test] + fn test_pause_blocks_vote_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, validators, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + client.pause(&admin); + + let voter = validators.get(0).unwrap(); + let result = client.try_vote_milestone(&1, &0, &voter, &true); + assert!(result.is_err(), "vote_milestone should be blocked when paused"); + } + + #[test] + fn test_resume_before_time_delay_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_resume(&admin); + assert!(result.is_err(), "resume should fail before time delay expires"); + } + + #[test] + fn test_resume_after_time_delay_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 86400 + 1); + let result = client.try_resume(&admin); + assert!(result.is_ok(), "resume should succeed after time delay"); + assert!(!client.get_is_paused()); + } + + #[test] + fn test_operations_work_after_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + client.resume(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_ok(), "deposit should work after resume"); + } + + #[test] + fn test_only_admin_can_pause() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let random = Address::generate(&env); + let result = client.try_pause(&random); + assert!(result.is_err(), "non-admin should not be able to pause"); + } + + #[test] + fn test_only_admin_can_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + + let random = Address::generate(&env); + let result = client.try_resume(&random); + assert!(result.is_err(), "non-admin should not be able to resume"); + } + + // ---------- Upgrade (time-lock, requires pause) ---------- + #[test] + fn test_schedule_upgrade_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let result = client.try_schedule_upgrade(&admin, &wasm_hash); + assert!(result.is_ok()); + + let pending = client.get_pending_upgrade(); + assert!(pending.is_some()); + let p = pending.unwrap(); + assert_eq!(p.wasm_hash, wasm_hash); + assert_eq!(p.execute_not_before, 1000 + shared::UPGRADE_TIME_LOCK_SECS); + } + + #[test] + fn test_execute_upgrade_too_early_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err(), "execute_upgrade should fail before 48h"); + } + + #[test] + fn test_execute_upgrade_without_pause_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + + env.ledger().set_timestamp(1000 + shared::UPGRADE_TIME_LOCK_SECS + 1); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err(), "execute_upgrade should fail when not paused"); + } + + #[test] + fn test_cancel_upgrade_clears_pending() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + assert!(client.get_pending_upgrade().is_some()); + + client.cancel_upgrade(&admin); + assert!(client.get_pending_upgrade().is_none()); + } + + #[test] + fn test_only_admin_can_schedule_upgrade() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let random = Address::generate(&env); + let result = client.try_schedule_upgrade(&random, &wasm_hash); + assert!(result.is_err()); + } + + #[test] + fn test_founder_vesting_linear() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + // 1 hour vesting duration + let duration = 3600; + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &duration); + client.deposit(&1, &1000); + + let desc = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &desc, &1000); + let proof = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof); + + // Approve milestone (needs 2 out of 3 votes with 67% threshold) + client.vote_milestone(&1, &0, &validators.get(0).unwrap(), &true); + client.vote_milestone(&1, &0, &validators.get(1).unwrap(), &true); + + + // Milestone should be approved now + assert_eq!(client.get_milestone(&1, &0).status, MilestoneStatus::Approved); + + // Check vesting info + let vesting = client.get_vesting_info(&1, &0); + assert_eq!(vesting.total_amount, 1000); + assert_eq!(vesting.claimed_amount, 0); + + // Jump 30 mins (1800s) + env.ledger().set_timestamp(1000 + 1800); + + // Should be able to claim 50% + let claimed = client.claim_unlocked(&1, &0); + assert_eq!(claimed, 500); + + // Jump to end + env.ledger().set_timestamp(1000 + 3600); + let claimed2 = client.claim_unlocked(&1, &0); + assert_eq!(claimed2, 500); + + // Vesting should be cleared + assert!(client.try_get_vesting_info(&1, &0).is_err()); + } +} +#![cfg(test)] + +mod tests { + use crate::{EscrowContract, EscrowContractClient}; + use shared::types::{DisputeResolution, MilestoneStatus}; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, BytesN, Env, Vec, Symbol, + }; + + #[contract] + pub struct MockToken; + + #[contractimpl] + impl MockToken { + pub fn transfer(_env: Env, _from: Address, _to: Address, _amount: i128) {} + pub fn balance(_env: Env, _id: Address) -> i128 { + // Return 1200 for the escrow contract in yield tests + 1200 + } + } + + #[contract] + pub struct MockProfitDist; + + #[contractimpl] + impl MockProfitDist { + pub fn deposit_profits(_env: Env, _project_id: u64, _depositor: Address, _amount: i128) -> Result<(), shared::errors::Error> { + Ok(()) + } + } + + fn create_mock_token(env: &Env) -> Address { + env.register_contract(None, MockToken) + } + + fn create_test_env() -> (Env, Address, Address, Address, Vec
) { + let env = Env::default(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = create_mock_token(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3.clone()); + + (env, creator, token, validator3, validators) + } + + fn create_client(env: &Env) -> EscrowContractClient<'_> { + EscrowContractClient::new(env, &env.register_contract(None, EscrowContract)) + } + + fn setup_with_admin( + env: &Env, + ) -> ( + Address, + Address, + Address, + Vec
, + EscrowContractClient, + ) { + let admin = Address::generate(env); + let creator = Address::generate(env); + let token = Address::generate(env); + + let mut validators = Vec::new(env); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(env, &contract_id); + + client.initialize_admin(&admin); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); // 5% fee + + (admin, creator, token, validators, client) + } + + /// Default threshold used by all existing tests (67%). + const DEFAULT_THRESHOLD: u32 = 6700; + + // ── existing tests ── + + #[test] + fn test_initialize_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.project_id, 1); + assert_eq!(escrow.creator, creator); + assert_eq!(escrow.token, token); + assert_eq!(escrow.total_deposited, 0); + assert_eq!(escrow.released_amount, 0); + assert_eq!(escrow.approval_threshold, DEFAULT_THRESHOLD); + assert_eq!(escrow.management_fee_bps, 500); + } + + #[test] + fn test_initialize_with_insufficient_validators() { + let env = Env::default(); + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + assert!(result.is_err()); + } + + #[test] + fn test_initialize_with_invalid_fee() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &10001); + assert!(result.is_err(), "fee above 100% should be rejected"); + } + + #[test] + fn test_initialize_duplicate_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + assert!(result.is_err()); + } + + #[test] + fn test_deposit_funds() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let deposit_amount: i128 = 1000; + let result = client.try_deposit(&1, &deposit_amount); + + assert!(result.is_ok()); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.total_deposited, deposit_amount); + } + + #[test] + fn test_deposit_invalid_amount() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_deposit(&1, &0); + assert!(result.is_err()); + + let result = client.try_deposit(&1, &-100); + assert!(result.is_err()); + } + + #[test] + fn test_create_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.id, 0); + assert_eq!(milestone.project_id, 1); + assert_eq!(milestone.amount, 500); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.description_hash, description_hash); + } + + #[test] + fn test_create_milestone_exceeds_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &500); + + let description_hash = BytesN::from_array(&env, &[2u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &1000); + + assert!(result.is_err()); + } + + #[test] + fn test_create_multiple_milestones() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &3000); + + let desc1 = BytesN::from_array(&env, &[1u8; 32]); + let desc2 = BytesN::from_array(&env, &[2u8; 32]); + let desc3 = BytesN::from_array(&env, &[3u8; 32]); + + client.create_milestone(&1, &desc1, &1000); + client.create_milestone(&1, &desc2, &1000); + client.create_milestone(&1, &desc3, &1000); + + assert!(client.get_milestone(&1, &0).id == 0); + assert!(client.get_milestone(&1, &1).id == 1); + assert!(client.get_milestone(&1, &2).id == 2); + + let total = client.get_total_milestone_amount(&1); + assert_eq!(total, 3000); + } + + #[test] + fn test_submit_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); + } + + #[test] + fn test_submit_milestone_invalid_status() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let proof_hash2 = BytesN::from_array(&env, &[10u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash2); + + assert!(result.is_err()); + } + + #[test] + fn test_get_available_balance() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + client.deposit(&1, &1000); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1000); + + client.deposit(&1, &500); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1500); + } + + #[test] + fn test_escrow_not_found() { + let env = Env::default(); + let client = create_client(&env); + + let result = client.try_get_escrow(&999); + assert!(result.is_err()); + } + + #[test] + fn test_milestone_not_found() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_get_milestone(&1, &999); + assert!(result.is_err()); + } + + #[test] + fn test_milestone_status_transitions() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.approval_count, 0); + assert_eq!(milestone.rejection_count, 0); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); + } + + #[test] + fn test_deposit_updates_correctly() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + client.deposit(&1, &500); + assert_eq!(client.get_escrow(&1).total_deposited, 500); + + client.deposit(&1, &300); + assert_eq!(client.get_escrow(&1).total_deposited, 800); + + client.deposit(&1, &200); + assert_eq!(client.get_escrow(&1).total_deposited, 1000); + } + + #[test] + fn test_multiple_projects_isolated() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let escrow1 = client.get_escrow(&1); + assert_eq!(escrow1.project_id, 1); + } + + #[test] + fn test_juror_registration() { + let (env, _, _, _, _) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + let juror = Address::generate(&env); + let stake: i128 = 500_0000000; + + client.register_as_juror(&juror, &stake); + assert!(client.try_register_as_juror(&juror, &stake).is_err()); + + client.deregister_as_juror(&juror); + assert!(client.try_deregister_as_juror(&juror).is_err()); + } + + // ── NEW tests for Issue #39: Customizable Validator Thresholds ──────────── + + #[test] + fn test_initialize_with_low_threshold() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5000, &0); + assert!(result.is_err(), "threshold below 51% should be rejected"); + } + + #[test] + fn test_initialize_with_threshold_above_100() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10100, &0); + assert!(result.is_err(), "threshold above 100% should be rejected"); + } + + #[test] + fn test_minimum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5100, &0); + assert!(result.is_ok(), "5100 basis points (51%) should be accepted"); + } + + #[test] + fn test_maximum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10000, &0); + assert!(result.is_ok(), "10000 basis points (100%) should be accepted"); + } + + #[test] + fn test_different_projects_have_independent_thresholds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &6700, &0); + client.initialize(&2, &creator, &token, &validators, &10000, &1000); + + assert_eq!(client.get_escrow(&1).approval_threshold, 6700); + assert_eq!(client.get_escrow(&2).approval_threshold, 10000); + } + + #[test] + fn test_dispute_happy_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use 10000 (100%) threshold — requires ALL 3 validators to approve + client.initialize(&1, &creator, &token, &validators, &10000, &0); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + let v3 = validators.get(2).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..7 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // With 100% threshold, 1 vote is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not be enough with 100% threshold" + ); + + // With 100% threshold, 2 votes are still not enough + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "two approvals should not be enough with 100% threshold" + ); + + // With 100% threshold, all 3 votes trigger approval + client.vote_milestone(&1, &0, &v3, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "all three approvals should trigger approval with 100% threshold" + ); + } + + #[test] + fn test_dispute_appeals_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use DEFAULT_THRESHOLD (67%) — with 3 validators, 2 votes meets the threshold + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..20 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // Two rejections to trigger dispute + client.vote_milestone(&1, &0, &validators.get(0).unwrap(), &false); + client.vote_milestone(&1, &0, &validators.get(1).unwrap(), &false); + + let project_contract = Address::generate(&env); + let dispute_id = client.initiate_dispute(&1, &0, &creator, &project_contract); + + client.select_jury(&dispute_id); + + let mut salt_buf = [0u8; 32]; + for i in 0..32 { + salt_buf[i] = 123; + } + let salt = soroban_sdk::Bytes::from_array(&env, &salt_buf); + + let assigned_jurors = client.get_juror_assignments(&dispute_id); + for i in 0..7 { + let juror_addr = assigned_jurors.get(i).unwrap(); + + let mut b = soroban_sdk::Bytes::new(&env); + b.push_back(0); // RelFunds maps to variant byte 0 in reveal_vote + b.append(&salt); + let h = env.crypto().sha256(&b); + + client.commit_vote(&dispute_id, &juror_addr, &h.into()); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 259201); + + for j in 0..7 { + let juror_addr = assigned_jurors.get(j).unwrap(); + client.reveal_vote( + &dispute_id, + &juror_addr, + &DisputeResolution::RelFunds, + &0, + &salt, + ); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 172801); + client.tally_votes(&dispute_id); + + // File appeal + let appellant = Address::generate(&env); + client.file_appeal(&dispute_id, &appellant); + + // Re-submit milestone so validators can vote on it again after appeal + client.submit_milestone(&1, &0, &proof_hash); + + // With 67% threshold and 3 validators, 1 vote (33%) is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not meet the 67% threshold with 3 validators" + ); + + // With 67% threshold and 3 validators, 2 votes (67%) meets the threshold + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "two approvals should meet the 67% threshold with 3 validators" + ); + } + + // ====== NEW tests for Emergency Pause/Resume ====== + #[test] + fn test_is_paused_defaults_to_false() { + let env = Env::default(); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + assert!(!client.get_is_paused()); + } + + #[test] + fn test_pause_sets_paused_state() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + assert!(client.get_is_paused()); + } + + #[test] + fn test_pause_blocks_deposit() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_err(), "deposit should be blocked when paused"); + } + + #[test] + fn test_pause_blocks_create_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + client.pause(&admin); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &500); + assert!( + result.is_err(), + "create_milestone should be blocked when paused" + ); + } + + #[test] + fn test_pause_blocks_submit_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + client.pause(&admin); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash); + assert!( + result.is_err(), + "submit_milestone should be blocked when paused" + ); + } + + #[test] + fn test_pause_blocks_vote_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, validators, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + client.pause(&admin); + + let voter = validators.get(0).unwrap(); + let result = client.try_vote_milestone(&1, &0, &voter, &true); + assert!( + result.is_err(), + "vote_milestone should be blocked when paused" + ); + } + + #[test] + fn test_resume_before_time_delay_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_resume(&admin); + assert!( + result.is_err(), + "resume should fail before time delay expires" + ); + } + + #[test] + fn test_resume_after_time_delay_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 86400 + 1); + let result = client.try_resume(&admin); + assert!(result.is_ok(), "resume should succeed after time delay"); + assert!(!client.get_is_paused()); + } + + #[test] + fn test_operations_work_after_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + client.resume(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_ok(), "deposit should work after resume"); + } + + #[test] + fn test_only_admin_can_pause() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let random = Address::generate(&env); + let result = client.try_pause(&random); + assert!(result.is_err(), "non-admin should not be able to pause"); + } + + #[test] + fn test_only_admin_can_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + + let random = Address::generate(&env); + let result = client.try_resume(&random); + assert!(result.is_err(), "non-admin should not be able to resume"); + } + + // ---------- Upgrade (time-lock, requires pause) ---------- + #[test] + fn test_schedule_upgrade_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let result = client.try_schedule_upgrade(&admin, &wasm_hash); + assert!(result.is_ok()); + + let pending = client.get_pending_upgrade(); + assert!(pending.is_some()); + let p = pending.unwrap(); + assert_eq!(p.wasm_hash, wasm_hash); + assert_eq!(p.execute_not_before, 1000 + shared::UPGRADE_TIME_LOCK_SECS); + } + + #[test] + fn test_execute_upgrade_too_early_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err(), "execute_upgrade should fail before 48h"); + } + + #[test] + fn test_execute_upgrade_without_pause_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + + env.ledger() + .set_timestamp(1000 + shared::UPGRADE_TIME_LOCK_SECS + 1); + let result = client.try_execute_upgrade(&admin); + assert!( + result.is_err(), + "execute_upgrade should fail when not paused" + ); + } + + #[test] + fn test_cancel_upgrade_clears_pending() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + assert!(client.get_pending_upgrade().is_some()); + + client.cancel_upgrade(&admin); + assert!(client.get_pending_upgrade().is_none()); + } + + #[test] + fn test_only_admin_can_schedule_upgrade() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let random = Address::generate(&env); + let result = client.try_schedule_upgrade(&random, &wasm_hash); + assert!(result.is_err()); + } + + #[test] + fn test_claim_yield_happy_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let profit_dist_id = env.register_contract(None, MockProfitDist); + + // 5% management fee + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + + // Initial deposit of 1000 + client.deposit(&1, &1000); + + // MockToken::balance will return 1200 for the balance check + // Since MockToken::transfer does nothing, we just verify the call doesn't panic + client.claim_yield(&1, &profit_dist_id); + } + + #[test] + fn test_claim_yield_no_yield() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + let profit_dist_id = Address::generate(&env); + + // No tokens in contract, should fail + let result = client.try_claim_yield(&1, &profit_dist_id); + assert!(result.is_err()); + } +} +main +#![cfg(test)] + +use crate::{EmergencyWithdrawStatus, EscrowContract, EscrowContractClient}; +use shared::types::{DisputeResolution, MilestoneStatus}; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + Address, BytesN, Env, Vec, +}; + +#[contract] +pub struct MockToken; + +#[contractimpl] +impl MockToken { + pub fn transfer(_env: Env, _from: Address, _to: Address, _amount: i128) {} + pub fn balance(_env: Env, _id: Address) -> i128 { + 1200 + } +} + +#[contract] +pub struct MockProfitDist; + +#[contractimpl] +impl MockProfitDist { + pub fn deposit_profits( + _env: Env, + _project_id: u64, + _depositor: Address, + _amount: i128, + ) -> Result<(), shared::errors::Error> { + Ok(()) + } +} + +#[contract] +pub struct MockYieldPool; + +#[contractimpl] +impl MockYieldPool { + pub fn deposit(_env: Env, _from: Address, _amount: i128) {} + + pub fn withdraw(_env: Env, _to: Address, _amount: i128) {} + + pub fn get_balance(_env: Env, _account: Address) -> i128 { + 750 + } +} + +fn create_mock_token(env: &Env) -> Address { + env.register_contract(None, MockToken) +} + +fn create_mock_yield_pool(env: &Env) -> Address { + env.register_contract(None, MockYieldPool) +} + +fn create_test_env() -> (Env, Address, Address, Address, Vec
) { + let env = Env::default(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = create_mock_token(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3.clone()); + + (env, creator, token, validator3, validators) +} + +fn create_client(env: &Env) -> EscrowContractClient<'_> { + EscrowContractClient::new(env, &env.register_contract(None, EscrowContract)) +} + +fn setup_with_admin( + env: &Env, +) -> ( + Address, + Address, + Address, + Vec
, + EscrowContractClient<'_>, +) { + let admin = Address::generate(env); + let creator = Address::generate(env); + let token = Address::generate(env); + + let mut validators = Vec::new(env); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + validators.push_back(Address::generate(env)); + + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(env, &contract_id); + + client.initialize_admin(&admin); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + + (admin, creator, token, validators, client) +} + +/// Default threshold used by all existing tests (67%). +const DEFAULT_THRESHOLD: u32 = 6700; + +// ── existing tests ── + +#[test] +fn test_initialize_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.project_id, 1); + assert_eq!(escrow.creator, creator); + assert_eq!(escrow.token, token); + assert_eq!(escrow.total_deposited, 0); + assert_eq!(escrow.released_amount, 0); + assert_eq!(escrow.approval_threshold, DEFAULT_THRESHOLD); + assert_eq!(escrow.management_fee_bps, 500); +} + +#[test] +fn test_initialize_with_insufficient_validators() { + let env = Env::default(); + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + assert!(result.is_err()); +} + +#[test] +fn test_initialize_with_invalid_fee() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize( + &1, + &creator, + &token, + &validators, + &DEFAULT_THRESHOLD, + &10001, + ); + assert!(result.is_err(), "fee above 100% should be rejected"); +} + +#[test] +fn test_initialize_duplicate_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + assert!(result.is_err()); +} + +#[test] +fn test_deposit_funds() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let deposit_amount: i128 = 1000; + let result = client.try_deposit(&1, &deposit_amount); + + assert!(result.is_ok()); + + let escrow = client.get_escrow(&1); + assert_eq!(escrow.total_deposited, deposit_amount); +} + +#[test] +fn test_deposit_invalid_amount() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_deposit(&1, &0); + assert!(result.is_err()); + + let result = client.try_deposit(&1, &-100); + assert!(result.is_err()); +} + +#[test] +fn test_create_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.id, 0); + assert_eq!(milestone.project_id, 1); + assert_eq!(milestone.amount, 500); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.description_hash, description_hash); +} + +#[test] +fn test_create_milestone_exceeds_escrow() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &500); + + let description_hash = BytesN::from_array(&env, &[2u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &1000); + + assert!(result.is_err()); +} + +#[test] +fn test_create_multiple_milestones() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &3000); + + let desc1 = BytesN::from_array(&env, &[1u8; 32]); + let desc2 = BytesN::from_array(&env, &[2u8; 32]); + let desc3 = BytesN::from_array(&env, &[3u8; 32]); + + client.create_milestone(&1, &desc1, &1000); + client.create_milestone(&1, &desc2, &1000); + client.create_milestone(&1, &desc3, &1000); + + assert!(client.get_milestone(&1, &0).id == 0); + assert!(client.get_milestone(&1, &1).id == 1); + assert!(client.get_milestone(&1, &2).id == 2); + + let total = client.get_total_milestone_amount(&1); + assert_eq!(total, 3000); +} + +#[test] +fn test_submit_milestone() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + + env.mock_all_auths(); + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); +} + +#[test] +fn test_submit_milestone_invalid_status() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let proof_hash2 = BytesN::from_array(&env, &[10u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash2); + + assert!(result.is_err()); +} + +#[test] +fn test_get_available_balance() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + client.deposit(&1, &1000); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1000); + + client.deposit(&1, &500); + let balance = client.get_available_balance(&1); + assert_eq!(balance, 1500); +} + +#[test] +fn test_escrow_not_found() { + let env = Env::default(); + let client = create_client(&env); + + let result = client.try_get_escrow(&999); + assert!(result.is_err()); +} + +#[test] +fn test_milestone_not_found() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let result = client.try_get_milestone(&1, &999); + assert!(result.is_err()); +} + +#[test] +fn test_milestone_status_transitions() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + client.deposit(&1, &1000); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Pending); + assert_eq!(milestone.approval_count, 0); + assert_eq!(milestone.rejection_count, 0); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + let milestone = client.get_milestone(&1, &0); + assert_eq!(milestone.status, MilestoneStatus::Submitted); + assert_eq!(milestone.proof_hash, proof_hash); +} + +#[test] +fn test_deposit_updates_correctly() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + client.deposit(&1, &500); + assert_eq!(client.get_escrow(&1).total_deposited, 500); + + client.deposit(&1, &300); + assert_eq!(client.get_escrow(&1).total_deposited, 800); + + client.deposit(&1, &200); + assert_eq!(client.get_escrow(&1).total_deposited, 1000); +} + +#[test] +fn test_multiple_projects_isolated() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + let validator1 = Address::generate(&env); + let validator2 = Address::generate(&env); + let validator3 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(validator1); + validators.push_back(validator2); + validators.push_back(validator3); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let escrow1 = client.get_escrow(&1); + assert_eq!(escrow1.project_id, 1); +} + +#[test] +fn test_juror_registration() { + let (env, _, _, _, _) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + let juror = Address::generate(&env); + let stake: i128 = 500_0000000; + + client.register_as_juror(&juror, &stake); + assert!(client.try_register_as_juror(&juror, &stake).is_err()); + + client.deregister_as_juror(&juror); + assert!(client.try_deregister_as_juror(&juror).is_err()); +} + +// ── NEW tests for Issue #39: Customizable Validator Thresholds ──────────── + +#[test] +fn test_initialize_with_low_threshold() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5000, &0); + assert!(result.is_err(), "threshold below 51% should be rejected"); +} + +#[test] +fn test_initialize_with_threshold_above_100() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10100, &0); + assert!(result.is_err(), "threshold above 100% should be rejected"); +} + +#[test] +fn test_minimum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &5100, &0); + assert!(result.is_ok(), "5100 basis points (51%) should be accepted"); +} + +#[test] +fn test_maximum_valid_threshold_accepted() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let result = client.try_initialize(&1, &creator, &token, &validators, &10000, &0); + assert!( + result.is_ok(), + "10000 basis points (100%) should be accepted" + ); +} + +#[test] +fn test_different_projects_have_independent_thresholds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let token = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + validators.push_back(Address::generate(&env)); + + let client = create_client(&env); + + client.initialize(&1, &creator, &token, &validators, &6700, &0); + client.initialize(&2, &creator, &token, &validators, &10000, &1000); + + assert_eq!(client.get_escrow(&1).approval_threshold, 6700); + assert_eq!(client.get_escrow(&2).approval_threshold, 10000); +} + +#[test] +fn test_dispute_happy_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use 10000 (100%) threshold — requires ALL 3 validators to approve + client.initialize(&1, &creator, &token, &validators, &10000, &0); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + let v3 = validators.get(2).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..7 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // With 100% threshold, 1 vote is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not be enough with 100% threshold" + ); + + // With 100% threshold, 2 votes are still not enough + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "two approvals should not be enough with 100% threshold" + ); + + // With 100% threshold, all 3 votes trigger approval + client.vote_milestone(&1, &0, &v3, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "all three approvals should trigger approval with 100% threshold" + ); +} + +#[test] +fn test_dispute_appeals_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.initialize_admin(&admin); + + let juror_token = create_mock_token(&env); + client.configure_dispute_token(&juror_token); + + // Use DEFAULT_THRESHOLD (67%) — with 3 validators, 2 votes meets the threshold + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &0); + + let v1 = validators.get(0).unwrap(); + let v2 = validators.get(1).unwrap(); + + let mut jurors = Vec::new(&env); + for _ in 0..20 { + let j = Address::generate(&env); + client.register_as_juror(&j, &500_0000000); + jurors.push_back(j); + } + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + + // Two rejections to trigger dispute + client.vote_milestone(&1, &0, &validators.get(0).unwrap(), &false); + client.vote_milestone(&1, &0, &validators.get(1).unwrap(), &false); + + let project_contract = Address::generate(&env); + let dispute_id = client.initiate_dispute(&1, &0, &creator, &project_contract); + + client.select_jury(&dispute_id); + + let mut salt_buf = [0u8; 32]; + salt_buf.fill(123); + let salt = soroban_sdk::Bytes::from_array(&env, &salt_buf); + + let assigned_jurors = client.get_juror_assignments(&dispute_id); + for i in 0..7 { + let juror_addr = assigned_jurors.get(i).unwrap(); + + let mut b = soroban_sdk::Bytes::new(&env); + b.push_back(0); // RelFunds maps to variant byte 0 in reveal_vote + b.append(&salt); + let h = env.crypto().sha256(&b); + + client.commit_vote(&dispute_id, &juror_addr, &h.into()); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 259201); + + for j in 0..7 { + let juror_addr = assigned_jurors.get(j).unwrap(); + client.reveal_vote( + &dispute_id, + &juror_addr, + &DisputeResolution::RelFunds, + &0, + &salt, + ); + } + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 172801); + client.tally_votes(&dispute_id); + + // File appeal + let appellant = Address::generate(&env); + client.file_appeal(&dispute_id, &appellant); + + // Re-submit milestone so validators can vote on it again after appeal + client.submit_milestone(&1, &0, &proof_hash); + + // With 67% threshold and 3 validators, 1 vote (33%) is not enough + client.vote_milestone(&1, &0, &v1, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Submitted, + "one approval should not meet the 67% threshold with 3 validators" + ); + + // With 67% threshold and 3 validators, 2 votes (67%) meets the threshold + client.vote_milestone(&1, &0, &v2, &true); + assert_eq!( + client.get_milestone(&1, &0).status, + MilestoneStatus::Approved, + "two approvals should meet the 67% threshold with 3 validators" + ); +} + +// ====== NEW tests for Emergency Pause/Resume ====== +#[test] +fn test_is_paused_defaults_to_false() { + let env = Env::default(); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + assert!(!client.get_is_paused()); +} + +#[test] +fn test_pause_sets_paused_state() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + assert!(client.get_is_paused()); +} + +#[test] +fn test_pause_blocks_deposit() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_err(), "deposit should be blocked when paused"); +} + +#[test] +fn test_pause_blocks_create_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + client.pause(&admin); + + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + let result = client.try_create_milestone(&1, &description_hash, &500); + assert!( + result.is_err(), + "create_milestone should be blocked when paused" + ); +} + +#[test] +fn test_pause_blocks_submit_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + client.pause(&admin); + + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + let result = client.try_submit_milestone(&1, &0, &proof_hash); + assert!( + result.is_err(), + "submit_milestone should be blocked when paused" + ); +} + +#[test] +fn test_pause_blocks_vote_milestone() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, validators, client) = setup_with_admin(&env); + + client.deposit(&1, &1000); + let description_hash = BytesN::from_array(&env, &[1u8; 32]); + client.create_milestone(&1, &description_hash, &500); + let proof_hash = BytesN::from_array(&env, &[9u8; 32]); + client.submit_milestone(&1, &0, &proof_hash); + client.pause(&admin); + + let voter = validators.get(0).unwrap(); + let result = client.try_vote_milestone(&1, &0, &voter, &true); + assert!( + result.is_err(), + "vote_milestone should be blocked when paused" + ); +} + +#[test] +fn test_resume_before_time_delay_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_resume(&admin); + assert!( + result.is_err(), + "resume should fail before time delay expires" + ); +} + +#[test] +fn test_resume_after_time_delay_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 86400 + 1); + let result = client.try_resume(&admin); + assert!(result.is_ok(), "resume should succeed after time delay"); + assert!(!client.get_is_paused()); +} + +#[test] +fn test_operations_work_after_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + client.resume(&admin); + + let result = client.try_deposit(&1, &500); + assert!(result.is_ok(), "deposit should work after resume"); +} + +#[test] +fn test_only_admin_can_pause() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let random = Address::generate(&env); + let result = client.try_pause(&random); + assert!(result.is_err(), "non-admin should not be able to pause"); +} + +#[test] +fn test_only_admin_can_resume() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + client.pause(&admin); + env.ledger().set_timestamp(1000 + 86400 + 1); + + let random = Address::generate(&env); + let result = client.try_resume(&random); + assert!(result.is_err(), "non-admin should not be able to resume"); +} + +// ---------- Upgrade (time-lock, requires pause) ---------- +#[test] +fn test_schedule_upgrade_succeeds() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let result = client.try_schedule_upgrade(&admin, &wasm_hash); + assert!(result.is_ok()); + + let pending = client.get_pending_upgrade(); + assert!(pending.is_some()); + let p = pending.unwrap(); + assert_eq!(p.wasm_hash, wasm_hash); + assert_eq!(p.execute_not_before, 1000 + shared::UPGRADE_TIME_LOCK_SECS); +} + +#[test] +fn test_execute_upgrade_too_early_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + client.pause(&admin); + + env.ledger().set_timestamp(1000 + 3600); + let result = client.try_execute_upgrade(&admin); + assert!(result.is_err(), "execute_upgrade should fail before 48h"); +} + +#[test] +fn test_execute_upgrade_without_pause_fails() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + + env.ledger() + .set_timestamp(1000 + shared::UPGRADE_TIME_LOCK_SECS + 1); + let result = client.try_execute_upgrade(&admin); + assert!( + result.is_err(), + "execute_upgrade should fail when not paused" + ); +} + +#[test] +fn test_cancel_upgrade_clears_pending() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + client.schedule_upgrade(&admin, &wasm_hash); + assert!(client.get_pending_upgrade().is_some()); + + client.cancel_upgrade(&admin); + assert!(client.get_pending_upgrade().is_none()); +} + +#[test] +fn test_only_admin_can_schedule_upgrade() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (_, _, _, _, client) = setup_with_admin(&env); + + let wasm_hash = BytesN::from_array(&env, &[42u8; 32]); + let random = Address::generate(&env); + let result = client.try_schedule_upgrade(&random, &wasm_hash); + assert!(result.is_err()); +} + +#[test] +fn test_claim_yield_happy_path() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + let profit_dist_id = env.register_contract(None, MockProfitDist); + + // 5% management fee + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + + // Initial deposit of 1000 + client.deposit(&1, &1000); + + // MockToken::balance will return 1200 for the balance check + // Since MockToken::transfer does nothing, we just verify the call doesn't panic + client.claim_yield(&1, &profit_dist_id); +} + +#[test] +fn test_claim_yield_no_yield() { + let (env, creator, token, _, validators) = create_test_env(); + let client = create_client(&env); + env.mock_all_auths(); + + client.initialize(&1, &creator, &token, &validators, &DEFAULT_THRESHOLD, &500); + let profit_dist_id = Address::generate(&env); + + // No tokens in contract, should fail + let result = client.try_claim_yield(&1, &profit_dist_id); + assert!(result.is_err()); +} + +#[test] +fn test_admin_emergency_withdraw_requires_validator_multisig() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, token, validators, client) = setup_with_admin(&env); + + let pool_id = create_mock_yield_pool(&env); + client.configure_yield_router(&admin, &pool_id, &token, &2000); + client.pause(&admin); + + let first_validator = validators.get(0).unwrap(); + let second_validator = validators.get(1).unwrap(); + + assert_eq!(client.approve_emergency_withdraw(&1, &first_validator), 1); + let early_result = client.try_admin_emergency_withdraw(&1, &admin); + assert!( + early_result.is_err(), + "admin execution should require multisig approvals" + ); + + assert_eq!(client.approve_emergency_withdraw(&1, &second_validator), 2); + let rescued_amount = client.admin_emergency_withdraw(&1, &admin); + assert_eq!(rescued_amount, 750); + + let state = client.get_emergency_withdraw_state(&1); + assert_eq!(state.status, EmergencyWithdrawStatus::Executed); + assert_eq!(state.rescued_amount, 750); + assert_eq!(state.approvals.len(), 2); +} + +#[test] +fn test_emergency_withdraw_rejects_non_validator_approval() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + env.mock_all_auths(); + let (admin, _, token, _, client) = setup_with_admin(&env); + + let pool_id = create_mock_yield_pool(&env); + client.configure_yield_router(&admin, &pool_id, &token, &2000); + client.pause(&admin); + + let random = Address::generate(&env); + let result = client.try_approve_emergency_withdraw(&1, &random); + assert!( + result.is_err(), + "only escrow validators may co-sign emergency rescue" + ); +} diff --git a/contracts/project-launch/Cargo.toml b/contracts/project-launch/Cargo.toml index 4898317..f57206b 100644 --- a/contracts/project-launch/Cargo.toml +++ b/contracts/project-launch/Cargo.toml @@ -18,15 +18,6 @@ identity = { path = "../identity", features = ["testutils"] } [lib] crate-type = ["cdylib"] -[profile.release] -opt-level = "z" -overflow-checks = true -debug = 0 -strip = "symbols" -debug-assertions = false -panic = "abort" -codegen-units = 1 -lto = true [features] testutils = ["soroban-sdk/testutils"] diff --git a/contracts/project-launch/src/lib.rs b/contracts/project-launch/src/lib.rs index 232e53d..03dd6d6 100644 --- a/contracts/project-launch/src/lib.rs +++ b/contracts/project-launch/src/lib.rs @@ -53,6 +53,7 @@ pub struct Project { pub status: ProjectStatus, pub metadata_hash: Bytes, pub total_raised: i128, + pub vesting_duration: u64, pub created_at: u64, } @@ -116,6 +117,7 @@ impl ProjectLaunch { deadline: u64, token: Address, metadata_hash: Bytes, + vesting_duration: u64, jurisdictions: Option>, ) -> Result { if Self::get_is_paused(env.clone()) { @@ -165,6 +167,7 @@ impl ProjectLaunch { status: ProjectStatus::Active, metadata_hash, total_raised: 0, + vesting_duration, created_at: current_time, }; @@ -699,6 +702,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); assert!(result.is_err()); @@ -711,6 +715,7 @@ mod tests { &too_soon_deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); assert!(result.is_err()); @@ -745,6 +750,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -888,6 +894,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); } @@ -921,6 +928,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -981,6 +989,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -1029,6 +1038,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -1089,6 +1099,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -1160,6 +1171,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -1201,6 +1213,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); @@ -1255,6 +1268,7 @@ mod tests { &deadline, &token, &metadata_hash, + &3600, // Added vesting duration (1 hour) &None, ); assert!( diff --git a/contracts/shared/src/types.rs b/contracts/shared/src/types.rs index 1289cfd..865f030 100644 --- a/contracts/shared/src/types.rs +++ b/contracts/shared/src/types.rs @@ -54,7 +54,7 @@ pub enum Jurisdiction { } #[contracttype] -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct EscrowInfo { pub project_id: u64, pub creator: Address, @@ -63,9 +63,23 @@ pub struct EscrowInfo { pub released_amount: Amount, pub validators: Vec
, pub approval_threshold: u32, + pub vesting_duration: u64, pub management_fee_bps: u32, } +#[contracttype] +#[derive(Clone, Debug)] +pub struct VestingSchedule { + pub project_id: u64, + pub milestone_id: u64, + pub beneficiary: Address, + pub total_amount: Amount, + pub claimed_amount: Amount, + pub start_time: u64, + pub duration: u64, +} + + #[contracttype] #[derive(Clone, Copy, PartialEq, Debug)] pub enum MilestoneStatus {