From c8d675a50e36331ff7293f4d41145bb5faf146b7 Mon Sep 17 00:00:00 2001 From: Akpolo Date: Sun, 21 Jun 2026 16:12:46 +0100 Subject: [PATCH] feat(dispute): implement raise_dispute entrypoint Assembles the core TrustFlow contract and exposes raise_dispute, letting either party (depositor or beneficiary) halt escrow release and call for a jury. - Wire contracts/ as a buildable Soroban package with a #[contract] TrustFlowContract and the raise_dispute entrypoint (lib.rs). - Harden raise_dispute: require_auth(caller), restrict to escrow parties, only allow disputing an Active escrow (transitions it to Disputed to halt release), record a DisputeRecord for the jury, extend TTL, and emit a `disputed` event for indexers. - Fix storage.extend_ttl to the soroban-sdk 20 persistent TTL API (bump) and drop an unused import. - Add Rust unit tests: depositor/beneficiary can raise; outsider rejected; missing escrow, non-active escrow, and double-raise all rejected; event emitted. cargo fmt + clippy clean (only upstream SDK-macro cfg warnings); cargo test passes (6 tests); builds to a 10KB wasm32 artifact. Closes #3 --- Cargo.lock | 7 ++ Cargo.toml | 4 +- contracts/Cargo.toml | 15 ++++ contracts/src/dispute.rs | 68 ++++++++++++++--- contracts/src/lib.rs | 34 +++++++++ contracts/src/storage.rs | 6 +- contracts/src/test.rs | 159 +++++++++++++++++++++++++++++++++++++++ contracts/src/types.rs | 8 +- 8 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 contracts/src/lib.rs create mode 100644 contracts/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index c765933..a0a2fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,6 +1332,13 @@ dependencies = [ "time-core", ] +[[package]] +name = "trustflow-core" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.16.0" diff --git a/Cargo.toml b/Cargo.toml index 0ccfa12..3c20078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,9 @@ resolver = "2" members = [ - "contracts/*", + "contracts", + "contracts/abundance", + "contracts/crowdfund", ] [workspace.package] diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 146d670..a833f2c 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,2 +1,17 @@ # TrustFlow core contract dependencies pinned for reproducibility +[package] +name = "trustflow-core" +description = "TrustFlow escrow core contract" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/src/dispute.rs b/contracts/src/dispute.rs index 99d53f7..a2c7feb 100644 --- a/contracts/src/dispute.rs +++ b/contracts/src/dispute.rs @@ -1,15 +1,65 @@ -use soroban_sdk::{contractimpl, Address, Env, String}; -use crate::types::{DisputeRecord, EscrowStatus}; -use crate::storage::DataKey; +use soroban_sdk::{symbol_short, Address, Env, String}; + use crate::errors::TrustFlowError; +use crate::storage::{extend_ttl, DataKey}; +use crate::types::{DisputeRecord, EscrowRecord, EscrowStatus}; + +/// Raise a dispute on an escrow, halting release and calling for a jury. +/// +/// Either party to the escrow (the depositor or the beneficiary) may raise a +/// dispute, and only while the escrow is `Active`. The escrow transitions to +/// `Disputed` (which blocks settlement) and a [`DisputeRecord`] is written for +/// the jury to resolve. A `disputed` event is emitted for indexers. +pub fn raise_dispute( + env: &Env, + escrow_id: u64, + caller: &Address, + reason: String, +) -> Result<(), TrustFlowError> { + // The caller must have authorized this invocation. + caller.require_auth(); -pub fn raise_dispute(env: &Env, escrow_id: u64, caller: &Address, reason: String) -> Result<(), TrustFlowError> { - let mut escrow = env.storage().persistent().get::(&DataKey::Escrow(escrow_id)) + let mut escrow = env + .storage() + .persistent() + .get::(&DataKey::Escrow(escrow_id)) .ok_or(TrustFlowError::EscrowNotFound)?; - if !matches!(escrow.status, EscrowStatus::Active) { return Err(TrustFlowError::InvalidState); } + + // Only a party to the escrow may dispute it. + if caller != &escrow.depositor && caller != &escrow.beneficiary { + return Err(TrustFlowError::Unauthorized); + } + + // Only an active escrow can be disputed. This also makes raising idempotent- + // safe: a second call sees `Disputed` (not `Active`) and is rejected, and a + // settled/cancelled escrow can never be re-opened by a dispute. + if !matches!(escrow.status, EscrowStatus::Active) { + return Err(TrustFlowError::InvalidState); + } + + // Halt release. escrow.status = EscrowStatus::Disputed; - env.storage().persistent().set(&DataKey::Escrow(escrow_id), &escrow); - let dispute = DisputeRecord { escrow_id, raised_by: caller.clone(), reason, resolved: false, ruling_for_depositor: false }; - env.storage().persistent().set(&DataKey::Dispute(escrow_id), &dispute); + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &escrow); + extend_ttl(env, &DataKey::Escrow(escrow_id)); + + // Record the dispute for the jury. + let dispute = DisputeRecord { + escrow_id, + raised_by: caller.clone(), + reason, + resolved: false, + ruling_for_depositor: false, + }; + env.storage() + .persistent() + .set(&DataKey::Dispute(escrow_id), &dispute); + extend_ttl(env, &DataKey::Dispute(escrow_id)); + + // Emit an event for indexers: topics (`disputed`, escrow_id), data raised_by. + env.events() + .publish((symbol_short!("disputed"), escrow_id), caller.clone()); + Ok(()) } diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs new file mode 100644 index 0000000..5e772f5 --- /dev/null +++ b/contracts/src/lib.rs @@ -0,0 +1,34 @@ +#![no_std] + +mod dispute; +mod errors; +mod storage; +mod types; + +#[cfg(test)] +mod test; + +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +use crate::errors::TrustFlowError; + +#[contract] +pub struct TrustFlowContract; + +#[contractimpl] +impl TrustFlowContract { + /// Raise a dispute on an active escrow. + /// + /// Callable by either party to the escrow (the depositor or the + /// beneficiary). It halts release by moving the escrow to `Disputed` and + /// records the dispute for a jury to resolve. Fails if the caller is not a + /// party, the escrow does not exist, or it is not `Active`. + pub fn raise_dispute( + env: Env, + escrow_id: u64, + caller: Address, + reason: String, + ) -> Result<(), TrustFlowError> { + dispute::raise_dispute(&env, escrow_id, &caller, reason) + } +} diff --git a/contracts/src/storage.rs b/contracts/src/storage.rs index c14d412..86d6201 100644 --- a/contracts/src/storage.rs +++ b/contracts/src/storage.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address}; +use soroban_sdk::contracttype; #[contracttype] pub enum DataKey { @@ -17,6 +17,8 @@ pub const PERSISTENT_THRESHOLD: u32 = 120_960; pub fn extend_ttl(env: &soroban_sdk::Env, key: &DataKey) { if env.storage().persistent().has(key) { - env.storage().persistent().extend_ttl(key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP); + env.storage() + .persistent() + .bump(key, PERSISTENT_THRESHOLD, PERSISTENT_BUMP); } } diff --git a/contracts/src/test.rs b/contracts/src/test.rs new file mode 100644 index 0000000..8999ced --- /dev/null +++ b/contracts/src/test.rs @@ -0,0 +1,159 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Events as _}, + Address, Env, String, +}; + +use crate::errors::TrustFlowError; +use crate::storage::DataKey; +use crate::types::{DisputeRecord, EscrowRecord, EscrowStatus}; +use crate::{TrustFlowContract, TrustFlowContractClient}; + +struct Setup { + env: Env, + contract_id: Address, + depositor: Address, + beneficiary: Address, + outsider: Address, +} + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, TrustFlowContract); + Setup { + depositor: Address::random(&env), + beneficiary: Address::random(&env), + outsider: Address::random(&env), + contract_id, + env, + } +} + +fn seed_escrow(s: &Setup, id: u64, status: EscrowStatus) { + let token = Address::random(&s.env); + let depositor = s.depositor.clone(); + let beneficiary = s.beneficiary.clone(); + s.env.as_contract(&s.contract_id, || { + let rec = EscrowRecord { + id, + depositor, + beneficiary, + amount: 1_000, + token, + status, + created_at: 0, + release_deadline: 0, + }; + s.env.storage().persistent().set(&DataKey::Escrow(id), &rec); + }); +} + +fn escrow_status(s: &Setup, id: u64) -> EscrowStatus { + s.env.as_contract(&s.contract_id, || { + s.env + .storage() + .persistent() + .get::(&DataKey::Escrow(id)) + .unwrap() + .status + }) +} + +fn dispute_record(s: &Setup, id: u64) -> Option { + s.env.as_contract(&s.contract_id, || { + s.env + .storage() + .persistent() + .get::(&DataKey::Dispute(id)) + }) +} + +#[test] +fn depositor_can_raise_dispute() { + let s = setup(); + seed_escrow(&s, 1, EscrowStatus::Active); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + client.raise_dispute( + &1, + &s.depositor, + &String::from_slice(&s.env, "not delivered"), + ); + + assert_eq!(escrow_status(&s, 1), EscrowStatus::Disputed); + let d = dispute_record(&s, 1).unwrap(); + assert_eq!(d.raised_by, s.depositor); + assert!(!d.resolved); + // exactly one event published (the dispute notification for indexers) + assert_eq!(s.env.events().all().len(), 1); +} + +#[test] +fn beneficiary_can_raise_dispute() { + let s = setup(); + seed_escrow(&s, 1, EscrowStatus::Active); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + client.raise_dispute( + &1, + &s.beneficiary, + &String::from_slice(&s.env, "wrong amount"), + ); + + assert_eq!(escrow_status(&s, 1), EscrowStatus::Disputed); + assert_eq!(dispute_record(&s, 1).unwrap().raised_by, s.beneficiary); +} + +#[test] +fn outsider_cannot_raise_dispute() { + let s = setup(); + seed_escrow(&s, 1, EscrowStatus::Active); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + assert_eq!( + client.try_raise_dispute(&1, &s.outsider, &String::from_slice(&s.env, "meddling")), + Err(Ok(TrustFlowError::Unauthorized)) + ); + // escrow is untouched and no dispute was recorded + assert_eq!(escrow_status(&s, 1), EscrowStatus::Active); + assert!(dispute_record(&s, 1).is_none()); +} + +#[test] +fn cannot_raise_on_missing_escrow() { + let s = setup(); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + assert_eq!( + client.try_raise_dispute(&99, &s.depositor, &String::from_slice(&s.env, "ghost")), + Err(Ok(TrustFlowError::EscrowNotFound)) + ); +} + +#[test] +fn cannot_raise_on_non_active_escrow() { + let s = setup(); + seed_escrow(&s, 1, EscrowStatus::Released); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + assert_eq!( + client.try_raise_dispute(&1, &s.depositor, &String::from_slice(&s.env, "too late")), + Err(Ok(TrustFlowError::InvalidState)) + ); +} + +#[test] +fn cannot_raise_twice() { + let s = setup(); + seed_escrow(&s, 1, EscrowStatus::Active); + let client = TrustFlowContractClient::new(&s.env, &s.contract_id); + + client.raise_dispute(&1, &s.depositor, &String::from_slice(&s.env, "first")); + // escrow is now Disputed, so a second raise is rejected as InvalidState + assert_eq!( + client.try_raise_dispute(&1, &s.beneficiary, &String::from_slice(&s.env, "second")), + Err(Ok(TrustFlowError::InvalidState)) + ); +} diff --git a/contracts/src/types.rs b/contracts/src/types.rs index 127328e..54dbd33 100644 --- a/contracts/src/types.rs +++ b/contracts/src/types.rs @@ -2,7 +2,13 @@ use soroban_sdk::{contracttype, Address}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum EscrowStatus { Pending, Active, Released, Disputed, Cancelled } +pub enum EscrowStatus { + Pending, + Active, + Released, + Disputed, + Cancelled, +} #[contracttype] #[derive(Clone, Debug)]