From 9848b9b2e4046cf82f423faf16a53a797f964090 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Sat, 25 Apr 2026 10:33:40 +0100 Subject: [PATCH] feat(escrow_contract): implement admin dispute resolution and escrow settlement --- contracts/escrow_contract/lib.rs | 132 +++++++++++++++++++++++------- contracts/escrow_contract/test.rs | 56 ++++++++----- 2 files changed, 139 insertions(+), 49 deletions(-) diff --git a/contracts/escrow_contract/lib.rs b/contracts/escrow_contract/lib.rs index 041bfd5..e6dec8a 100644 --- a/contracts/escrow_contract/lib.rs +++ b/contracts/escrow_contract/lib.rs @@ -1,7 +1,12 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Env, Symbol, Address, panic_with_error}; -use shared_types::DeliveryStatus; +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Env, Symbol, Address, panic_with_error, token}; +use shared_types::{DeliveryStatus, events}; + +mod constants { + pub const ESCROW_TTL_THRESHOLD: u32 = 17280; // ~1 day in ledgers (assuming 5s) + pub const ESCROW_TTL_EXTEND_TO: u32 = 518400; // ~30 days +} #[contracttype] #[derive(Clone)] @@ -9,6 +14,7 @@ enum DataKey { Admin, PlatformFeeBps, Amount, + Escrow(u64), } #[contracterror] @@ -25,6 +31,32 @@ pub struct FeeUpdated { pub new_fee: u32, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EscrowState { + Pending, + Released, + Refunded, + Paused, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowRecord { + pub sender: Address, + pub driver: Address, + pub token: Address, + pub amount: i128, + pub status: EscrowState, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeFavour { + Sender, + Driver, +} + #[contract] pub struct EscrowContract; @@ -171,7 +203,7 @@ impl EscrowContract { driver, token, amount, - status: EscrowStatus::Pending, + status: EscrowState::Pending, }, ); env.events().publish( @@ -184,7 +216,7 @@ impl EscrowContract { caller.require_auth(); require_admin(&env, &caller); let mut record = load_escrow(&env, delivery_id); - if record.status != EscrowStatus::Pending { + if record.status != EscrowState::Pending { panic!("escrow is not in pending state"); } token::Client::new(&env, &record.token).transfer( @@ -192,7 +224,7 @@ impl EscrowContract { &record.driver, &record.amount, ); - record.status = EscrowStatus::Released; + record.status = EscrowState::Released; save_escrow(&env, delivery_id, &record); env.events().publish( (events::escrow_released(&env), delivery_id), @@ -204,7 +236,7 @@ impl EscrowContract { caller.require_auth(); require_admin(&env, &caller); let mut record = load_escrow(&env, delivery_id); - if record.status != EscrowStatus::Pending { + if record.status != EscrowState::Pending { panic!("escrow is not in pending state"); } token::Client::new(&env, &record.token).transfer( @@ -212,7 +244,7 @@ impl EscrowContract { &record.sender, &record.amount, ); - record.status = EscrowStatus::Refunded; + record.status = EscrowState::Refunded; save_escrow(&env, delivery_id, &record); env.events().publish( (events::escrow_refunded(&env), delivery_id), @@ -226,10 +258,10 @@ impl EscrowContract { if caller != record.sender { panic!("only the escrow sender can raise a dispute"); } - if record.status != EscrowStatus::Pending { + if record.status != EscrowState::Pending { panic!("escrow is not in pending state"); } - record.status = EscrowStatus::Disputed; + record.status = EscrowState::Paused; save_escrow(&env, delivery_id, &record); let timestamp = env.ledger().timestamp(); env.events().publish( @@ -240,35 +272,51 @@ impl EscrowContract { pub fn resolve_dispute( env: Env, - caller: Address, + admin: Address, delivery_id: u64, - release_to_driver: bool, + favour: DisputeFavour, ) { - caller.require_auth(); - require_admin(&env, &caller); + admin.require_auth(); + + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("Not initialized"); + if admin != stored_admin { + panic!("Unauthorized"); + } + let mut record = load_escrow(&env, delivery_id); - if record.status != EscrowStatus::Disputed { - panic!("escrow is not in disputed state"); + if record.status != EscrowState::Paused { + panic!("escrow is not in paused state"); } - if release_to_driver { - token::Client::new(&env, &record.token).transfer( - &env.current_contract_address(), - &record.driver, - &record.amount, - ); - record.status = EscrowStatus::Released; - } else { - token::Client::new(&env, &record.token).transfer( - &env.current_contract_address(), - &record.sender, - &record.amount, - ); - record.status = EscrowStatus::Refunded; + + match favour { + DisputeFavour::Sender => { + token::Client::new(&env, &record.token).transfer( + &env.current_contract_address(), + &record.sender, + &record.amount, + ); + record.status = EscrowState::Refunded; + }, + DisputeFavour::Driver => { + let fee_bps: u32 = env.storage().instance().get(&DataKey::PlatformFeeBps).unwrap_or(0); + let fee_amount = (record.amount * fee_bps as i128) / 10000; + let release_amount = record.amount - fee_amount; + + token::Client::new(&env, &record.token).transfer( + &env.current_contract_address(), + &record.driver, + &release_amount, + ); + + record.status = EscrowState::Released; + } } + save_escrow(&env, delivery_id, &record); + env.events().publish( (events::dispute_resolved(&env), delivery_id), - (release_to_driver, caller), + (favour, admin), ); } @@ -277,5 +325,29 @@ impl EscrowContract { } } +fn load_escrow(env: &Env, delivery_id: u64) -> EscrowRecord { + env.storage() + .persistent() + .get(&DataKey::Escrow(delivery_id)) + .expect("Escrow not found") +} + +fn save_escrow(env: &Env, delivery_id: u64, record: &EscrowRecord) { + let key = DataKey::Escrow(delivery_id); + env.storage().persistent().set(&key, record); + env.storage().persistent().extend_ttl( + &key, + constants::ESCROW_TTL_THRESHOLD, + constants::ESCROW_TTL_EXTEND_TO, + ); +} + +fn require_admin(env: &Env, caller: &Address) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if *caller != admin { + panic!("Unauthorized"); + } +} + #[cfg(test)] mod test; diff --git a/contracts/escrow_contract/test.rs b/contracts/escrow_contract/test.rs index e30fbc0..7a8ce98 100644 --- a/contracts/escrow_contract/test.rs +++ b/contracts/escrow_contract/test.rs @@ -4,17 +4,15 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Events}, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, + Address, Env, IntoVal, }; #[test] fn test_init_and_get_status() { let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(EscrowContract, ()); - (env, contract_id) -} - + let contract_id = env.register_contract(None, EscrowContract); + let client = EscrowContractClient::new(&env, &contract_id); + // Generate a mock admin address let admin = Address::generate(&env); @@ -50,7 +48,6 @@ fn test_update_platform_fee_success() { // Verify event emission let events = env.events().all(); - panic!("Events: {:?}", events); let last_event = events.last().unwrap(); assert_eq!(last_event.0, contract_id); @@ -235,13 +232,13 @@ fn test_happy_path_create_and_release() { assert_eq!(balance(&env, &token_addr, &contract_id), 1000); let record = client.get_escrow_record(&1u64); - assert_eq!(record.status, EscrowStatus::Pending); + assert_eq!(record.status, EscrowState::Pending); client.release_escrow(&admin, &1u64); assert_eq!(balance(&env, &token_addr, &driver), 1000); assert_eq!(balance(&env, &token_addr, &contract_id), 0); - assert_eq!(client.get_escrow_record(&1u64).status, EscrowStatus::Released); + assert_eq!(client.get_escrow_record(&1u64).status, EscrowState::Released); } #[test] @@ -266,7 +263,7 @@ fn test_refund_path_restores_sender_balance() { assert_eq!(balance(&env, &token_addr, &sender), 500); assert_eq!(balance(&env, &token_addr, &contract_id), 0); - assert_eq!(client.get_escrow_record(&2u64).status, EscrowStatus::Refunded); + assert_eq!(client.get_escrow_record(&2u64).status, EscrowState::Refunded); } #[test] @@ -286,13 +283,13 @@ fn test_dispute_resolved_to_driver() { client.create_escrow(&sender, &driver, &3u64, &token_addr, &750); client.raise_dispute(&sender, &3u64); - assert_eq!(client.get_escrow_record(&3u64).status, EscrowStatus::Disputed); + assert_eq!(client.get_escrow_record(&3u64).status, EscrowState::Paused); - client.resolve_dispute(&admin, &3u64, &true); + client.resolve_dispute(&admin, &3u64, &DisputeFavour::Driver); assert_eq!(balance(&env, &token_addr, &driver), 750); assert_eq!(balance(&env, &token_addr, &sender), 0); - assert_eq!(client.get_escrow_record(&3u64).status, EscrowStatus::Released); + assert_eq!(client.get_escrow_record(&3u64).status, EscrowState::Released); } #[test] @@ -311,11 +308,11 @@ fn test_dispute_resolved_to_sender() { client.create_escrow(&sender, &driver, &4u64, &token_addr, &300); client.raise_dispute(&sender, &4u64); - client.resolve_dispute(&admin, &4u64, &false); + client.resolve_dispute(&admin, &4u64, &DisputeFavour::Sender); assert_eq!(balance(&env, &token_addr, &sender), 300); assert_eq!(balance(&env, &token_addr, &driver), 0); - assert_eq!(client.get_escrow_record(&4u64).status, EscrowStatus::Refunded); + assert_eq!(client.get_escrow_record(&4u64).status, EscrowState::Refunded); } #[test] @@ -396,7 +393,7 @@ fn test_resolve_dispute_by_non_admin_rejected() { client.create_escrow(&sender, &driver, &8u64, &token_addr, &200); client.raise_dispute(&sender, &8u64); - client.resolve_dispute(&attacker, &8u64, &true); + client.resolve_dispute(&attacker, &8u64, &DisputeFavour::Driver); } #[test] @@ -580,7 +577,7 @@ fn test_resolve_dispute_to_driver_emits_dispute_resolved_event() { client.raise_dispute(&sender, &104u64); let event_count_before = env.events().all().len(); - client.resolve_dispute(&admin, &104u64, &true); + client.resolve_dispute(&admin, &104u64, &DisputeFavour::Driver); let event_count_after = env.events().all().len(); // Verify new event was emitted @@ -607,7 +604,7 @@ fn test_resolve_dispute_to_sender_emits_dispute_resolved_event() { client.raise_dispute(&sender, &105u64); let event_count_before = env.events().all().len(); - client.resolve_dispute(&admin, &105u64, &false); + client.resolve_dispute(&admin, &105u64, &DisputeFavour::Sender); let event_count_after = env.events().all().len(); // Verify new event was emitted @@ -633,7 +630,28 @@ fn test_lifecycle_events_emitted() { client.create_escrow(&sender, &driver, &12u64, &token_addr, &600); client.raise_dispute(&sender, &12u64); - client.resolve_dispute(&admin, &12u64, &true); + client.resolve_dispute(&admin, &12u64, &DisputeFavour::Driver); assert!(!env.events().all().is_empty()); } + +fn setup_env() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, EscrowContract); + (env, contract_id) +} + +fn setup_token(env: &Env, admin: &Address) -> Address { + env.register_stellar_asset_contract(admin.clone()) +} + +fn mint(env: &Env, token: &Address, to: &Address, amount: i128) { + let client = StellarAssetClient::new(env, token); + client.mint(to, &amount); +} + +fn balance(env: &Env, token: &Address, user: &Address) -> i128 { + let client = TokenClient::new(env, token); + client.balance(user) +}