From 6b124d2844835394bdbf19dc648ad3eb145c138d Mon Sep 17 00:00:00 2001 From: gorrettivibes-coder Date: Sun, 18 Oct 2026 04:08:34 +0100 Subject: [PATCH] feat: secure Soroban dispute resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/contracts/escrow/src/lib.rs | 52 +++++++++++++++++++++++--- contracts/contracts/escrow/src/test.rs | 46 ++++++++++++++++++++++- docs/soroban-contract-events.md | 5 ++- docs/soroban-escrow-deployment.md | 2 +- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 27eb5c9..01e12d3 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -43,6 +43,23 @@ pub enum DataKey { Milestone(u32), MilestoneIds, EscrowBalance, + Resolution(u32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[contracttype] +pub enum DisputeOutcome { + ReleaseToFreelancer, + RefundToClient, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub struct DisputeResolution { + pub outcome: DisputeOutcome, + pub resolver: Address, + pub amount: i128, + pub timestamp: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -73,6 +90,8 @@ pub enum Error { DeadlineExceeded = 11, /// ERR_ALREADY_EXPIRED AlreadyExpired = 12, + /// ERR_INSUFFICIENT_ESCROW_BALANCE + InsufficientBalance = 13, } /// Standardized event schemas. Topics are indexed by Soroban RPC consumers; @@ -186,6 +205,7 @@ pub struct DisputeResolved { pub recipient: Address, pub amount: i128, pub release_to_freelancer: bool, + pub timestamp: u64, } #[contractevent] @@ -554,15 +574,30 @@ impl EscrowContract { } let transfer_amount = milestone.amount; - milestone.status = MilestoneStatus::Resolved; - env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); - let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); - if balance >= transfer_amount { - balance -= transfer_amount; - env.storage().instance().set(&DataKey::EscrowBalance, &balance); + if balance < transfer_amount { + return Err(Error::InsufficientBalance); } + let outcome = if release_to_freelancer { + DisputeOutcome::ReleaseToFreelancer + } else { + DisputeOutcome::RefundToClient + }; + let timestamp = env.ledger().timestamp(); + let resolution = DisputeResolution { + outcome, + resolver: arbiter.clone(), + amount: transfer_amount, + timestamp, + }; + + milestone.status = MilestoneStatus::Resolved; + env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + env.storage().instance().set(&DataKey::Resolution(milestone_id), &resolution); + let recipient: Address = if release_to_freelancer { env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)? } else { @@ -580,6 +615,7 @@ impl EscrowContract { recipient, amount: transfer_amount, release_to_freelancer, + timestamp, } .publish(&env); @@ -670,6 +706,10 @@ impl EscrowContract { env.storage().instance().get(&DataKey::Arbiter).ok_or(Error::NotInitialized) } + pub fn get_dispute_resolution(env: Env, milestone_id: u32) -> Option { + env.storage().instance().get(&DataKey::Resolution(milestone_id)) + } + pub fn get_token(env: Env) -> Result { env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized) } diff --git a/contracts/contracts/escrow/src/test.rs b/contracts/contracts/escrow/src/test.rs index 12a68d0..7b989a1 100644 --- a/contracts/contracts/escrow/src/test.rs +++ b/contracts/contracts/escrow/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Events as _}, + testutils::{Address as _, Events as _, Ledger as _}, vec, Address, Env, String, }; @@ -217,6 +217,7 @@ fn test_dispute_and_resolve_to_freelancer() { assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Disputed); // Arbiter resolves dispute in freelancer's favor + env.ledger().set_timestamp(1_234); escrow.resolve_dispute(&1, &true); // Verify freelancer receives funds @@ -224,6 +225,16 @@ fn test_dispute_and_resolve_to_freelancer() { assert_eq!(token_client.balance(&setup.freelancer), 400); assert_eq!(token_client.balance(&setup.client), 600); assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Resolved); + assert_eq!(escrow.get_escrow_balance(), 0); + assert_eq!( + escrow.get_dispute_resolution(&1).unwrap(), + DisputeResolution { + outcome: DisputeOutcome::ReleaseToFreelancer, + resolver: setup.arbiter, + amount: 400, + timestamp: 1_234, + } + ); } #[test] @@ -260,6 +271,10 @@ fn test_dispute_and_resolve_to_client() { assert_eq!(token_client.balance(&setup.client), 1000); assert_eq!(token_client.balance(&setup.freelancer), 0); assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Resolved); + assert_eq!( + escrow.get_dispute_resolution(&1).unwrap().outcome, + DisputeOutcome::RefundToClient + ); } #[test] @@ -727,9 +742,38 @@ fn test_resolve_without_dispute_fails() { setup.escrow_client.start_milestone(&1); setup.escrow_client.submit_milestone(&1); + assert_eq!(setup.escrow_client.get_dispute_resolution(&1), None); setup.escrow_client.resolve_dispute(&1, &true); } +#[test] +#[should_panic] +fn test_resolution_requires_arbiter_authorization() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + setup.escrow_client.dispute(&1, &setup.client); + + setup.env.mock_auths(&[]); + setup.escrow_client.resolve_dispute(&1, &true); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #6)")] +fn test_duplicate_dispute_resolution_fails() { + let setup = setup_test(); + initialize_single_milestone(&setup, 150); + setup.escrow_client.fund(); + setup.escrow_client.start_milestone(&1); + setup.escrow_client.submit_milestone(&1); + setup.escrow_client.dispute(&1, &setup.client); + setup.escrow_client.resolve_dispute(&1, &true); + + setup.escrow_client.resolve_dispute(&1, &false); +} + #[test] fn test_dispute_events_are_emitted() { let setup = setup_test(); diff --git a/docs/soroban-contract-events.md b/docs/soroban-contract-events.md index 060aa00..bf02fcf 100644 --- a/docs/soroban-contract-events.md +++ b/docs/soroban-contract-events.md @@ -20,6 +20,7 @@ Contract methods that can fail return `Result<_, Error>`. Soroban exposes the en | Escrow | 10 | `AlreadyApproved` | `ERR_ALREADY_APPROVED` | | Escrow | 11 | `DeadlineExceeded` | `ERR_DEADLINE_EXCEEDED` | | Escrow | 12 | `AlreadyExpired` | `ERR_ALREADY_EXPIRED` | +| Escrow | 13 | `InsufficientBalance` | `ERR_INSUFFICIENT_ESCROW_BALANCE` | | Dispute | 1 | `AlreadyInitialized` | `ERR_ALREADY_INITIALIZED` | | Dispute | 2 | `NotInitialized` | `ERR_NOT_INITIALIZED` | | Dispute | 3 | `DisputeNotFound` | `ERR_DISPUTE_NOT_FOUND` | @@ -50,9 +51,11 @@ All standardized events use the PascalCase type names below in contract source a | `PaymentReleased` / `payment_released` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | | `DisputeRaised` / `dispute_raised` | `contract_id`, `milestone_id`, `actor` | none | | `RefundIssued` / `refund_issued` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | -| `DisputeResolved` / `dispute_resolved` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount`, `release_to_freelancer` | +| `DisputeResolved` / `dispute_resolved` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount`, `release_to_freelancer`, `timestamp` | | `MilestoneExpired` / `milestone_expired` | `contract_id`, `milestone_id`, `actor` | `recipient`, `amount` | +The escrow's `arbiter` is fixed at initialization. `resolve_dispute(milestone_id, release_to_freelancer)` requires authorization from that exact address and only accepts a milestone in the `Disputed` state. It atomically transfers the milestone amount, updates the escrow balance and milestone state, and stores `DisputeResolution` metadata. `get_dispute_resolution(milestone_id)` returns the recorded outcome (`ReleaseToFreelancer` or `RefundToClient`), resolver, amount, and ledger timestamp; unresolved milestones return no record. The event's `contract_id` and `milestone_id` identify the escrow and disputed milestone, while `actor` identifies the arbitrator. + ### Dispute events | Event | Indexed topics | Data payload | diff --git a/docs/soroban-escrow-deployment.md b/docs/soroban-escrow-deployment.md index 22261fd..2186b14 100644 --- a/docs/soroban-escrow-deployment.md +++ b/docs/soroban-escrow-deployment.md @@ -47,7 +47,7 @@ Notes on contract functions and mapping - `freelancer_confirm(milestone_id)` — freelancer confirms approval. - `release(milestone_id, caller)` — releases funds to freelancer (maps to `release_payment`). - `refund(milestone_id, caller)` — refunds client (maps to `refund_payment`). -- `dispute(...)` / `resolve_dispute(...)` — dispute and arbiter resolution. +- `dispute(...)` / `resolve_dispute(...)` — dispute and arbiter resolution. `resolve_dispute` requires authorization from the arbitrator address configured at initialization; the `release_to_freelancer` flag selects the payout (`true`) or client refund (`false`). A successful resolution is recorded by `get_dispute_resolution(milestone_id)` and emitted as a `DisputeResolved` event. Integration notes - The contract expects an SPL-like token address (`token`) passed at initialization. The contract uses the standard `token::Client` interface for transfers.