diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 56cc93de..6732ffe1 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -101,6 +101,8 @@ pub enum DataKey { Locked, MultisigConfig(u64), // Per-job multisig configuration UpgradeAdmin, + Treasury, + FeeBps, } #[contracttype] @@ -150,11 +152,18 @@ pub enum EscrowError { UpgradeAdminNotSet = 18, ArithmeticOverflow = 19, DisputeResolutionExpired = 20, + MaxMilestonesExceeded = 21, + FeeTooHigh = 22, + NothingToSweep = 23, } /// Maximum platform fee, in basis points (100% = 10_000 bps). pub const MAX_FEE_BPS: u32 = 10_000; +/// Maximum number of milestones allowed per escrow job. +/// Prevents unbounded storage growth and keeps WASM execution within block limits. +pub const MAX_MILESTONES: u32 = 12; + #[contracttype] #[derive(Clone)] pub struct DisputeRaisedEvent { @@ -257,6 +266,41 @@ pub struct DisputeExpiredEvent { pub expired_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct FeeConfigUpdatedEvent { + pub treasury: Option
, + pub fee_bps: u32, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct LockupUpdatedEvent { + pub job_id: u64, + pub expires_at: u64, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct EmergencySweepEvent { + pub job_id: u64, + pub admin: Address, + pub rescue_address: Address, + pub amount: i128, + pub swept_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct MilestonesAmendedEvent { + pub job_id: u64, + pub milestone_count: u32, + pub remaining_amount: i128, + pub amended_at: u64, +} + fn enter_reentrancy_guard(env: &Env) { if env.storage().instance().has(&DataKey::Locked) { panic_with_error!(env, EscrowError::ReentrancyDetected); @@ -606,6 +650,11 @@ impl EscrowContract { return Err(EscrowError::InvalidInput); } + // Enforce maximum milestone partition count + if job.milestones.len() >= MAX_MILESTONES { + return Err(EscrowError::MaxMilestonesExceeded); + } + job.milestones.push_back(Milestone { amount, status: MilestoneStatus::Pending, @@ -759,6 +808,32 @@ impl EscrowContract { Ok(()) } + fn payout_with_fee(env: &Env, _job_id: u64, job: &EscrowJob, amount: i128) { + let token_client = token::Client::new(env, &job.token); + let fee_bps = Self::get_fee_bps(env.clone()); + let mut payout_amount = amount; + + if fee_bps > 0 { + if let Some(treasury) = Self::get_treasury(env.clone()) { + let fee_amount = amount + .checked_mul(fee_bps as i128) + .unwrap_or(0) + .checked_div(MAX_FEE_BPS as i128) + .unwrap_or(0); + + payout_amount = amount.checked_sub(fee_amount).unwrap_or(amount); + + if fee_amount > 0 { + token_client.transfer(&env.current_contract_address(), &treasury, &fee_amount); + } + } + } + + if payout_amount > 0 { + token_client.transfer(&env.current_contract_address(), &job.freelancer, &payout_amount); + } + } + /// Happy-path release for an explicit milestone index (0-based). /// Only the client may call this to release the funds for a specific milestone. pub fn release_funds( @@ -851,7 +926,9 @@ impl EscrowContract { let next_status = EscrowStatus::Disputed; job.status.validate_transition(&next_status)?; job.status = next_status; - job.dispute_deadline = env.ledger().timestamp() + Self::DISPUTE_RESOLUTION_WINDOW; + job.dispute_deadline = env.ledger().timestamp() + .checked_add(Self::DISPUTE_RESOLUTION_WINDOW) + .ok_or(EscrowError::ArithmeticError)?; log!(&env, "open_dispute: job {}", job_id); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -914,7 +991,9 @@ impl EscrowContract { let next_status = EscrowStatus::Disputed; job.status.validate_transition(&next_status)?; job.status = next_status; - job.dispute_deadline = now + Self::DISPUTE_RESOLUTION_WINDOW; + job.dispute_deadline = now + .checked_add(Self::DISPUTE_RESOLUTION_WINDOW) + .ok_or(EscrowError::ArithmeticError)?; log!(&env, "raise_dispute: job {}", job_id); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -925,7 +1004,7 @@ impl EscrowContract { let mut released_count = 0u32; for m in job.milestones.iter() { if m.status == MilestoneStatus::Released { - released_count += 1; + released_count = released_count.checked_add(1).ok_or(EscrowError::ArithmeticError)?; } } @@ -1127,6 +1206,54 @@ impl EscrowContract { Ok(()) } + /// Recoups storage space by removing the job from persistent storage. + /// Can only be invoked by the client or freelancer, and only if the job + /// is in a terminal state (Completed, Refunded, or Resolved). + pub fn cleanup_job(env: Env, job_id: u64, caller: Address) -> Result<(), EscrowError> { + caller.require_auth(); + + let key = DataKey::Job(job_id); + let job: EscrowJob = env + .storage() + .persistent() + .get(&key) + .ok_or(EscrowError::JobNotFound)?; + + if !(caller == job.client || caller == job.freelancer) { + return Err(EscrowError::Unauthorized); + } + + if !(job.status == EscrowStatus::Completed + || job.status == EscrowStatus::Refunded + || job.status == EscrowStatus::Resolved) + { + return Err(EscrowError::InvalidState); + } + + let remaining = job + .total_amount + .checked_sub(job.released_amount) + .ok_or(EscrowError::ArithmeticError)?; + if remaining > 0 { + return Err(EscrowError::InvalidState); // Should not happen in terminal state + } + + // De-allocate + env.storage().persistent().remove(&key); + + let ms_key = DataKey::MultisigConfig(job_id); + if env.storage().persistent().has(&ms_key) { + env.storage().persistent().remove(&ms_key); + } + + env.events().publish( + ("escrow", "CleanupJob"), + (job_id, caller, env.ledger().timestamp()), + ); + + Ok(()) + } + pub fn get_job(env: Env, job_id: u64) -> Result { let key = DataKey::Job(job_id); let job: EscrowJob = env @@ -1450,11 +1577,12 @@ impl EscrowContract { treasury: Address, fee_bps: u32, ) -> Result<(), EscrowError> { - let admin: Address = env + let config: ContractConfig = env .storage() .instance() - .get(&DataKey::Admin) + .get(&DataKey::Config) .ok_or(EscrowError::NotInitialized)?; + let admin = config.admin; admin.require_auth(); if fee_bps > MAX_FEE_BPS { @@ -1479,7 +1607,10 @@ impl EscrowContract { /// Returns the active platform fee in basis points (0 when unset). pub fn get_fee_bps(env: Env) -> u32 { - Self::fee_bps(&env) + env.storage() + .instance() + .get(&DataKey::FeeBps) + .unwrap_or(0) } /// Returns the configured treasury address, if any. @@ -1560,11 +1691,12 @@ impl EscrowContract { job_id: u64, rescue_address: Address, ) -> Result<(), EscrowError> { - let admin: Address = env + let config: ContractConfig = env .storage() .instance() - .get(&DataKey::Admin) + .get(&DataKey::Config) .ok_or(EscrowError::NotInitialized)?; + let admin = config.admin; admin.require_auth(); let key = DataKey::Job(job_id); @@ -3145,4 +3277,641 @@ mod test { assert_eq!(config.required_signatures, 2); assert_eq!(config.signers.len(), 2); } + + // ───────────────────────────────────────────────────────────────────────── + // SC-ESC-016: Enforce Limit Restrictions on Maximum Milestone Partition Counts + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn test_add_milestones_up_to_limit_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + + // Adding exactly MAX_MILESTONES (12) should succeed + for _ in 0..MAX_MILESTONES { + cc.add_milestone(&1u64, &100i128); + } + + let job = cc.get_job(&1u64); + assert_eq!(job.milestones.len(), MAX_MILESTONES); + } + + #[test] + #[should_panic(expected = "Error(Contract, #21)")] + fn test_add_milestones_limit_exceeded() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + + // Fill up to the max + for _ in 0..MAX_MILESTONES { + cc.add_milestone(&1u64, &100i128); + } + + // The 13th milestone must fail with MaxMilestonesExceeded (#21) + cc.add_milestone(&1u64, &100i128); + } + + // ───────────────────────────────────────────────────────────────────────── + // SC-ESC-013: Verify State Machine Integrity across Multi-Milestone Gigs + // ───────────────────────────────────────────────────────────────────────── + + /// Full lifecycle: Setup → Funded → WIP → WIP → Completed. + /// Validates status, released_amount, remaining balance, and milestone + /// statuses at every intermediate step. + #[test] + fn test_multi_milestone_full_lifecycle_state_integrity() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + let tc = token::Client::new(&env, &token_addr); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + + // ── Setup phase ── + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Setup); + assert_eq!(job.milestones.len(), 0); + assert_eq!(job.total_amount, 0); + assert_eq!(job.released_amount, 0); + + cc.add_milestone(&1u64, &1000i128); + cc.add_milestone(&1u64, &2000i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &4000i128); + + let job = cc.get_job(&1u64); + assert_eq!(job.milestones.len(), 4); + assert_eq!(job.status, EscrowStatus::Setup); + + // ── Deposit → Funded ── + cc.deposit(&1u64, &10_000i128); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Funded); + assert_eq!(job.total_amount, 10_000); + assert_eq!(job.released_amount, 0); + assert_eq!(cc.get_escrow_balance(&1u64), 10_000); + assert_eq!(cc.get_remaining_balance(&1u64), 10_000); + assert_eq!(tc.balance(&contract_id), 10_000); + + // Verify all milestones are Pending + let statuses = cc.get_milestone_status(&1u64); + for i in 0..4u32 { + assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Pending); + } + + // ── Release milestone 0 → WorkInProgress ── + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 1000); + assert_eq!(cc.get_escrow_balance(&1u64), 9000); + assert_eq!(cc.get_remaining_balance(&1u64), 9000); + assert_eq!(tc.balance(&freelancer), 1000); + + let statuses = cc.get_milestone_status(&1u64); + assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released); + assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending); + assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending); + assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Pending); + + // ── Release milestone 1 → still WorkInProgress ── + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 3000); + assert_eq!(cc.get_remaining_balance(&1u64), 7000); + assert_eq!(tc.balance(&freelancer), 3000); + + // ── Release milestone 2 → still WorkInProgress ── + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 6000); + assert_eq!(cc.get_remaining_balance(&1u64), 4000); + assert_eq!(tc.balance(&freelancer), 6000); + + // ── Release milestone 3 → Completed ── + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Completed); + assert_eq!(job.released_amount, 10_000); + assert_eq!(cc.get_remaining_balance(&1u64), 0); + assert_eq!(tc.balance(&freelancer), 10_000); + assert_eq!(tc.balance(&contract_id), 0); + + // All milestones must be Released + let statuses = cc.get_milestone_status(&1u64); + for i in 0..4u32 { + assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released); + } + } + + /// Out-of-order release_funds (explicit index) with state checks at each step. + #[test] + fn test_out_of_order_release_funds_state_integrity() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + let tc = token::Client::new(&env, &token_addr); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &1500i128); + cc.add_milestone(&1u64, &2500i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &3000i128); + cc.deposit(&1u64, &10_000i128); + + // Release milestone index 3 first (out of order) + cc.release_funds(&1u64, &client, &3u32); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 3000); + let statuses = cc.get_milestone_status(&1u64); + assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Pending); + assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Released); + + // Release milestone index 1 + cc.release_funds(&1u64, &client, &1u32); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 5500); + assert_eq!(tc.balance(&freelancer), 5500); + + // Release milestone index 0 + cc.release_funds(&1u64, &client, &0u32); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 7000); + + // Release milestone index 2 — final → Completed + cc.release_funds(&1u64, &client, &2u32); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Completed); + assert_eq!(job.released_amount, 10_000); + assert_eq!(tc.balance(&freelancer), 10_000); + assert_eq!(tc.balance(&contract_id), 0); + + // All Released + let statuses = cc.get_milestone_status(&1u64); + for i in 0..4u32 { + assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released); + } + } + + /// Dispute raised mid-WIP after partial milestone releases; verifies balance + /// accounting is correct through dispute resolution. + #[test] + fn test_dispute_mid_wip_partial_milestones_balance_integrity() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + let tc = token::Client::new(&env, &token_addr); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &2000i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &10_000i128); + + // Release first two milestones + cc.release_milestone(&1u64, &client); // 2000 + cc.release_milestone(&1u64, &client); // 3000 + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 5000); + assert_eq!(tc.balance(&freelancer), 5000); + + // Raise dispute with 5000 remaining + cc.raise_dispute(&1u64, &freelancer); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Disputed); + assert_eq!(cc.get_remaining_balance(&1u64), 5000); + + // Milestone statuses: first two Released, third Pending + let statuses = cc.get_milestone_status(&1u64); + assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released); + assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Released); + assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending); + + // Resolve: 60/40 split of remaining 5000 + cc.resolve_dispute(&1u64, &3000i128, &2000i128); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Resolved); + // Total released: 5000 (milestones) + 5000 (resolution) = 10000 + assert_eq!(job.released_amount, 10_000); + assert_eq!(tc.balance(&freelancer), 8000); // 5000 + 3000 + assert_eq!(tc.balance(&client), 92_000); // 100000 - 10000 + 2000 + } + + /// Cancel brief in WorkInProgress state refunds only the unreleased portion. + #[test] + fn test_cancel_brief_wip_refunds_remaining_only() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + let tc = token::Client::new(&env, &token_addr); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &2000i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &10_000i128); + + // Release first milestone → WIP + cc.release_milestone(&1u64, &client); + assert_eq!(tc.balance(&freelancer), 2000); + assert_eq!(tc.balance(&client), 90_000); + + // Cancel brief — should refund remaining 8000 to client + cc.cancel_brief(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Refunded); + assert_eq!(job.released_amount, job.total_amount); // fully accounted + assert_eq!(tc.balance(&client), 98_000); // 90000 + 8000 + assert_eq!(tc.balance(&freelancer), 2000); // unchanged + assert_eq!(tc.balance(&contract_id), 0); // fully drained + } + + /// Amend milestones mid-WIP, then release amended milestones to Completed. + /// Validates the state machine remains coherent through amendment. + #[test] + fn test_amend_milestones_then_complete_state_integrity() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + let tc = token::Client::new(&env, &token_addr); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &2000i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &10_000i128); + + // Release first milestone + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 2000); + + // Amend remaining milestones: 3000+5000=8000 → 4000+4000=8000 + let new_amounts = soroban_sdk::vec![&env, 4000i128, 4000i128]; + cc.amend_milestones(&1u64, &new_amounts); + + // Verify structure: 1 Released + 2 new Pending + let job = cc.get_job(&1u64); + assert_eq!(job.milestones.len(), 3); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 2000); + + let statuses = cc.get_milestone_status(&1u64); + assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released); + assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending); + assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending); + + // Release remaining amended milestones + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 6000); + assert_eq!(tc.balance(&freelancer), 6000); + + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Completed); + assert_eq!(job.released_amount, 10_000); + assert_eq!(tc.balance(&freelancer), 10_000); + assert_eq!(tc.balance(&contract_id), 0); + } + + /// Getter consistency: get_escrow_balance and get_remaining_balance match + /// across every state transition in a multi-milestone lifecycle. + #[test] + fn test_getter_consistency_across_transitions() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &4000i128); + cc.deposit(&1u64, &10_000i128); + + // Both getters must agree at every step + assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64)); + assert_eq!(cc.get_escrow_balance(&1u64), 10_000); + + cc.release_milestone(&1u64, &client); + assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64)); + assert_eq!(cc.get_escrow_balance(&1u64), 7000); + + cc.release_milestone(&1u64, &client); + assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64)); + assert_eq!(cc.get_escrow_balance(&1u64), 4000); + + cc.release_milestone(&1u64, &client); + assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64)); + assert_eq!(cc.get_escrow_balance(&1u64), 0); + + // Final state + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Completed); + } + + /// Unauthorized callers are blocked from all state-mutating functions + /// on a multi-milestone job. + #[test] + fn test_unauthorized_state_mutations_blocked_multi_milestone() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + let rando = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &5000i128); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &10_000i128); + + // Release first milestone to enter WIP + cc.release_milestone(&1u64, &client); + assert_eq!(cc.get_job(&1u64).status, EscrowStatus::WorkInProgress); + + // Verify: release_milestone by rando → Unauthorized (#3) + let result = cc.try_release_milestone(&1u64, &rando); + assert!(result.is_err()); + + // Verify: release_funds by freelancer → Unauthorized (#3) + let result = cc.try_release_funds(&1u64, &freelancer, &1u32); + assert!(result.is_err()); + + // Verify: refund by freelancer → Unauthorized (#3) + let result = cc.try_refund(&1u64, &freelancer); + assert!(result.is_err()); + + // Verify: cancel_brief by rando → Unauthorized (#3) + let result = cc.try_cancel_brief(&1u64, &rando); + assert!(result.is_err()); + + // State is still WIP — no mutation occurred + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::WorkInProgress); + assert_eq!(job.released_amount, 5000); + } + + /// Invalid state transitions are blocked on multi-milestone jobs: + /// cannot release after dispute, cannot dispute after completion. + #[test] + fn test_invalid_transitions_blocked_multi_milestone() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + + // ── Test 1: Cannot release after dispute ── + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &3000i128); + cc.add_milestone(&1u64, &7000i128); + cc.deposit(&1u64, &10_000i128); + + cc.release_milestone(&1u64, &client); // WIP + cc.raise_dispute(&1u64, &client); // Disputed + assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Disputed); + + // release_milestone must fail in Disputed state + let result = cc.try_release_milestone(&1u64, &client); + assert!(result.is_err()); + + // release_funds must fail in Disputed state + let result = cc.try_release_funds(&1u64, &client, &1u32); + assert!(result.is_err()); + + // ── Test 2: Cannot dispute after completion ── + cc.create_job(&2u64, &client, &freelancer, &token_addr); + cc.add_milestone(&2u64, &5000i128); + cc.add_milestone(&2u64, &5000i128); + cc.deposit(&2u64, &10_000i128); + + cc.release_milestone(&2u64, &client); + cc.release_milestone(&2u64, &client); + assert_eq!(cc.get_job(&2u64).status, EscrowStatus::Completed); + + // raise_dispute must fail in Completed state + let result = cc.try_raise_dispute(&2u64, &client); + assert!(result.is_err()); + + // open_dispute must fail in Completed state + let result = cc.try_open_dispute(&2u64, &client); + assert!(result.is_err()); + } + + // ───────────────────────────────────────────────────────────────────────── + // SC-ESC-019: Dynamic Storage Allocation Recouping (State De-allocation) + // ───────────────────────────────────────────────────────────────────────── + #[test] + fn test_cleanup_job_completed() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &5000i128); + cc.release_milestone(&1u64, &client); + + assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Completed); + + // cleanup_job by client + cc.cleanup_job(&1u64, &client); + + // verify it is deleted + let result = cc.try_get_job(&1u64); + assert!(result.is_err()); + } + + #[test] + fn test_cleanup_job_invalid_state() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &5000i128); + + // Setup state + assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Setup); + let res = cc.try_cleanup_job(&1u64, &client); + assert!(res.is_err()); + + cc.deposit(&1u64, &5000i128); + // Funded state + assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Funded); + let res = cc.try_cleanup_job(&1u64, &client); + assert!(res.is_err()); + } + + #[test] + fn test_cleanup_job_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + let random = Address::generate(&env); + + let token_addr = setup_token(&env, &admin); + mint(&env, &token_addr, &client); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &5000i128); + cc.release_milestone(&1u64, &client); + + // Random tries to cleanup + let res = cc.try_cleanup_job(&1u64, &random); + assert!(res.is_err()); + } } diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index 1956aeee..4a5e781a 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -28,6 +28,7 @@ pub enum JobRegistryError { InvalidExpiration = 15, JobExpired = 16, JobNotExpired = 17, + InvalidCollateral = 18, } #[contracttype] @@ -39,6 +40,7 @@ pub enum JobStatus { Completed, Disputed, Expired, + Defaulted, } #[contracttype] @@ -60,6 +62,7 @@ pub struct JobRecord { pub struct BidRecord { pub freelancer: Address, pub proposal_hash: Bytes, + pub collateral_stroops: i128, } #[contracttype] @@ -176,9 +179,18 @@ impl JobRegistryContract { } /// Freelancer submits a bid. - pub fn submit_bid(env: Env, job_id: u64, freelancer: Address, proposal_hash: Bytes) { + pub fn submit_bid( + env: Env, + job_id: u64, + freelancer: Address, + proposal_hash: Bytes, + collateral_stroops: i128, + ) { ensure_initialized(&env); validate_hash(&env, &proposal_hash); + if collateral_stroops < 0 { + panic_with_error!(&env, JobRegistryError::InvalidCollateral); + } freelancer.require_auth(); let key = DataKey::Job(job_id); @@ -215,10 +227,17 @@ impl JobRegistryContract { bids.push_back(BidRecord { freelancer: freelancer.clone(), proposal_hash, + collateral_stroops, }); env.storage().persistent().set(&bids_key, &bids); - log!(&env, "submit_bid: id {} freelancer {}", job_id, freelancer); + log!( + &env, + "submit_bid: id {} freelancer {} collateral {}", + job_id, + freelancer, + collateral_stroops + ); env.events() .publish((symbol_short!("bid"), job_id), freelancer); } @@ -376,6 +395,83 @@ impl JobRegistryContract { env.events().publish((symbol_short!("dispute"), job_id), ()); } + pub fn enforce_default_slashing(env: Env, job_id: u64, client: Address) -> i128 { + ensure_initialized(&env); + client.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: JobRecord = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); + + // Strict ownership validation: only the job creator can enforce slashing + if client != job.client { + panic_with_error!(&env, JobRegistryError::Unauthorized); + } + + // Must be in Assigned state to default/slash + if job.status != JobStatus::Assigned { + panic_with_error!(&env, JobRegistryError::InvalidStateTransition); + } + + // Must be expired + let now = env.ledger().timestamp(); + if now < job.expires_at { + panic_with_error!(&env, JobRegistryError::JobNotExpired); + } + + let freelancer = job.freelancer.clone().unwrap_or_else(|| { + panic_with_error!(&env, JobRegistryError::Unauthorized) + }); + + // Find the accepted freelancer's bid to get their collateral amount + let bids: Vec = env + .storage() + .persistent() + .get(&DataKey::Bids(job_id)) + .unwrap_or(Vec::new(&env)); + + let mut collateral_stroops: i128 = 0; + let mut found = false; + for bid in bids.iter() { + if bid.freelancer == freelancer { + collateral_stroops = bid.collateral_stroops; + found = true; + break; + } + } + + if !found { + panic_with_error!(&env, JobRegistryError::BidNotFound); + } + + // Checked math operations for penalty slashing (100% of collateral) + let penalty_bps: i128 = 10_000; + let slashed_amount = collateral_stroops + .checked_mul(penalty_bps) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)) + .checked_div(10_000) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)); + + // Clean state transition to Defaulted + job.status = JobStatus::Defaulted; + env.storage().persistent().set(&key, &job); + + log!( + &env, + "enforce_default_slashing: id {} freelancer {} slashed {}", + job_id, + freelancer, + slashed_amount + ); + env.events() + .publish((symbol_short!("slash"), job_id), (freelancer, slashed_amount)); + + slashed_amount + } + pub fn get_job(env: Env, job_id: u64) -> JobRecord { ensure_initialized(&env); env.storage() @@ -602,10 +698,11 @@ mod test { assert_eq!(job.freelancer, None); let proposal = Bytes::from_slice(&env, b"QmProposalHash"); - cc.submit_bid(&1u64, &freelancer, &proposal); + cc.submit_bid(&1u64, &freelancer, &proposal, &1000i128); let bids = cc.get_bids(&1u64); assert_eq!(bids.len(), 1); + assert_eq!(bids.get(0).unwrap().collateral_stroops, 1000i128); cc.accept_bid(&1u64, &client, &freelancer); let job = cc.get_job(&1u64); @@ -633,8 +730,8 @@ mod test { cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); - cc.submit_bid(&1u64, &freelancer, &proposal); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); + cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); } #[test] @@ -660,7 +757,7 @@ mod test { cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + cc.submit_bid(&1u64, &freelancer, &proposal, &0i128); cc.accept_bid(&1u64, &client, &freelancer); cc.mark_disputed(&1u64); @@ -694,7 +791,7 @@ mod test { env.ledger().set_timestamp(expires_at + 1); let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); } #[test] @@ -708,7 +805,7 @@ mod test { cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal); + cc.submit_bid(&1u64, &freelancer, &proposal, &200i128); env.ledger().set_timestamp(expires_at + 1); cc.accept_bid(&1u64, &client, &freelancer); @@ -755,4 +852,104 @@ mod test { cc.get_deliverable(&1u64); } + + #[test] + fn test_enforce_default_slashing_success() { + let (env, cc, admin, client, freelancer) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmHash"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"QmProposal"); + cc.submit_bid(&1u64, &freelancer, &proposal, &12345i128); + + cc.accept_bid(&1u64, &client, &freelancer); + + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::Assigned); + + // Advance ledger timestamp to default threshold + env.ledger().set_timestamp(expires_at + 1); + + // Client triggers default and gets 100% of collateral slashed + let slashed = cc.enforce_default_slashing(&1u64, &client); + assert_eq!(slashed, 12345i128); + + let updated_job = cc.get_job(&1u64); + assert_eq!(updated_job.status, JobStatus::Defaulted); + } + + #[test] + #[should_panic] + fn test_enforce_default_slashing_before_expiration_panics() { + let (env, cc, admin, client, freelancer) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmHash"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"QmProposal"); + cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); + cc.accept_bid(&1u64, &client, &freelancer); + + // Calling enforce default slashing before job expires must fail + cc.enforce_default_slashing(&1u64, &client); + } + + #[test] + #[should_panic] + fn test_enforce_default_slashing_unauthorized_panics() { + let (env, cc, admin, client, freelancer) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmHash"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"QmProposal"); + cc.submit_bid(&1u64, &freelancer, &proposal, &200i128); + cc.accept_bid(&1u64, &client, &freelancer); + + env.ledger().set_timestamp(expires_at + 1); + + // A third-party address (represented by freelancer here) attempts to default + cc.enforce_default_slashing(&1u64, &freelancer); + } + + #[test] + #[should_panic] + fn test_enforce_default_slashing_invalid_state_panics() { + let (env, cc, admin, client, freelancer) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmHash"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"QmProposal"); + cc.submit_bid(&1u64, &freelancer, &proposal, &300i128); + + env.ledger().set_timestamp(expires_at + 1); + + // The job status is Open (not Assigned). Enforce default slashing should fail. + cc.enforce_default_slashing(&1u64, &client); + } + + #[test] + #[should_panic] + fn test_submit_bid_negative_collateral_panics() { + let (env, cc, admin, client, freelancer) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmHash"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"QmProposal"); + // Bid with negative collateral must panic + cc.submit_bid(&1u64, &freelancer, &proposal, &-100i128); + } } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index ec22f312..844d25ac 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -482,7 +482,6 @@ impl ReputationContract { let total_jobs = metrics.completed_jobs; let badge_level = metrics.badge_level; - profile.refresh_badges(); storage::write_profile(&env, &address, &profile); env.events().publish( ("reputation", "ScoreAdjusted"), @@ -515,7 +514,6 @@ impl ReputationContract { let total_jobs = metrics.completed_jobs; let badge_level = metrics.badge_level; - profile.refresh_badges(); storage::write_profile(&env, &address, &profile); env.events().publish( ("reputation", "ScoreAdjusted"), @@ -623,6 +621,52 @@ impl ReputationContract { is_blacklisted: profile.is_blacklisted, } } + + pub fn get_badge(env: Env, address: Address, role: Role) -> BadgeLevel { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + let score = Self::role_metrics(&profile, &role).score; + BadgeLevel::from_score(score) + } + + pub fn set_badge_metadata( + env: Env, + admin: Address, + address: Address, + tier: BadgeTier, + uri: Bytes, + ) { + Self::require_admin(&env, &admin); + let mut profile = storage::read_profile_or_default(&env, &address); + + let mut updated = false; + for i in 0..profile.badge_metadata.len() { + if let Some(mut entry) = profile.badge_metadata.get(i) { + if entry.tier == tier { + entry.uri = uri.clone(); + profile.badge_metadata.set(i, entry); + updated = true; + break; + } + } + } + if !updated { + profile.badge_metadata.push_back(BadgeMetadataEntry { tier, uri }); + } + storage::write_profile(&env, &address, &profile); + Self::bump_instance_ttl(&env); + } + + pub fn get_badge_metadata(env: Env, address: Address, tier: BadgeTier) -> Option { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + for entry in profile.badge_metadata.iter() { + if entry.tier == tier { + return Some(entry.uri); + } + } + None + } } #[cfg(test)] @@ -985,9 +1029,10 @@ mod test { let cid = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); // Raise score by 1000 → 5000+1000 = 6000 → Silver - client.update_score(&addr, &Role::Freelancer, &1000); + client.update_score(&admin, &addr, &Role::Freelancer, &1000); let badge = client.get_badge(&addr, &Role::Freelancer); assert_eq!(badge, BadgeLevel::Silver); } @@ -1001,8 +1046,9 @@ mod test { let cid = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); - client.update_score(&addr, &Role::Freelancer, &3000); // 5000+3000=8000 + client.update_score(&admin, &addr, &Role::Freelancer, &3000); // 5000+3000=8000 assert_eq!(client.get_badge(&addr, &Role::Freelancer), BadgeLevel::Gold); } @@ -1015,13 +1061,14 @@ mod test { let cid = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &cid); client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); // Bring to Gold first, then slash twice to drop back to Bronze - client.update_score(&addr, &Role::Client, &3000); // 8000 → Gold + client.update_score(&admin, &addr, &Role::Client, &3000); // 8000 → Gold assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Gold); - client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver + client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Silver); - client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze + client.slash(&admin, &addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Bronze); } diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index fc8a0afd..7599068b 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -99,14 +99,14 @@ pub struct Profile { } impl Profile { - pub fn new(address: Address) -> Self { + pub fn new(env: &soroban_sdk::Env, address: Address) -> Self { Self { address, client: RoleMetrics::new(), freelancer: RoleMetrics::new(), is_blacklisted: false, metadata_hash: None, - badge_metadata: soroban_sdk::Vec::new(_env), + badge_metadata: soroban_sdk::Vec::new(env), } } } diff --git a/contracts/reputation/src/storage.rs b/contracts/reputation/src/storage.rs index f20489cd..1c1242dc 100644 --- a/contracts/reputation/src/storage.rs +++ b/contracts/reputation/src/storage.rs @@ -23,7 +23,7 @@ pub fn read_profile(env: &Env, address: &Address) -> Option { } pub fn read_profile_or_default(env: &Env, address: &Address) -> Profile { - read_profile(env, address).unwrap_or_else(|| Profile::new(address.clone())) + read_profile(env, address).unwrap_or_else(|| Profile::new(env, address.clone())) } pub fn write_profile(env: &Env, address: &Address, profile: &Profile) {