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/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 5cf3f74..2e0f682 100644 --- a/contracts/src/storage.rs +++ b/contracts/src/storage.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address}; +use soroban_sdk::contracttype; /// Storage keys for the TrustFlow escrow contract /// @@ -39,6 +39,8 @@ pub const PERSISTENT_THRESHOLD: u32 = 120_960; /// * `key` - Storage key to extend 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)) + ); +}