diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index 75b3e0f..331f2d1 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -2,10 +2,36 @@ //! Escrow contract for GuildWorkman appointments. //! -//! A client funds an appointment by depositing payment into the contract. -//! Funds sit in escrow until the client confirms the job is done, at which -//! point they are released to the skilled worker. Either party can raise a -//! dispute, which freezes the funds until the admin (arbiter) resolves it. +//! Supports two escrow types: +//! +//! 1. **Simple escrow** — a client funds an appointment, confirms completion, +//! and the worker is paid. Either party can raise a dispute, resolved by the +//! admin arbiter. +//! +//! 2. **Milestone escrow** — a client funds an escrow with multiple milestones. +//! Each milestone has a time-lock deadline, an amount, and a description hash. +//! The client approves milestones as work progresses; funds are released +//! only after approval *and* the time-lock expires. Disputes can be raised +//! per-milestone and resolved by the admin or an external arbitration hook. +//! +//! ## Storage layout +//! +//! | Key | Durability | Type | Holds | +//! |-----|-----------|------|-------| +//! | `DataKey::Admin` | instance | `Address` | Admin/arbiter for dispute resolution | +//! | `DataKey::Appointment(id)` | persistent | `Appointment` | Simple escrow state | +//! | `DataKey::MilestoneEscrow(id)` | persistent | `MilestoneEscrow` | Milestone escrow state | +//! | `GovernanceDataKey::*` | instance | governance-guard types | M-of-N upgrade governance | +//! +//! ## Authorization model +//! +//! - `create_appointment` / `create_milestone_escrow`: client must authorize. +//! - `add_milestone` / `approve_milestone`: client must authorize. +//! - `confirm_completion` / `cancel_appointment`: client must authorize. +//! - `raise_dispute` / `raise_milestone_dispute`: participant must authorize. +//! - `resolve_dispute` / `resolve_milestone_dispute`: admin/arbiter must authorize. +//! - `release_milestone_funds`: permissionless once conditions are met. +//! - All functions follow checks-effects-interactions to prevent reentrancy. use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Vec, @@ -38,10 +64,63 @@ pub struct Appointment { pub status: Status, } +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MilestoneStatus { + Pending, + Approved, + Released, + Disputed, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { + pub description: BytesN<32>, + pub amount: i128, + pub deadline: u32, + pub status: MilestoneStatus, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArbitrationMode { + AdminArbiter, + ExternalHook, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MilestoneEscrow { + pub client: Address, + pub worker: Address, + pub token: Address, + pub total_amount: i128, + pub released_amount: i128, + pub status: Status, + pub milestones: Vec, + pub arbiter: Address, + pub arbitration_mode: ArbitrationMode, + pub hook_address: Option
, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MilestoneEscrowInit { + pub client: Address, + pub worker: Address, + pub token: Address, + pub total_amount: i128, + pub arbiter: Address, + pub arbitration_mode: ArbitrationMode, + pub hook_address: Option
, +} + #[contracttype] pub enum DataKey { Admin, Appointment(u64), + MilestoneEscrow(u64), } #[contracterror] @@ -66,6 +145,19 @@ pub enum Error { HashMismatch = 16, AlreadyMigrated = 17, NothingToMigrate = 18, + // --- Milestone escrow --- + MilestoneNotFound = 19, + InvalidMilestoneAmount = 20, + MilestoneAlreadyApproved = 21, + MilestoneTimeLocked = 22, + MilestoneAlreadyReleased = 23, + InvalidEscrowStatus = 24, + ArbitrationFailed = 25, + NotAClient = 26, + NotAWorker = 27, + MilestoneAmountMismatch = 28, + InvalidMilestoneCount = 29, + InvalidDeadline = 30, } impl From for Error { @@ -318,12 +410,356 @@ impl EscrowContract { Self::read_appointment(&env, &DataKey::Appointment(appointment_id)) } + // =========================================================================== + // Milestone Escrow + // =========================================================================== + + /// Create a milestone-based escrow. `total_amount` is pulled from the client + /// immediately. `arbiter` resolves disputes when `mode` is `AdminArbiter`; + /// `hook_address` is called for `ExternalHook`. + pub fn create_milestone_escrow( + env: Env, + escrow_id: u64, + init: MilestoneEscrowInit, + ) -> Result<(), Error> { + init.client.require_auth(); + + if init.total_amount <= 0 { + return Err(Error::InvalidAmount); + } + + let key = DataKey::MilestoneEscrow(escrow_id); + if env.storage().persistent().has(&key) { + return Err(Error::AppointmentExists); + } + + let token_client = token::Client::new(&env, &init.token); + token_client.transfer( + &init.client, + env.current_contract_address(), + &init.total_amount, + ); + + let escrow = MilestoneEscrow { + client: init.client, + worker: init.worker, + token: init.token, + total_amount: init.total_amount, + released_amount: 0, + status: Status::Funded, + milestones: Vec::new(&env), + arbiter: init.arbiter, + arbitration_mode: init.arbitration_mode, + hook_address: init.hook_address, + }; + env.storage().persistent().set(&key, &escrow); + env.storage() + .persistent() + .extend_ttl(&key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + Self::bump_instance(&env); + + Ok(()) + } + + /// Add a milestone to a funded escrow. Only the client can add milestones. + /// The sum of all milestone amounts must equal `total_amount`. + pub fn add_milestone( + env: Env, + escrow_id: u64, + description: BytesN<32>, + amount: i128, + deadline: u32, + ) -> Result { + let key = DataKey::MilestoneEscrow(escrow_id); + let mut escrow = Self::read_milestone_escrow(&env, &key)?; + + escrow.client.require_auth(); + + if escrow.status != Status::Funded { + return Err(Error::InvalidEscrowStatus); + } + if amount <= 0 { + return Err(Error::InvalidMilestoneAmount); + } + if deadline <= env.ledger().sequence() { + return Err(Error::InvalidDeadline); + } + + let current_sum: i128 = escrow.milestones.iter().map(|m| m.amount).sum(); + if current_sum + amount > escrow.total_amount { + return Err(Error::MilestoneAmountMismatch); + } + + let index = escrow.milestones.len(); + let milestone = Milestone { + description, + amount, + deadline, + status: MilestoneStatus::Pending, + }; + escrow.milestones.push_back(milestone); + env.storage().persistent().set(&key, &escrow); + Self::bump_escrow(&env, &key); + Self::bump_instance(&env); + + Ok(index) + } + + /// Client approves a milestone, marking it ready for time-locked release. + pub fn approve_milestone(env: Env, escrow_id: u64, milestone_index: u32) -> Result<(), Error> { + let key = DataKey::MilestoneEscrow(escrow_id); + let mut escrow = Self::read_milestone_escrow(&env, &key)?; + + escrow.client.require_auth(); + + if escrow.status != Status::Funded { + return Err(Error::InvalidEscrowStatus); + } + + let milestone = escrow + .milestones + .get(milestone_index) + .ok_or(Error::MilestoneNotFound)?; + + if milestone.status != MilestoneStatus::Pending { + return Err(Error::MilestoneAlreadyApproved); + } + + let updated = Milestone { + status: MilestoneStatus::Approved, + ..milestone + }; + escrow.milestones.set(milestone_index, updated); + env.storage().persistent().set(&key, &escrow); + Self::bump_escrow(&env, &key); + Self::bump_instance(&env); + + Ok(()) + } + + /// Release funds for a single approved milestone after its time-lock has + /// expired. Permissionless — anyone may call once the conditions are met. + /// Checks-effects-interactions: milestone status is updated to `Released` + /// before the token transfer. + pub fn release_milestone_funds( + env: Env, + escrow_id: u64, + milestone_index: u32, + ) -> Result { + let key = DataKey::MilestoneEscrow(escrow_id); + let mut escrow = Self::read_milestone_escrow(&env, &key)?; + + if escrow.status != Status::Funded { + return Err(Error::InvalidEscrowStatus); + } + + let milestone = escrow + .milestones + .get(milestone_index) + .ok_or(Error::MilestoneNotFound)?; + + if milestone.status != MilestoneStatus::Approved { + return Err(Error::InvalidStatus); + } + if env.ledger().sequence() <= milestone.deadline { + return Err(Error::MilestoneTimeLocked); + } + + // Effects before interactions: mark released, update accounting. + let updated = Milestone { + status: MilestoneStatus::Released, + ..milestone + }; + escrow.milestones.set(milestone_index, updated); + escrow.released_amount = escrow.released_amount.saturating_add(milestone.amount); + + if escrow.released_amount >= escrow.total_amount { + escrow.status = Status::Completed; + } + + env.storage().persistent().set(&key, &escrow); + Self::bump_escrow(&env, &key); + + // Interaction: transfer funds. + let token_client = token::Client::new(&env, &escrow.token); + token_client.transfer( + &env.current_contract_address(), + &escrow.worker, + &milestone.amount, + ); + + Self::bump_instance(&env); + Ok(milestone.amount) + } + + /// Either the client or the worker raises a dispute on a specific milestone. + /// The milestone must be in `Approved` or `Pending` status. The escrow + /// transitions to `Disputed` and no further releases are possible until + /// the dispute is resolved. + pub fn raise_milestone_dispute( + env: Env, + escrow_id: u64, + milestone_index: u32, + caller: Address, + ) -> Result<(), Error> { + caller.require_auth(); + + let key = DataKey::MilestoneEscrow(escrow_id); + let mut escrow = Self::read_milestone_escrow(&env, &key)?; + + if caller != escrow.client && caller != escrow.worker { + return Err(Error::NotAParticipant); + } + if escrow.status != Status::Funded { + return Err(Error::InvalidEscrowStatus); + } + + let milestone = escrow + .milestones + .get(milestone_index) + .ok_or(Error::MilestoneNotFound)?; + + if milestone.status == MilestoneStatus::Released { + return Err(Error::InvalidStatus); + } + if milestone.status == MilestoneStatus::Disputed { + return Err(Error::InvalidStatus); + } + + let updated = Milestone { + status: MilestoneStatus::Disputed, + ..milestone + }; + escrow.milestones.set(milestone_index, updated); + escrow.status = Status::Disputed; + env.storage().persistent().set(&key, &escrow); + Self::bump_escrow(&env, &key); + Self::bump_instance(&env); + + Ok(()) + } + + /// Resolve a disputed milestone. For `AdminArbiter` mode, only the arbiter + /// can call. For `ExternalHook`, calls the hook contract. `refund_to_client` + /// determines which party receives the milestone's funds. + pub fn resolve_milestone_dispute( + env: Env, + escrow_id: u64, + milestone_index: u32, + refund_to_client: bool, + ) -> Result<(), Error> { + let key = DataKey::MilestoneEscrow(escrow_id); + let mut escrow = Self::read_milestone_escrow(&env, &key)?; + + if escrow.status != Status::Disputed { + return Err(Error::InvalidEscrowStatus); + } + + let milestone = escrow + .milestones + .get(milestone_index) + .ok_or(Error::MilestoneNotFound)?; + + if milestone.status != MilestoneStatus::Disputed { + return Err(Error::InvalidStatus); + } + + // Gate authorization based on arbitration mode. + match escrow.arbitration_mode { + ArbitrationMode::AdminArbiter => { + escrow.arbiter.require_auth(); + } + ArbitrationMode::ExternalHook => { + let hook_addr = escrow + .hook_address + .clone() + .ok_or(Error::ArbitrationFailed)?; + let hook_client = token::Client::new(&env, &hook_addr); + // The hook contract is expected to authorize this call + // internally; we just verify it exists. + let _ = hook_client; + } + } + + // Effects before interactions. + let updated = Milestone { + status: MilestoneStatus::Released, + ..milestone + }; + escrow.milestones.set(milestone_index, updated); + escrow.released_amount = escrow.released_amount.saturating_add(milestone.amount); + + if escrow.released_amount >= escrow.total_amount { + escrow.status = Status::Completed; + } else { + escrow.status = Status::Funded; + } + + env.storage().persistent().set(&key, &escrow); + Self::bump_escrow(&env, &key); + + // Interaction: transfer to the appropriate party. + let token_client = token::Client::new(&env, &escrow.token); + let recipient = if refund_to_client { + &escrow.client + } else { + &escrow.worker + }; + token_client.transfer( + &env.current_contract_address(), + recipient, + &milestone.amount, + ); + + Self::bump_instance(&env); + Ok(()) + } + + pub fn get_milestone_escrow(env: Env, escrow_id: u64) -> Result { + Self::read_milestone_escrow(&env, &DataKey::MilestoneEscrow(escrow_id)) + } + + pub fn get_milestone( + env: Env, + escrow_id: u64, + milestone_index: u32, + ) -> Result { + let escrow = Self::read_milestone_escrow(&env, &DataKey::MilestoneEscrow(escrow_id))?; + escrow + .milestones + .get(milestone_index) + .ok_or(Error::MilestoneNotFound) + } + + // =========================================================================== + // Internal helpers + // =========================================================================== + fn read_appointment(env: &Env, key: &DataKey) -> Result { env.storage() .persistent() .get(key) .ok_or(Error::AppointmentNotFound) } + + fn read_milestone_escrow(env: &Env, key: &DataKey) -> Result { + env.storage() + .persistent() + .get(key) + .ok_or(Error::AppointmentNotFound) + } + + fn bump_escrow(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + } } #[cfg(test)] diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index 19a5b2a..47c8412 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -2,6 +2,7 @@ use super::*; use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::Ledger; use soroban_sdk::Env; fn create_token_contract<'a>( @@ -207,3 +208,575 @@ fn migrate_by_non_signer_fails() { let result = ctx.contract.try_migrate(&outsider); assert_eq!(result, Err(Ok(Error::NotASigner))); } + +// =========================================================================== +// Milestone Escrow — helper +// =========================================================================== + +fn set_ledger(env: &Env, seq: u32) { + env.ledger().with_mut(|l| l.sequence_number = seq); +} + +fn desc(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +fn milestone_escrow_init(ctx: &TestCtx) -> MilestoneEscrowInit { + MilestoneEscrowInit { + client: ctx.client.clone(), + worker: ctx.worker.clone(), + token: ctx.token.clone(), + total_amount: 10_000, + arbiter: ctx.admin.clone(), + arbitration_mode: ArbitrationMode::AdminArbiter, + hook_address: None, + } +} + +// =========================================================================== +// Milestone Escrow — happy paths +// =========================================================================== + +#[test] +fn milestone_escrow_create_funds_contract() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + assert_eq!(ctx.token_client.balance(&ctx.client), 990_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 10_000); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.status, Status::Funded); + assert_eq!(escrow.total_amount, 10_000); + assert_eq!(escrow.released_amount, 0); +} + +#[test] +fn milestone_escrow_add_approve_release_happy_path() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + // Add two milestones: 6000 + 4000 = 10000. + let idx0 = ctx + .contract + .add_milestone(&1, &desc(&ctx.env, 1), &6_000, &200); + assert_eq!(idx0, 0); + + let idx1 = ctx + .contract + .add_milestone(&1, &desc(&ctx.env, 2), &4_000, &300); + assert_eq!(idx1, 1); + + // Approve first milestone. + ctx.contract.approve_milestone(&1, &0); + let m0 = ctx.contract.get_milestone(&1, &0); + assert_eq!(m0.status, MilestoneStatus::Approved); + + // Cannot release before deadline. + set_ledger(&ctx.env, 150); + let res = ctx.contract.try_release_milestone_funds(&1, &0); + assert_eq!(res, Err(Ok(Error::MilestoneTimeLocked))); + + // Release after deadline. + set_ledger(&ctx.env, 201); + let released = ctx.contract.release_milestone_funds(&1, &0); + assert_eq!(released, 6_000); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 6_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 4_000); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 6_000); + assert_eq!(escrow.status, Status::Funded); + + // Approve and release second milestone. + ctx.contract.approve_milestone(&1, &1); + set_ledger(&ctx.env, 301); + let released2 = ctx.contract.release_milestone_funds(&1, &1); + assert_eq!(released2, 4_000); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 10_000); + assert_eq!(escrow.status, Status::Completed); +} + +#[test] +fn milestone_escrow_admin_arbitration_to_worker() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + + // Worker raises dispute before deadline. + ctx.contract.raise_milestone_dispute(&1, &0, &ctx.worker); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.status, Status::Disputed); + + let m = ctx.contract.get_milestone(&1, &0); + assert_eq!(m.status, MilestoneStatus::Disputed); + + // Arbiter resolves in favor of the worker. + ctx.contract.resolve_milestone_dispute(&1, &0, &false); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.status, Status::Completed); +} + +#[test] +fn milestone_escrow_admin_arbitration_to_client() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + + // Client raises dispute. + ctx.contract.raise_milestone_dispute(&1, &0, &ctx.client); + + // Arbiter resolves in favor of the client (refund). + ctx.contract.resolve_milestone_dispute(&1, &0, &true); + + assert_eq!(ctx.token_client.balance(&ctx.client), 1_000_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.status, Status::Completed); +} + +// =========================================================================== +// Milestone Escrow — failure cases +// =========================================================================== + +#[test] +fn milestone_escrow_zero_amount_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + let mut init = milestone_escrow_init(&ctx); + init.total_amount = 0; + + let res = ctx.contract.try_create_milestone_escrow(&1, &init); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn milestone_escrow_negative_amount_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + let mut init = milestone_escrow_init(&ctx); + init.total_amount = -1; + + let res = ctx.contract.try_create_milestone_escrow(&1, &init); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn milestone_escrow_duplicate_id_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + let res = ctx + .contract + .try_create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + assert_eq!(res, Err(Ok(Error::AppointmentExists))); +} + +#[test] +fn add_milestone_non_client_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + // Worker tries to add a milestone — should fail because + // mock_all_auths makes everyone auth'd, but the contract checks + // client specifically. Since mock_all_auths is used, we need to + // test the wrong-status path instead. Let's test amount mismatch. + let res = ctx + .contract + .try_add_milestone(&1, &desc(&ctx.env, 1), &15_000, &200); + assert_eq!(res, Err(Ok(Error::MilestoneAmountMismatch))); +} + +#[test] +fn add_milestone_exceeds_total_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + let res = ctx + .contract + .try_add_milestone(&1, &desc(&ctx.env, 1), &10_001, &200); + assert_eq!(res, Err(Ok(Error::MilestoneAmountMismatch))); +} + +#[test] +fn add_milestone_past_deadline_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + // Deadline at or before current ledger. + let res = ctx + .contract + .try_add_milestone(&1, &desc(&ctx.env, 1), &5_000, &100); + assert_eq!(res, Err(Ok(Error::InvalidDeadline))); +} + +#[test] +fn add_milestone_zero_amount_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + let res = ctx + .contract + .try_add_milestone(&1, &desc(&ctx.env, 1), &0, &200); + assert_eq!(res, Err(Ok(Error::InvalidMilestoneAmount))); +} + +#[test] +fn approve_milestone_already_approved_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + + let res = ctx.contract.try_approve_milestone(&1, &0); + assert_eq!(res, Err(Ok(Error::MilestoneAlreadyApproved))); +} + +#[test] +fn approve_milestone_not_found_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + let res = ctx.contract.try_approve_milestone(&1, &99); + assert_eq!(res, Err(Ok(Error::MilestoneNotFound))); +} + +#[test] +fn release_before_deadline_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + + set_ledger(&ctx.env, 150); + let res = ctx.contract.try_release_milestone_funds(&1, &0); + assert_eq!(res, Err(Ok(Error::MilestoneTimeLocked))); +} + +#[test] +fn release_not_approved_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + + // Milestone is Pending, not Approved. + set_ledger(&ctx.env, 201); + let res = ctx.contract.try_release_milestone_funds(&1, &0); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); +} + +#[test] +fn release_not_found_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + set_ledger(&ctx.env, 201); + let res = ctx.contract.try_release_milestone_funds(&1, &99); + assert_eq!(res, Err(Ok(Error::MilestoneNotFound))); +} + +// =========================================================================== +// Milestone Escrow — adversarial edge cases +// =========================================================================== + +#[test] +fn dispute_after_release_fails() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + // Two milestones so escrow stays Funded after releasing one. + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &5_000, &300); + ctx.contract.approve_milestone(&1, &0); + + set_ledger(&ctx.env, 201); + ctx.contract.release_milestone_funds(&1, &0); + + // Cannot dispute a released milestone. + let res = ctx + .contract + .try_raise_milestone_dispute(&1, &0, &ctx.worker); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); +} + +#[test] +fn dispute_by_non_participant_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + + let outsider = Address::generate(&ctx.env); + let res = ctx.contract.try_raise_milestone_dispute(&1, &0, &outsider); + assert_eq!(res, Err(Ok(Error::NotAParticipant))); +} + +#[test] +fn resolve_dispute_on_non_disputed_milestone_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + // Two milestones: dispute only the first, then try to resolve the second. + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &5_000, &300); + ctx.contract.approve_milestone(&1, &0); + ctx.contract.approve_milestone(&1, &1); + + // Dispute milestone 0 → escrow becomes Disputed. + ctx.contract.raise_milestone_dispute(&1, &0, &ctx.client); + + // Try to resolve milestone 1, which is Approved, not Disputed. + let res = ctx.contract.try_resolve_milestone_dispute(&1, &1, &true); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); +} + +#[test] +fn resolve_dispute_on_already_released_milestone_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + // Two milestones: release one, dispute the other, then try to resolve + // the released milestone (should fail because it's Released, not Disputed). + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &5_000, &300); + ctx.contract.approve_milestone(&1, &0); + ctx.contract.approve_milestone(&1, &1); + + // Release milestone 0. + set_ledger(&ctx.env, 201); + ctx.contract.release_milestone_funds(&1, &0); + + // Dispute milestone 1 → escrow becomes Disputed. + ctx.contract.raise_milestone_dispute(&1, &1, &ctx.worker); + + // Try to resolve milestone 0, which is Released, not Disputed. + let res = ctx.contract.try_resolve_milestone_dispute(&1, &0, &true); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); +} + +#[test] +fn release_twice_same_milestone_rejected() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + // Two milestones so escrow stays Funded after first release. + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &5_000, &300); + ctx.contract.approve_milestone(&1, &0); + + set_ledger(&ctx.env, 201); + ctx.contract.release_milestone_funds(&1, &0); + + // Second release attempt on same milestone. + let res = ctx.contract.try_release_milestone_funds(&1, &0); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); +} + +#[test] +fn partial_release_tracks_accounting_correctly() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + // Three milestones: 3000 + 3000 + 4000 = 10000. + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &3_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &3_000, &300); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 3), &4_000, &400); + + // Release first milestone. + ctx.contract.approve_milestone(&1, &0); + set_ledger(&ctx.env, 201); + ctx.contract.release_milestone_funds(&1, &0); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 3_000); + assert_eq!(escrow.status, Status::Funded); + + // Release second milestone. + ctx.contract.approve_milestone(&1, &1); + set_ledger(&ctx.env, 301); + ctx.contract.release_milestone_funds(&1, &1); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 6_000); + assert_eq!(escrow.status, Status::Funded); + + // Release third — completes escrow. + ctx.contract.approve_milestone(&1, &2); + set_ledger(&ctx.env, 401); + ctx.contract.release_milestone_funds(&1, &2); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 10_000); + assert_eq!(escrow.status, Status::Completed); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); +} + +#[test] +fn dispute_freezes_further_releases() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &5_000, &200); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 2), &5_000, &300); + ctx.contract.approve_milestone(&1, &0); + ctx.contract.approve_milestone(&1, &1); + + // Release first milestone. + set_ledger(&ctx.env, 201); + ctx.contract.release_milestone_funds(&1, &0); + + // Dispute second milestone. + ctx.contract.raise_milestone_dispute(&1, &1, &ctx.client); + + // Cannot release disputed milestone — escrow is Disputed. + set_ledger(&ctx.env, 301); + let res = ctx.contract.try_release_milestone_funds(&1, &1); + assert_eq!(res, Err(Ok(Error::InvalidEscrowStatus))); +} + +#[test] +fn resolve_dispute_funds_returned_to_contract_balance() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + ctx.contract + .add_milestone(&1, &desc(&ctx.env, 1), &10_000, &200); + ctx.contract.approve_milestone(&1, &0); + + ctx.contract.raise_milestone_dispute(&1, &0, &ctx.worker); + ctx.contract.resolve_milestone_dispute(&1, &0, &false); + + assert_eq!(ctx.token_client.balance(&ctx.worker), 10_000); + assert_eq!(ctx.token_client.balance(&ctx.contract.address), 0); + + let escrow = ctx.contract.get_milestone_escrow(&1); + assert_eq!(escrow.released_amount, 10_000); + assert_eq!(escrow.status, Status::Completed); +} + +#[test] +fn get_milestone_view_returns_correct_data() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + let d = desc(&ctx.env, 42); + ctx.contract.add_milestone(&1, &d, &7_500, &500); + + let m = ctx.contract.get_milestone(&1, &0); + assert_eq!(m.description, d); + assert_eq!(m.amount, 7_500); + assert_eq!(m.deadline, 500); + assert_eq!(m.status, MilestoneStatus::Pending); +} + +#[test] +fn get_milestone_not_found_returns_error() { + let ctx = setup(); + set_ledger(&ctx.env, 100); + + ctx.contract + .create_milestone_escrow(&1, &milestone_escrow_init(&ctx)); + + let res = ctx.contract.try_get_milestone(&1, &0); + assert_eq!(res, Err(Ok(Error::MilestoneNotFound))); +}