From f56933d85e62c2833c70169600cf8bcb5889af08 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Wed, 27 May 2026 00:15:46 +0100 Subject: [PATCH] feat(escrow): add raise_dispute and resolve_dispute --- contracts/escrow/src/dispute.rs | 73 +++++ contracts/escrow/src/lib.rs | 268 +++++++++++++---- contracts/escrow/src/test/dispute.rs | 427 ++++++++++++++------------- contracts/escrow/src/test/mod.rs | 1 + contracts/escrow/src/types.rs | 2 + docs/escrow/dispute-resolution.md | 269 +++++++---------- 6 files changed, 617 insertions(+), 423 deletions(-) create mode 100644 contracts/escrow/src/dispute.rs diff --git a/contracts/escrow/src/dispute.rs b/contracts/escrow/src/dispute.rs new file mode 100644 index 0000000..9b2ef73 --- /dev/null +++ b/contracts/escrow/src/dispute.rs @@ -0,0 +1,73 @@ +use soroban_sdk::contracttype; + +use crate::{safe_add_amounts, ContractStatus, EscrowContractData, EscrowError}; + +/// Resolution selected by the assigned arbiter for a disputed escrow. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeResolution { + /// Refund all remaining escrowed funds to the client. + FullRefund, + /// Refund 70% of the remaining balance to the client and release 30% to the freelancer. + PartialRefund, + /// Release all remaining escrowed funds to the freelancer. + FullPayout, + /// Apply a custom split of the remaining balance. + Split(i128, i128), +} + +impl DisputeResolution { + pub fn code(&self) -> u32 { + match self { + Self::FullRefund => 0, + Self::PartialRefund => 1, + Self::FullPayout => 2, + Self::Split(_, _) => 3, + } + } +} + +pub fn resolution_payouts( + contract: &EscrowContractData, + resolution: &DisputeResolution, +) -> Result<(i128, i128), EscrowError> { + let available = contract + .total_deposited + .checked_sub(contract.released_amount) + .and_then(|value| value.checked_sub(contract.refunded_amount)) + .ok_or(EscrowError::AccountingInvariantViolated)?; + if available < 0 { + return Err(EscrowError::AccountingInvariantViolated); + } + + match resolution { + DisputeResolution::FullRefund => Ok((available, 0)), + DisputeResolution::PartialRefund => { + let freelancer_payout = available + .checked_mul(30) + .and_then(|value| value.checked_div(100)) + .ok_or(EscrowError::PotentialOverflow)?; + Ok((available - freelancer_payout, freelancer_payout)) + } + DisputeResolution::FullPayout => Ok((0, available)), + DisputeResolution::Split(client_amount, freelancer_amount) => { + if *client_amount < 0 || *freelancer_amount < 0 { + return Err(EscrowError::InvalidDisputeSplit); + } + let total = safe_add_amounts(*client_amount, *freelancer_amount) + .ok_or(EscrowError::PotentialOverflow)?; + if total != available { + return Err(EscrowError::InvalidDisputeSplit); + } + Ok((*client_amount, *freelancer_amount)) + } + } +} + +pub fn final_status_after_resolution(contract: &EscrowContractData) -> ContractStatus { + if contract.refunded_amount == contract.total_deposited { + ContractStatus::Refunded + } else { + ContractStatus::Completed + } +} diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 696afbf..10c6d48 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -34,6 +34,9 @@ pub use types::{ MilestoneSummary, ReadinessChecklist, CONTRACT_SUMMARY_SCHEMA_VERSION, }; +mod dispute; +pub use dispute::DisputeResolution; + mod amount_validation; pub use amount_validation::{safe_add_amounts, safe_subtract_amounts, AmountValidationError}; @@ -113,6 +116,82 @@ pub struct Escrow; #[contractimpl] impl Escrow { + fn create_contract_internal( + env: &Env, + client: Address, + freelancer: Address, + arbiter: Option
, + milestone_amounts: Vec, + deposit_mode: DepositMode, + ) -> u32 { + if client == freelancer { + env.panic_with_error(EscrowError::InvalidParticipant); + } + if let Some(arbiter_addr) = arbiter.clone() { + if arbiter_addr == client || arbiter_addr == freelancer { + env.panic_with_error(EscrowError::InvalidParticipant); + } + } + if milestone_amounts.is_empty() { + env.panic_with_error(EscrowError::EmptyMilestones); + } + if milestone_amounts.len() > MAX_MILESTONES { + env.panic_with_error(EscrowError::TooManyMilestones); + } + + let mut total: i128 = 0; + for i in 0..milestone_amounts.len() { + let amt = milestone_amounts.get(i).unwrap(); + if amt <= 0 { + env.panic_with_error(EscrowError::InvalidMilestoneAmount); + } + total = safe_add_amounts(total, amt) + .unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow)); + } + if total > MAX_TOTAL_ESCROW_STROOPS { + env.panic_with_error(EscrowError::InvalidMilestoneAmount); + } + + let id: u32 = env + .storage() + .persistent() + .get(&DataKey::NextContractId) + .unwrap_or(1); + env.storage() + .persistent() + .set(&DataKey::NextContractId, &(id + 1)); + + let data = EscrowContractData { + client: client.clone(), + freelancer: freelancer.clone(), + arbiter, + milestones: milestone_amounts, + status: ContractStatus::Created, + total_deposited: 0, + released_amount: 0, + refunded_amount: 0, + reputation_issued: false, + deposit_mode, + }; + env.storage() + .persistent() + .set(&DataKey::Contract(id), &data); + + Self::emit_audit_event( + env, + id, + ContractStatus::Created, + ContractStatus::Created, + &client, + ); + + env.events().publish( + (symbol_short!("created"), id), + (client, freelancer, env.ledger().timestamp()), + ); + id + } + // ─── Guard ─────────────────────────────────────────────────────────────── /// Panics with `ContractPaused` if the contract is paused or in emergency. @@ -375,68 +454,36 @@ impl Escrow { Self::require_not_paused(&env); client.require_auth(); - if client == freelancer { - env.panic_with_error(EscrowError::InvalidParticipant); - } - if milestone_amounts.is_empty() { - env.panic_with_error(EscrowError::EmptyMilestones); - } - if milestone_amounts.len() > MAX_MILESTONES { - env.panic_with_error(EscrowError::TooManyMilestones); - } - - let mut total: i128 = 0; - for i in 0..milestone_amounts.len() { - let amt = milestone_amounts.get(i).unwrap(); - if amt <= 0 { - env.panic_with_error(EscrowError::InvalidMilestoneAmount); - } - total = safe_add_amounts(total, amt) - .unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow)); - } - if total > MAX_TOTAL_ESCROW_STROOPS { - env.panic_with_error(EscrowError::InvalidMilestoneAmount); - } - - let id: u32 = env - .storage() - .persistent() - .get(&DataKey::NextContractId) - .unwrap_or(1); - env.storage() - .persistent() - .set(&DataKey::NextContractId, &(id + 1)); - - let data = EscrowContractData { - client: client.clone(), - freelancer: freelancer.clone(), - arbiter: None, - milestones: milestone_amounts, - status: ContractStatus::Created, - total_deposited: 0, - released_amount: 0, - refunded_amount: 0, - reputation_issued: false, + Self::create_contract_internal( + &env, + client, + freelancer, + None, + milestone_amounts, deposit_mode, - }; - env.storage() - .persistent() - .set(&DataKey::Contract(id), &data); + ) + } - // Audit: contract created - Self::emit_audit_event( - &env, - id, - ContractStatus::Created, - ContractStatus::Created, - &client, - ); + /// Create a new escrow contract with an assigned arbiter for dispute resolution. + pub fn create_contract_with_arbiter( + env: Env, + client: Address, + freelancer: Address, + arbiter: Address, + milestone_amounts: Vec, + deposit_mode: DepositMode, + ) -> u32 { + Self::require_not_paused(&env); + client.require_auth(); - env.events().publish( - (symbol_short!("created"), id), - (client, freelancer, env.ledger().timestamp()), - ); - id + Self::create_contract_internal( + &env, + client, + freelancer, + Some(arbiter), + milestone_amounts, + deposit_mode, + ) } /// Deposit funds into an escrow contract. Blocked when paused. @@ -514,6 +561,12 @@ impl Escrow { .get::<_, EscrowContractData>(&key) .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)); + if contract.status != ContractStatus::Funded + && contract.status != ContractStatus::PartiallyFunded + { + env.panic_with_error(EscrowError::InvalidStatusTransition); + } + if milestone_index >= contract.milestones.len() { env.panic_with_error(EscrowError::InvalidMilestone); } @@ -579,6 +632,103 @@ impl Escrow { true } + /// Raise a dispute on a funded escrow. Only the client or freelancer may call this. + pub fn raise_dispute(env: Env, contract_id: u32, caller: Address) -> bool { + Self::require_not_paused(&env); + caller.require_auth(); + + let key = DataKey::Contract(contract_id); + let mut contract = env + .storage() + .persistent() + .get::<_, EscrowContractData>(&key) + .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)); + + if caller != contract.client && caller != contract.freelancer { + env.panic_with_error(EscrowError::UnauthorizedRole); + } + if contract.arbiter.is_none() { + env.panic_with_error(EscrowError::ArbiterRequired); + } + if contract.status != ContractStatus::Funded + && contract.status != ContractStatus::PartiallyFunded + { + env.panic_with_error(EscrowError::InvalidStatusTransition); + } + + let old_status = contract.status; + contract.status = ContractStatus::Disputed; + + Self::check_accounting_invariant(&env, &contract, contract_id); + env.storage().persistent().set(&key, &contract); + + Self::emit_audit_event(&env, contract_id, old_status, contract.status, &caller); + env.events().publish( + (symbol_short!("dispute"), contract_id), + (caller, env.ledger().timestamp()), + ); + true + } + + /// Resolve a disputed escrow and distribute the remaining balance according to the resolution. + pub fn resolve_dispute( + env: Env, + contract_id: u32, + arbiter: Address, + resolution: DisputeResolution, + ) -> bool { + Self::require_not_paused(&env); + arbiter.require_auth(); + + let key = DataKey::Contract(contract_id); + let mut contract = env + .storage() + .persistent() + .get::<_, EscrowContractData>(&key) + .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)); + + if contract.status != ContractStatus::Disputed { + env.panic_with_error(EscrowError::InvalidStatusTransition); + } + if contract.arbiter.clone() != Some(arbiter.clone()) { + env.panic_with_error(EscrowError::UnauthorizedRole); + } + + let old_status = contract.status; + let (client_payout, freelancer_payout) = + dispute::resolution_payouts(&contract, &resolution) + .unwrap_or_else(|err| env.panic_with_error(err)); + + contract.refunded_amount = safe_add_amounts(contract.refunded_amount, client_payout) + .unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow)); + contract.released_amount = safe_add_amounts(contract.released_amount, freelancer_payout) + .unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow)); + + if safe_add_amounts(contract.released_amount, contract.refunded_amount) + != Some(contract.total_deposited) + { + env.panic_with_error(EscrowError::AccountingInvariantViolated); + } + + contract.status = dispute::final_status_after_resolution(&contract); + + Self::check_accounting_invariant(&env, &contract, contract_id); + env.storage().persistent().set(&key, &contract); + + Self::emit_audit_event(&env, contract_id, old_status, contract.status, &arbiter); + env.events().publish( + (symbol_short!("dsp_res"), contract_id), + ( + arbiter, + resolution.code(), + client_payout, + freelancer_payout, + env.ledger().timestamp(), + ), + ); + true + } + /// Issue reputation for a completed contract. Blocked when paused. pub fn issue_reputation( env: Env, diff --git a/contracts/escrow/src/test/dispute.rs b/contracts/escrow/src/test/dispute.rs index c16d731..5aa31a3 100644 --- a/contracts/escrow/src/test/dispute.rs +++ b/contracts/escrow/src/test/dispute.rs @@ -1,273 +1,290 @@ -//! Tests for raise_dispute and resolve_dispute. +use crate::{ContractStatus, DepositMode, DisputeResolution, Escrow, EscrowClient, EscrowError}; +use soroban_sdk::{testutils::Address as _, vec, Address, Env}; -#![cfg(test)] - -use soroban_sdk::{testutils::Address as _, vec, Address, BytesN, Env}; - -use crate::{ContractStatus, DisputeResolution, Escrow, EscrowClient}; - -// ── helpers ────────────────────────────────────────────────────────────────── - -fn register(env: &Env) -> EscrowClient { - EscrowClient::new(env, &env.register(Escrow, ())) -} - -fn participants(env: &Env) -> (Address, Address, Address) { - ( - Address::generate(env), - Address::generate(env), - Address::generate(env), - ) +fn setup_initialized() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + let admin = Address::generate(&env); + assert!(client.initialize(&admin)); + (env, contract_id) } -fn reason_hash(env: &Env) -> BytesN<32> { - BytesN::from_array(env, &[0xabu8; 32]) +fn create_client<'a>(env: &'a Env, contract_id: &Address) -> EscrowClient<'a> { + EscrowClient::new(env, contract_id) } -/// Create a funded contract with an arbiter. -fn funded_with_arbiter(env: &Env, escrow: &EscrowClient) -> (Address, Address, Address, u32) { - let (client_addr, freelancer_addr, arbiter_addr) = participants(env); - let milestones = vec![env, 100_i128, 200_i128]; - let id = escrow.create_contract( +fn funded_contract_with_arbiter( + env: &Env, + client: &EscrowClient<'_>, + milestones: soroban_sdk::Vec, + deposit_amount: i128, + deposit_mode: DepositMode, +) -> (Address, Address, Address, u32) { + let client_addr = Address::generate(env); + let freelancer_addr = Address::generate(env); + let arbiter_addr = Address::generate(env); + let contract_id = client.create_contract_with_arbiter( &client_addr, &freelancer_addr, - &Some(arbiter_addr.clone()), + &arbiter_addr, &milestones, + &deposit_mode, ); - escrow.deposit_funds(&id, &300_i128); - (client_addr, freelancer_addr, arbiter_addr, id) + assert!(client.deposit_funds(&contract_id, &deposit_amount)); + (client_addr, freelancer_addr, arbiter_addr, contract_id) } -// ── raise_dispute happy paths ───────────────────────────────────────────────── - #[test] fn client_can_raise_dispute_on_funded_contract() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - - assert!(escrow.raise_dispute(&id, &client_addr, &reason_hash(&env))); - - let contract = escrow.get_contract(&id); - assert_eq!(contract.status, ContractStatus::Disputed); -} - -#[test] -fn freelancer_can_raise_dispute_on_funded_contract() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (_, freelancer_addr, _, id) = funded_with_arbiter(&env, &escrow); - - assert!(escrow.raise_dispute(&id, &freelancer_addr, &reason_hash(&env))); + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, _, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128, 200_i128], + 300_i128, + DepositMode::ExactTotal, + ); - let contract = escrow.get_contract(&id); - assert_eq!(contract.status, ContractStatus::Disputed); + assert!(client.raise_dispute(&escrow_id, &client_addr)); + assert_eq!( + client.get_contract(&escrow_id).status, + ContractStatus::Disputed + ); } #[test] -fn raise_dispute_stores_metadata() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - let hash = reason_hash(&env); - - escrow.raise_dispute(&id, &client_addr, &hash); +fn freelancer_can_raise_dispute_on_partially_funded_contract() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (_, freelancer_addr, _, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128, 200_i128], + 150_i128, + DepositMode::Incremental, + ); - let meta = escrow.get_dispute(&id); - assert_eq!(meta.reason_hash, hash); - assert_eq!(meta.raised_by, client_addr); + assert!(client.raise_dispute(&escrow_id, &freelancer_addr)); + assert_eq!( + client.get_contract(&escrow_id).status, + ContractStatus::Disputed + ); } -// ── raise_dispute error paths ───────────────────────────────────────────────── - #[test] -#[should_panic] -fn arbiter_cannot_raise_dispute() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (_, _, arbiter_addr, id) = funded_with_arbiter(&env, &escrow); - - escrow.raise_dispute(&id, &arbiter_addr, &reason_hash(&env)); -} +fn raise_dispute_requires_contract_party() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (_, _, _, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128], + 100_i128, + DepositMode::ExactTotal, + ); -#[test] -#[should_panic] -fn third_party_cannot_raise_dispute() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (_, _, _, id) = funded_with_arbiter(&env, &escrow); let outsider = Address::generate(&env); - - escrow.raise_dispute(&id, &outsider, &reason_hash(&env)); + super::assert_contract_error( + client.try_raise_dispute(&escrow_id, &outsider), + EscrowError::UnauthorizedRole, + ); } #[test] -#[should_panic] -fn cannot_raise_dispute_without_arbiter() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); +fn raise_dispute_requires_assigned_arbiter() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); let client_addr = Address::generate(&env); let freelancer_addr = Address::generate(&env); - let milestones = vec![&env, 100_i128]; - let id = escrow.create_contract(&client_addr, &freelancer_addr, &None, &milestones); - escrow.deposit_funds(&id, &100_i128); - - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); -} - -#[test] -#[should_panic] -fn cannot_raise_dispute_on_created_contract() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, freelancer_addr, arbiter_addr) = participants(&env); - let milestones = vec![&env, 100_i128]; - let id = escrow.create_contract( + let escrow_id = client.create_contract( &client_addr, &freelancer_addr, - &Some(arbiter_addr), - &milestones, + &vec![&env, 100_i128], + &DepositMode::ExactTotal, ); - // Not funded — should fail + assert!(client.deposit_funds(&escrow_id, &100_i128)); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); -} - -#[test] -#[should_panic] -fn cannot_raise_dispute_twice() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); - // Already Disputed, not Funded — second call must fail - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); + super::assert_contract_error( + client.try_raise_dispute(&escrow_id, &client_addr), + EscrowError::ArbiterRequired, + ); } -// ── resolve_dispute happy paths ─────────────────────────────────────────────── - #[test] -fn arbiter_can_resolve_with_release() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, arbiter_addr, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); +fn resolve_full_refund_marks_refunded_and_closes_accounting() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 125_i128, 75_i128], + 200_i128, + DepositMode::ExactTotal, + ); + assert!(client.raise_dispute(&escrow_id, &client_addr)); - assert!(escrow.resolve_dispute(&id, &arbiter_addr, &DisputeResolution::Release)); + assert!(client.resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::FullRefund,)); - assert_eq!(escrow.get_contract(&id).status, ContractStatus::Completed); + let contract = client.get_contract(&escrow_id); + assert_eq!(contract.status, ContractStatus::Refunded); + assert_eq!(contract.released_amount, 0); + assert_eq!(contract.refunded_amount, 200); + assert_eq!( + contract.released_amount + contract.refunded_amount, + contract.total_deposited + ); } #[test] -fn arbiter_can_resolve_with_refund() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, arbiter_addr, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); - - assert!(escrow.resolve_dispute(&id, &arbiter_addr, &DisputeResolution::Refund)); - - assert_eq!(escrow.get_contract(&id).status, ContractStatus::Refunded); +fn resolve_partial_refund_applies_70_30_to_remaining_balance() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 101_i128, 100_i128], + 201_i128, + DepositMode::ExactTotal, + ); + assert!(client.release_milestone(&escrow_id, &0)); + assert!(client.raise_dispute(&escrow_id, &client_addr)); + + assert!(client.resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::PartialRefund,)); + + let contract = client.get_contract(&escrow_id); + assert_eq!(contract.status, ContractStatus::Completed); + assert_eq!(contract.released_amount, 131); + assert_eq!(contract.refunded_amount, 70); + assert_eq!( + contract.released_amount + contract.refunded_amount, + contract.total_deposited + ); } #[test] -fn arbiter_can_resolve_with_cancel() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, arbiter_addr, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); +fn resolve_split_accepts_custom_amounts_that_match_available_balance() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 40_i128, 60_i128], + 100_i128, + DepositMode::ExactTotal, + ); + assert!(client.raise_dispute(&escrow_id, &client_addr)); - assert!(escrow.resolve_dispute(&id, &arbiter_addr, &DisputeResolution::Cancel)); + assert!(client.resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::Split(35, 65),)); - assert_eq!(escrow.get_contract(&id).status, ContractStatus::Cancelled); + let contract = client.get_contract(&escrow_id); + assert_eq!(contract.status, ContractStatus::Completed); + assert_eq!(contract.refunded_amount, 35); + assert_eq!(contract.released_amount, 65); } -// ── resolve_dispute error paths ─────────────────────────────────────────────── - #[test] -#[should_panic] -fn client_cannot_resolve_dispute() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); +fn resolve_split_rejects_invalid_totals() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128], + 100_i128, + DepositMode::ExactTotal, + ); + assert!(client.raise_dispute(&escrow_id, &client_addr)); - escrow.resolve_dispute(&id, &client_addr, &DisputeResolution::Release); + super::assert_contract_error( + client.try_resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::Split(30, 50)), + EscrowError::InvalidDisputeSplit, + ); } #[test] -#[should_panic] -fn freelancer_cannot_resolve_dispute() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, freelancer_addr, _, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); +fn resolve_dispute_requires_assigned_arbiter() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, _, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128], + 100_i128, + DepositMode::ExactTotal, + ); + assert!(client.raise_dispute(&escrow_id, &client_addr)); - escrow.resolve_dispute(&id, &freelancer_addr, &DisputeResolution::Release); + let outsider = Address::generate(&env); + super::assert_contract_error( + client.try_resolve_dispute(&escrow_id, &outsider, &DisputeResolution::FullPayout), + EscrowError::UnauthorizedRole, + ); } #[test] -#[should_panic] -fn third_party_cannot_resolve_dispute() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); - let outsider = Address::generate(&env); +fn resolve_dispute_rejects_non_disputed_contract() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (_, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128], + 100_i128, + DepositMode::ExactTotal, + ); - escrow.resolve_dispute(&id, &outsider, &DisputeResolution::Release); + super::assert_contract_error( + client.try_resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::FullRefund), + EscrowError::InvalidStatusTransition, + ); } #[test] -#[should_panic] -fn cannot_resolve_non_disputed_contract() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (_, _, arbiter_addr, id) = funded_with_arbiter(&env, &escrow); - // Not disputed yet +fn release_is_blocked_while_disputed() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, _, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128, 50_i128], + 150_i128, + DepositMode::ExactTotal, + ); + assert!(client.raise_dispute(&escrow_id, &client_addr)); - escrow.resolve_dispute(&id, &arbiter_addr, &DisputeResolution::Release); + super::assert_contract_error( + client.try_release_milestone(&escrow_id, &0), + EscrowError::InvalidStatusTransition, + ); } -// ── state blocking ──────────────────────────────────────────────────────────── - #[test] -#[should_panic] -fn release_milestone_blocked_in_disputed_state() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (client_addr, _, _, id) = funded_with_arbiter(&env, &escrow); - escrow.raise_dispute(&id, &client_addr, &reason_hash(&env)); - - escrow.release_milestone(&id, &0); -} +fn pause_blocks_raise_and_resolve_dispute() { + let (env, contract_id) = setup_initialized(); + let client = create_client(&env, &contract_id); + let (client_addr, _, arbiter_addr, escrow_id) = funded_contract_with_arbiter( + &env, + &client, + vec![&env, 100_i128], + 100_i128, + DepositMode::ExactTotal, + ); -// ── get_dispute error path ──────────────────────────────────────────────────── + assert!(client.pause()); + super::assert_contract_error( + client.try_raise_dispute(&escrow_id, &client_addr), + EscrowError::ContractPaused, + ); -#[test] -#[should_panic] -fn get_dispute_fails_when_no_dispute_exists() { - let env = Env::default(); - env.mock_all_auths(); - let escrow = register(&env); - let (_, _, _, id) = funded_with_arbiter(&env, &escrow); + assert!(client.unpause()); + assert!(client.raise_dispute(&escrow_id, &client_addr)); + assert!(client.pause()); - escrow.get_dispute(&id); + super::assert_contract_error( + client.try_resolve_dispute(&escrow_id, &arbiter_addr, &DisputeResolution::FullRefund), + EscrowError::ContractPaused, + ); } diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index b540ebe..fc72929 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -6,6 +6,7 @@ use crate::{Escrow, EscrowClient, EscrowError}; // ─── Submodules ─────────────────────────────────────────────────────────────── +mod dispute; mod emergency_controls; mod pause_controls; diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index 1213d63..6d04e15 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -74,6 +74,8 @@ pub enum EscrowError { ExactDepositRequired = 41, DepositWouldExceedTotal = 42, AccountingInvariantViolated = 43, + ArbiterRequired = 44, + InvalidDisputeSplit = 45, } #[contracttype] diff --git a/docs/escrow/dispute-resolution.md b/docs/escrow/dispute-resolution.md index a4d6e5d..a2796f5 100644 --- a/docs/escrow/dispute-resolution.md +++ b/docs/escrow/dispute-resolution.md @@ -1,205 +1,156 @@ -# Dispute Resolution API Reference +# Dispute Resolution -## Dispute Resolution Types +The escrow contract now exposes an explicit dispute lifecycle for arbiter-backed contracts: -### FullRefund -- **Code**: 0 -- **Description**: Client receives 100% of escrowed funds -- **Use Case**: Work not delivered, contract breach by freelancer -- **Payout**: Client = 100%, Freelancer = 0% +- `create_contract_with_arbiter(...)` creates a contract that can later be disputed. +- `raise_dispute(contract_id, caller)` moves a funded or partially funded contract into `Disputed`. +- `resolve_dispute(contract_id, arbiter, resolution)` closes the dispute and allocates the remaining escrow balance. -### PartialRefund -- **Code**: 1 -- **Description**: Fixed 70/30 split favoring client -- **Use Case**: Partial delivery, quality issues -- **Payout**: Client = 70%, Freelancer = 30% +## Contract Requirements -### FullPayout -- **Code**: 2 -- **Description**: Freelancer receives 100% of escrowed funds -- **Use Case**: Work completed as agreed, client dispute without merit -- **Payout**: Client = 0%, Freelancer = 100% +- Only contracts created with `create_contract_with_arbiter` can enter the dispute flow. +- The arbiter must be distinct from both the client and the freelancer. +- Disputes are only valid from `Funded` or `PartiallyFunded`. +- Resolution is only valid from `Disputed`. -### Split -- **Code**: 3 -- **Description**: Custom split determined by arbitrator -- **Use Case**: Complex situations requiring nuanced resolution -- **Payout**: Custom amounts (must total 100%) +## Public API -## Function Signatures +### `create_contract_with_arbiter` -### create_dispute ```rust -pub fn create_dispute( +pub fn create_contract_with_arbiter( env: Env, - contract_id: u32, - reason: Symbol, - evidence: Vec, + client: Address, + freelancer: Address, + arbiter: Address, + milestone_amounts: Vec, + deposit_mode: DepositMode, ) -> u32 ``` -**Parameters:** -- `contract_id`: ID of the escrow contract -- `reason`: Symbol representing dispute reason (max 10 chars) -- `evidence`: Vector of evidence symbols +Creates a new escrow contract and stores the assigned arbiter in contract state. + +### `raise_dispute` -**Returns:** -- `u32`: Unique dispute ID +```rust +pub fn raise_dispute(env: Env, contract_id: u32, caller: Address) -> bool +``` -**Authorization:** Client or Freelancer +Rules: -**Preconditions:** -- Contract must be in `Funded` state -- Caller must be client or freelancer -- No existing dispute for contract +- `caller.require_auth()` is enforced before any state mutation. +- `caller` must equal `contract.client` or `contract.freelancer`. +- `contract.arbiter` must be set. +- `contract.status` must be `Funded` or `PartiallyFunded`. +- On success, the contract transitions to `Disputed`. +- The contract emits both an audit event and a `dispute` event. -**Postconditions:** -- Contract status changes to `Disputed` -- Dispute created with `Open` status +### `resolve_dispute` -### resolve_dispute ```rust pub fn resolve_dispute( env: Env, - dispute_id: u32, + contract_id: u32, + arbiter: Address, resolution: DisputeResolution, - client_payout: i128, - freelancer_payout: i128, ) -> bool ``` -**Parameters:** -- `dispute_id`: ID of the dispute to resolve -- `resolution`: Type of resolution (FullRefund, PartialRefund, FullPayout, Split) -- `client_payout`: Amount for client (only for Split resolution) -- `freelancer_payout`: Amount for freelancer (only for Split resolution) +Rules: -**Returns:** -- `bool`: True if resolution successful +- `arbiter.require_auth()` is enforced before any state mutation. +- `arbiter` must equal the stored `contract.arbiter`. +- `contract.status` must be `Disputed`. +- Resolution applies only to the available balance: -**Authorization:** Arbitrator only +```text +available_balance = total_deposited - released_amount - refunded_amount +``` -**Preconditions:** -- Dispute must be in `Open` or `InReview` state -- Caller must be arbitrator -- For Split resolution: payouts must equal contract total +- On success, the contract transitions to: + - `Refunded` when the client receives the full deposited amount + - `Completed` for any mixed or freelancer-positive outcome +- The contract emits both an audit event and a `dsp_res` event. -**Postconditions:** -- Dispute status changes to `Resolved` -- Contract status changes to `Resolved` -- Payout amounts calculated and stored +## Resolution Types -## Error Conditions +```rust +pub enum DisputeResolution { + FullRefund, + PartialRefund, + FullPayout, + Split(i128, i128), +} +``` -### create_dispute Errors -- `"contract not found"`: Invalid contract ID -- `"only client or freelancer can create dispute"`: Unauthorized caller -- `"invalid contract status"`: Contract not in Funded state -- Authorization failure: Caller not authenticated +### `FullRefund` -### resolve_dispute Errors -- `"dispute not found"`: Invalid dispute ID -- `"arbitrator not set"`: Contract not properly initialized -- `"dispute already resolved"`: Dispute already resolved -- `"split amounts must equal total contract amount"`: Invalid split amounts -- Authorization failure: Caller not arbitrator +- Client receives 100% of the remaining balance. +- Freelancer receives 0%. -## State Transitions +### `PartialRefund` -### Contract State Flow -``` -Created → Funded → Completed - ↓ - Disputed → Resolved +- Client receives 70% of the remaining balance. +- Freelancer receives 30% of the remaining balance. +- Integer rounding favors the client: + +```text +freelancer = floor(available_balance * 30 / 100) +client = available_balance - freelancer ``` -## Timeout Dispute Policy +### `FullPayout` -Deadline-driven disputes follow a narrower policy than generic payout disputes: +- Freelancer receives 100% of the remaining balance. +- Client receives 0%. -- an expired milestone causes `Funded -> Disputed` -- expiry is determined only from `env.ledger().timestamp()` and the stored - milestone deadline -- if an arbiter is configured, only the arbiter may resolve the dispute -- if no arbiter is configured, the client may resolve the dispute -- a timeout dispute cannot be resolved while any unreleased milestone is still - expired; the client must first update schedule metadata to a future due date +### `Split(client_amount, freelancer_amount)` -### Dispute State Flow -``` -Open → InReview → Resolved -``` +- Custom absolute payouts chosen by the arbiter. +- Both amounts must be non-negative. +- Their sum must equal the remaining balance exactly. + +## Accounting Invariants + +Resolution is fail-closed: -## Security Considerations - -### Access Control -- **Admin**: Can update arbitrator address -- **Arbitrator**: Can resolve disputes only -- **Client/Freelancer**: Can create disputes only -- **Public**: Can view contract and dispute data - -### Financial Safety -- All payouts are mathematically validated -- No funds can be lost in the resolution process -- Deterministic outcomes prevent manipulation - -### Audit Trail -- All actions timestamped -- Resolutions track which arbitrator decided -- Evidence stored permanently - -## Integration Examples - -### JavaScript/TypeScript -```typescript -// Create dispute -const disputeId = await contract.create_dispute({ - contractId: 1, - reason: "quality_issues", - evidence: ["photo_evidence", "chat_logs"] -}); - -// Resolve dispute (arbitrator) -await contract.resolve_dispute({ - disputeId: 1, - resolution: "PartialRefund", - clientPayout: 0, // Ignored for PartialRefund - freelancerPayout: 0 // Ignored for PartialRefund -}); +- `released_amount` is incremented only by the freelancer payout. +- `refunded_amount` is incremented only by the client payout. +- Resolution panics unless: + +```text +released_amount + refunded_amount == total_deposited ``` -### Rust -```rust -// Create dispute -let dispute_id = escrow.create_dispute( - contract_id, - symbol_short!("delay"), - vec![symbol_short!("evidence1")] -); - -// Resolve with custom split -escrow.resolve_dispute( - dispute_id, - DisputeResolution::Split, - 600_0000000, // 60% to client - 400_0000000 // 40% to freelancer -); +- The core invariant is rechecked after every dispute transition: + +```text +total_deposited == released_amount + refunded_amount + available_balance ``` -## Best Practices +## Security Notes + +- Unauthorized callers cannot raise or resolve disputes. +- Arbiter-less contracts cannot enter the dispute lifecycle. +- `release_milestone` is blocked while a contract is `Disputed`. +- Dispute actions are blocked while the contract is paused or in emergency mode. +- No separate dispute storage record is introduced, so there is no new TTL surface to manage. +- Resolution logic uses checked arithmetic and rejects invalid custom splits. + +## Test Coverage + +The active unit tests cover: + +- client and freelancer dispute raising +- partially funded dispute entry +- missing-arbiter rejection +- non-party and non-arbiter rejection +- full refund, fixed 70/30 partial refund, and custom split resolution +- disputed-state release blocking +- pause blocking for both dispute entrypoints -### For Clients -- Document all issues with evidence -- Create disputes promptly when issues arise -- Provide clear, concise reason codes +## Validation Note -### For Freelancers -- Maintain documentation of work completed -- Respond to disputes with counter-evidence if possible -- Monitor contract status regularly +`cargo check -p escrow --tests` passes on the current workspace. -### For Arbitrators -- Review all evidence carefully -- Use appropriate resolution type for situation -- Document rationale for custom splits -- Maintain impartiality and consistency +Full `cargo test -p escrow` is currently blocked by the local Windows GNU linker limit for the contract `cdylib` export table, and the installed MSVC toolchain does not have `link.exe` available on this machine.