From ff3d96bc48076de9ed22a7d800b45a2dc105a24d Mon Sep 17 00:00:00 2001 From: Praiz Francis Date: Tue, 28 Apr 2026 16:05:17 +0100 Subject: [PATCH] feat: add escrow contract with milestone approvals and dispute resolution --- soroban-contract/Cargo.toml | 1 + soroban-contract/contracts/escrow/Cargo.toml | 15 + soroban-contract/contracts/escrow/src/lib.rs | 373 ++++++++++++++++++ soroban-contract/contracts/escrow/src/test.rs | 213 ++++++++++ 4 files changed, 602 insertions(+) create mode 100644 soroban-contract/contracts/escrow/Cargo.toml create mode 100644 soroban-contract/contracts/escrow/src/lib.rs create mode 100644 soroban-contract/contracts/escrow/src/test.rs diff --git a/soroban-contract/Cargo.toml b/soroban-contract/Cargo.toml index a6017117..7cb3b122 100644 --- a/soroban-contract/Cargo.toml +++ b/soroban-contract/Cargo.toml @@ -17,6 +17,7 @@ members = [ "contracts/dao_governance", "contracts/merkle_distributor", "contracts/payment_splitter", + "contracts/escrow", "tests/integration", ] exclude = ["contracts/hello-world"] diff --git a/soroban-contract/contracts/escrow/Cargo.toml b/soroban-contract/contracts/escrow/Cargo.toml new file mode 100644 index 00000000..8f4cae86 --- /dev/null +++ b/soroban-contract/contracts/escrow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "escrow" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } +upgradeable = { path = "../upgradeable" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/soroban-contract/contracts/escrow/src/lib.rs b/soroban-contract/contracts/escrow/src/lib.rs new file mode 100644 index 00000000..843e05e7 --- /dev/null +++ b/soroban-contract/contracts/escrow/src/lib.rs @@ -0,0 +1,373 @@ +//! Escrow contract with milestone approvals and dispute resolution hooks. +//! +//! # Roles +//! - **depositor** – funds the escrow and approves milestones. +//! - **recipient** – receives funds as milestones are approved. +//! - **arbiter** – resolves disputes; set at creation time. +//! +//! # Lifecycle +//! ```text +//! create_escrow → [fund] → approve_milestone (repeats) → close +//! ↘ open_dispute → resolve_dispute +//! ``` + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec, +}; + +use upgradeable as upg; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + EscrowNotFound = 2, + Unauthorized = 3, + InvalidMilestone = 4, + MilestoneAlreadyApproved = 5, + EscrowClosed = 6, + DisputeAlreadyOpen = 7, + NoOpenDispute = 8, + InsufficientFunds = 9, + InvalidAmounts = 10, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum EscrowStatus { + Active = 0, + Disputed = 1, + Closed = 2, +} + +/// A single milestone: description hash (off-chain) + amount to release on approval. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { + pub amount: i128, + pub approved: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Escrow { + pub id: u32, + pub depositor: Address, + pub recipient: Address, + pub arbiter: Address, + pub token: Address, + pub total_amount: i128, + pub released: i128, + pub status: EscrowStatus, + pub milestones: Vec, +} + +#[contracttype] +pub enum DataKey { + Counter, + Escrow(u32), +} + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct EscrowContract; + +#[contractimpl] +impl EscrowContract { + // ── Admin ───────────────────────────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Counter) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + upg::set_admin(&env, &admin); + upg::init_version(&env); + env.storage().instance().set(&DataKey::Counter, &0u32); + upg::extend_instance_ttl(&env); + Ok(()) + } + + // ── Create ──────────────────────────────────────────────────────────────── + + /// Create an escrow and immediately transfer `total_amount` tokens from + /// `depositor` into the contract. `milestone_amounts` must sum to + /// `total_amount`. + pub fn create_escrow( + env: Env, + depositor: Address, + recipient: Address, + arbiter: Address, + token: Address, + milestone_amounts: Vec, + ) -> Result { + upg::require_not_paused(&env); + depositor.require_auth(); + + if milestone_amounts.is_empty() { + return Err(Error::InvalidMilestone); + } + + let mut total_amount: i128 = 0; + for amount in milestone_amounts.iter() { + total_amount += amount; + } + if total_amount <= 0 { + return Err(Error::InvalidAmounts); + } + + // Pull funds from depositor. + token::Client::new(&env, &token).transfer( + &depositor, + &env.current_contract_address(), + &total_amount, + ); + + let mut milestones: Vec = Vec::new(&env); + for amount in milestone_amounts.iter() { + milestones.push_back(Milestone { amount, approved: false }); + } + + let id = Self::next_id(&env); + let escrow = Escrow { + id, + depositor: depositor.clone(), + recipient: recipient.clone(), + arbiter: arbiter.clone(), + token, + total_amount, + released: 0, + status: EscrowStatus::Active, + milestones, + }; + + env.storage().persistent().set(&DataKey::Escrow(id), &escrow); + Self::extend_ttl(&env, id); + upg::extend_instance_ttl(&env); + + env.events().publish( + (Symbol::new(&env, "EscrowCreated"),), + (id, depositor, recipient, arbiter, total_amount), + ); + + Ok(id) + } + + // ── Milestone approval ──────────────────────────────────────────────────── + + /// Depositor approves a milestone; funds are released to the recipient. + pub fn approve_milestone( + env: Env, + escrow_id: u32, + milestone_index: u32, + ) -> Result { + upg::require_not_paused(&env); + + let mut escrow = Self::load(&env, escrow_id)?; + Self::require_active(&escrow)?; + escrow.depositor.require_auth(); + + let idx = milestone_index as usize; + if idx >= escrow.milestones.len() as usize { + return Err(Error::InvalidMilestone); + } + + let milestone = escrow.milestones.get(milestone_index).unwrap(); + if milestone.approved { + return Err(Error::MilestoneAlreadyApproved); + } + + // Rebuild milestones vec with this entry marked approved. + let mut updated: Vec = Vec::new(&env); + for i in 0..escrow.milestones.len() { + let m = escrow.milestones.get(i).unwrap(); + if i == milestone_index { + updated.push_back(Milestone { amount: m.amount, approved: true }); + } else { + updated.push_back(m); + } + } + escrow.milestones = updated; + escrow.released += milestone.amount; + + token::Client::new(&env, &escrow.token).transfer( + &env.current_contract_address(), + &escrow.recipient, + &milestone.amount, + ); + + // Auto-close when all milestones are approved. + if escrow.released >= escrow.total_amount { + escrow.status = EscrowStatus::Closed; + env.events().publish( + (Symbol::new(&env, "EscrowClosed"),), + (escrow_id,), + ); + } + + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow); + Self::extend_ttl(&env, escrow_id); + + env.events().publish( + (Symbol::new(&env, "MilestoneApproved"),), + (escrow_id, milestone_index, milestone.amount), + ); + + Ok(milestone.amount) + } + + // ── Dispute hooks ───────────────────────────────────────────────────────── + + /// Either party opens a dispute; only the arbiter can then resolve it. + pub fn open_dispute(env: Env, escrow_id: u32, caller: Address) -> Result<(), Error> { + upg::require_not_paused(&env); + + let mut escrow = Self::load(&env, escrow_id)?; + Self::require_active(&escrow)?; + caller.require_auth(); + + // Only depositor or recipient may open a dispute. + if caller != escrow.depositor && caller != escrow.recipient { + return Err(Error::Unauthorized); + } + + escrow.status = EscrowStatus::Disputed; + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow); + Self::extend_ttl(&env, escrow_id); + + env.events().publish( + (Symbol::new(&env, "DisputeOpened"),), + (escrow_id, caller), + ); + + Ok(()) + } + + /// Arbiter resolves a dispute by splitting the *remaining* (unreleased) + /// balance between depositor and recipient. + /// `recipient_share` is the fraction going to the recipient (0..=remaining). + pub fn resolve_dispute( + env: Env, + escrow_id: u32, + recipient_share: i128, + ) -> Result<(), Error> { + upg::require_not_paused(&env); + + let mut escrow = Self::load(&env, escrow_id)?; + if !matches!(escrow.status, EscrowStatus::Disputed) { + return Err(Error::NoOpenDispute); + } + escrow.arbiter.require_auth(); + + let remaining = escrow.total_amount - escrow.released; + if recipient_share < 0 || recipient_share > remaining { + return Err(Error::InvalidAmounts); + } + let depositor_share = remaining - recipient_share; + + let token_client = token::Client::new(&env, &escrow.token); + + if recipient_share > 0 { + token_client.transfer( + &env.current_contract_address(), + &escrow.recipient, + &recipient_share, + ); + } + if depositor_share > 0 { + token_client.transfer( + &env.current_contract_address(), + &escrow.depositor, + &depositor_share, + ); + } + + escrow.released = escrow.total_amount; + escrow.status = EscrowStatus::Closed; + + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow); + Self::extend_ttl(&env, escrow_id); + + env.events().publish( + (Symbol::new(&env, "DisputeResolved"),), + (escrow_id, recipient_share, depositor_share), + ); + + Ok(()) + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + pub fn get_escrow(env: Env, escrow_id: u32) -> Option { + env.storage().persistent().get(&DataKey::Escrow(escrow_id)) + } + + // ── Upgrade helpers (delegated to upgradeable crate) ────────────────────── + + pub fn schedule_upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { + upg::schedule_upgrade(&env, new_wasm_hash); + } + + pub fn cancel_upgrade(env: Env) { + upg::cancel_upgrade(&env); + } + + pub fn commit_upgrade(env: Env) { + upg::commit_upgrade(&env); + } + + pub fn pause(env: Env) { + upg::pause(&env); + } + + pub fn unpause(env: Env) { + upg::unpause(&env); + } + + pub fn transfer_admin(env: Env, new_admin: Address) { + upg::transfer_admin(&env, new_admin); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn load(env: &Env, id: u32) -> Result { + env.storage() + .persistent() + .get(&DataKey::Escrow(id)) + .ok_or(Error::EscrowNotFound) + } + + fn require_active(escrow: &Escrow) -> Result<(), Error> { + match escrow.status { + EscrowStatus::Active => Ok(()), + EscrowStatus::Disputed => Err(Error::DisputeAlreadyOpen), + EscrowStatus::Closed => Err(Error::EscrowClosed), + } + } + + fn next_id(env: &Env) -> u32 { + let id: u32 = env + .storage() + .instance() + .get(&DataKey::Counter) + .unwrap_or(0); + env.storage().instance().set(&DataKey::Counter, &(id + 1)); + id + } + + fn extend_ttl(env: &Env, id: u32) { + upg::extend_persistent_ttl(env, &DataKey::Escrow(id)); + } +} + +#[cfg(test)] +mod test; diff --git a/soroban-contract/contracts/escrow/src/test.rs b/soroban-contract/contracts/escrow/src/test.rs new file mode 100644 index 00000000..00b94ea9 --- /dev/null +++ b/soroban-contract/contracts/escrow/src/test.rs @@ -0,0 +1,213 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + contract, contractimpl, + testutils::Address as _, + vec, Env, +}; + +// ── Minimal mock token ──────────────────────────────────────────────────────── + +#[contract] +pub struct MockToken; + +#[contractimpl] +impl MockToken { + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + let from_bal: i128 = env.storage().instance().get(&from).unwrap_or(0); + let to_bal: i128 = env.storage().instance().get(&to).unwrap_or(0); + env.storage().instance().set(&from, &(from_bal - amount)); + env.storage().instance().set(&to, &(to_bal + amount)); + } + + pub fn balance(env: Env, addr: Address) -> i128 { + env.storage().instance().get(&addr).unwrap_or(0) + } + + pub fn mint(env: Env, to: Address, amount: i128) { + let bal: i128 = env.storage().instance().get(&to).unwrap_or(0); + env.storage().instance().set(&to, &(bal + amount)); + } +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +struct Setup { + env: Env, + client: EscrowContractClient<'static>, + token: Address, + depositor: Address, + recipient: Address, + arbiter: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let token = env.register(MockToken, ()); + let depositor = Address::generate(&env); + MockTokenClient::new(&env, &token).mint(&depositor, &1_000_000); + + Setup { + env, + client, + token, + depositor, + recipient: Address::generate(&env), + arbiter: Address::generate(&env), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_create_escrow_transfers_funds() { + let s = setup(); + let amounts = vec![&s.env, 300_i128, 700_i128]; + + let id = s.client.create_escrow( + &s.depositor, + &s.recipient, + &s.arbiter, + &s.token, + &amounts, + ); + + let escrow = s.client.get_escrow(&id).unwrap(); + assert_eq!(escrow.total_amount, 1_000); + assert_eq!(escrow.released, 0); + assert!(matches!(escrow.status, EscrowStatus::Active)); + // Depositor balance reduced + assert_eq!( + MockTokenClient::new(&s.env, &s.token).balance(&s.depositor), + 999_000 + ); +} + +#[test] +fn test_approve_milestone_releases_funds() { + let s = setup(); + let amounts = vec![&s.env, 400_i128, 600_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + let released = s.client.approve_milestone(&id, &0); + assert_eq!(released, 400); + assert_eq!( + MockTokenClient::new(&s.env, &s.token).balance(&s.recipient), + 400 + ); + + let escrow = s.client.get_escrow(&id).unwrap(); + assert_eq!(escrow.released, 400); + assert!(matches!(escrow.status, EscrowStatus::Active)); +} + +#[test] +fn test_all_milestones_approved_closes_escrow() { + let s = setup(); + let amounts = vec![&s.env, 500_i128, 500_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + s.client.approve_milestone(&id, &0); + s.client.approve_milestone(&id, &1); + + let escrow = s.client.get_escrow(&id).unwrap(); + assert!(matches!(escrow.status, EscrowStatus::Closed)); + assert_eq!( + MockTokenClient::new(&s.env, &s.token).balance(&s.recipient), + 1_000 + ); +} + +#[test] +fn test_double_approve_fails() { + let s = setup(); + let amounts = vec![&s.env, 1_000_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + s.client.approve_milestone(&id, &0); + let result = s.client.try_approve_milestone(&id, &0); + assert!(result.is_err()); +} + +#[test] +fn test_open_dispute_blocks_approval() { + let s = setup(); + let amounts = vec![&s.env, 1_000_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + s.client.open_dispute(&id, &s.depositor); + + let result = s.client.try_approve_milestone(&id, &0); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_dispute_splits_remaining() { + let s = setup(); + // Two milestones: first already approved, second disputed. + let amounts = vec![&s.env, 400_i128, 600_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + s.client.approve_milestone(&id, &0); // releases 400 + s.client.open_dispute(&id, &s.recipient); + + // Arbiter gives 300 of the remaining 600 to recipient, 300 back to depositor. + s.client.resolve_dispute(&id, &300); + + assert_eq!( + MockTokenClient::new(&s.env, &s.token).balance(&s.recipient), + 700 // 400 + 300 + ); + assert_eq!( + MockTokenClient::new(&s.env, &s.token).balance(&s.depositor), + 999_300 // 1_000_000 - 1_000 deposited + 300 returned + ); + + let escrow = s.client.get_escrow(&id).unwrap(); + assert!(matches!(escrow.status, EscrowStatus::Closed)); +} + +#[test] +fn test_resolve_dispute_requires_open_dispute() { + let s = setup(); + let amounts = vec![&s.env, 1_000_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + let result = s.client.try_resolve_dispute(&id, &500); + assert!(result.is_err()); +} + +#[test] +fn test_invalid_recipient_share_rejected() { + let s = setup(); + let amounts = vec![&s.env, 1_000_i128]; + let id = s.client.create_escrow( + &s.depositor, &s.recipient, &s.arbiter, &s.token, &amounts, + ); + + s.client.open_dispute(&id, &s.depositor); + + // Share exceeds remaining balance. + let result = s.client.try_resolve_dispute(&id, &1_001); + assert!(result.is_err()); +}