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.