Skip to content
Merged
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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 59 additions & 9 deletions contracts/src/dispute.rs
Original file line number Diff line number Diff line change
@@ -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, crate::types::EscrowRecord>(&DataKey::Escrow(escrow_id))
let mut escrow = env
.storage()
.persistent()
.get::<DataKey, EscrowRecord>(&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(())
}
34 changes: 34 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 4 additions & 2 deletions contracts/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{contracttype, Address};
use soroban_sdk::contracttype;

/// Storage keys for the TrustFlow escrow contract
///
Expand Down Expand Up @@ -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);
}
}
159 changes: 159 additions & 0 deletions contracts/src/test.rs
Original file line number Diff line number Diff line change
@@ -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, EscrowRecord>(&DataKey::Escrow(id))
.unwrap()
.status
})
}

fn dispute_record(s: &Setup, id: u64) -> Option<DisputeRecord> {
s.env.as_contract(&s.contract_id, || {
s.env
.storage()
.persistent()
.get::<DataKey, DisputeRecord>(&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))
);
}
Loading