Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions contracts/contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -186,6 +205,7 @@ pub struct DisputeResolved {
pub recipient: Address,
pub amount: i128,
pub release_to_freelancer: bool,
pub timestamp: u64,
}

#[contractevent]
Expand Down Expand Up @@ -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 {
Expand All @@ -580,6 +615,7 @@ impl EscrowContract {
recipient,
amount: transfer_amount,
release_to_freelancer,
timestamp,
}
.publish(&env);

Expand Down Expand Up @@ -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<DisputeResolution> {
env.storage().instance().get(&DataKey::Resolution(milestone_id))
}

pub fn get_token(env: Env) -> Result<Address, Error> {
env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)
}
Expand Down
46 changes: 45 additions & 1 deletion contracts/contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -217,13 +217,24 @@ 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
let token_client = token::Client::new(&env, &setup.token_address);
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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion docs/soroban-contract-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/soroban-escrow-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down