From 7e8eade4c96fa11e7eef8e40e60e812f1ec2da42 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Wed, 24 Jun 2026 18:13:22 +0100 Subject: [PATCH] feat: add wallet network mismatch warning --- contracts/escrow/src/events.rs | 42 +++++++ contracts/escrow/src/lib.rs | 57 ++++++++- contracts/escrow/src/test_escrow_states.rs | 12 +- contracts/escrow/src/test_refund_flow.rs | 131 +++++++++++++++++++++ contracts/escrow/src/types.rs | 1 + 5 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 contracts/escrow/src/test_refund_flow.rs diff --git a/contracts/escrow/src/events.rs b/contracts/escrow/src/events.rs index de86c4b5..dce7dbb5 100644 --- a/contracts/escrow/src/events.rs +++ b/contracts/escrow/src/events.rs @@ -447,3 +447,45 @@ pub fn emit_resolver_rotated( }, ); } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefundRequestedEvent { + pub escrow_id: u64, + pub buyer: Address, + pub requested_at: u64, +} + +/// Topic: `("refund_requested",)`, data: `RefundRequestedEvent`. +pub fn emit_refund_requested(env: &Env, escrow_id: u64, buyer: Address) { + env.events().publish( + (Symbol::new(env, "refund_requested"),), + RefundRequestedEvent { + escrow_id, + buyer, + requested_at: env.ledger().timestamp(), + }, + ); +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefundApprovedEvent { + pub escrow_id: u64, + pub seller: Address, + pub amount: i128, + pub approved_at: u64, +} + +/// Topic: `("refund_approved",)`, data: `RefundApprovedEvent`. +pub fn emit_refund_approved(env: &Env, escrow_id: u64, seller: Address, amount: i128) { + env.events().publish( + (Symbol::new(env, "refund_approved"),), + RefundApprovedEvent { + escrow_id, + seller, + amount, + approved_at: env.ledger().timestamp(), + }, + ); +} diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index b9398c37..764d8f23 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -12,12 +12,12 @@ pub use crate::events::{ AdminRotated, AutoReleased, ContractInitialized, ContractPausedEvent, ContractUnpausedEvent, DeliveryRecorded, DisputeRaised, DisputeResolved, EscrowCancelled, EscrowCompleted, EscrowCreated, EscrowFunded, EscrowShipped, FeeUpdated, FeesWithdrawn, ArbitrationFeeUpdated, - ProtocolFeeUpdated, ResolverRotated, + ProtocolFeeUpdated, ResolverRotated, RefundRequestedEvent, RefundApprovedEvent, emit_admin_rotated, emit_auto_released, emit_contract_initialized, emit_contract_paused, emit_contract_unpaused, emit_delivery_recorded, emit_dispute_raised, emit_dispute_resolved, emit_escrow_cancelled, emit_escrow_completed, emit_escrow_created, emit_escrow_funded, emit_escrow_shipped, emit_fee_updated, emit_fees_withdrawn, emit_arbitration_fee_updated, - emit_protocol_fee_updated, emit_resolver_rotated, + emit_protocol_fee_updated, emit_resolver_rotated, emit_refund_requested, emit_refund_approved, }; pub use crate::types::{ ContractConfig, ContractStats, DataKey, DisputeData, DisputeStatus, EscrowState, @@ -81,14 +81,18 @@ pub fn transition_state( let allowed = matches!( (from, to), (Pending, Funded) - | (Pending, Canceled) + | (Pending, Canceled) | (Funded, Shipped) | (Funded, Completed) | (Funded, Disputed) + | (Funded, RefundRequested) | (Funded, Refunded) | (Shipped, Completed) | (Shipped, Disputed) + | (Shipped, RefundRequested) | (Shipped, Refunded) + | (RefundRequested, Disputed) + | (RefundRequested, Refunded) | (Disputed, Completed) | (Disputed, Refunded) ); @@ -857,7 +861,53 @@ impl Escrow { Ok(()) } + pub fn request_refund(env: Env, caller: Address, escrow_id: u64) -> Result<(), ContractError> { + caller.require_auth(); + ensure_not_paused(&env)?; + let mut escrow = load_escrow(&env, escrow_id)?; + + let buyer = escrow.buyer.clone().ok_or(ContractError::EscrowHasNoBuyer)?; + if caller != buyer { + return Err(ContractError::NotAuthorized); + } + + if escrow.state != EscrowState::Funded && escrow.state != EscrowState::Shipped { + return Err(ContractError::InvalidState); + } + + escrow.state = EscrowState::RefundRequested; + save_escrow(&env, escrow_id, &escrow); + + crate::events::emit_refund_requested(&env, escrow_id, buyer); + Ok(()) + } + + pub fn approve_refund(env: Env, caller: Address, escrow_id: u64) -> Result<(), ContractError> { + caller.require_auth(); + ensure_not_paused(&env)?; + let mut escrow = load_escrow(&env, escrow_id)?; + + if caller != escrow.seller { + return Err(ContractError::NotAuthorized); + } + + if escrow.state != EscrowState::RefundRequested { + return Err(ContractError::InvalidState); + } + + let buyer = escrow.buyer.clone().ok_or(ContractError::EscrowHasNoBuyer)?; + + // Transfer full amount directly to buyer + let token_client = token::Client::new(&env, &escrow.token); + token_client.transfer(&env.current_contract_address(), &buyer, &escrow.amount); + escrow.state = EscrowState::Refunded; + save_escrow(&env, escrow_id, &escrow); + increment_counter(&env, &DataKey::TotalRefunded)?; + + crate::events::emit_refund_approved(&env, escrow_id, escrow.seller.clone(), escrow.amount); + Ok(()) + } pub fn resolve_dispute(env: Env, caller: Address, escrow_id: u64, resolution: ResolutionType) -> Result<(), ContractError> { // SECURITY: @@ -1192,3 +1242,4 @@ mod test_concurrent_vendor_escrows; mod test_not_found; mod test_get_escrows_by_vendor; mod test_resolver_rotation; +mod test_refund_flow; diff --git a/contracts/escrow/src/test_escrow_states.rs b/contracts/escrow/src/test_escrow_states.rs index 33ab0ca1..6e82b1e8 100644 --- a/contracts/escrow/src/test_escrow_states.rs +++ b/contracts/escrow/src/test_escrow_states.rs @@ -14,10 +14,11 @@ fn all_seven_states_are_defined() { EscrowState::Shipped, EscrowState::Completed, EscrowState::Disputed, + EscrowState::RefundRequested, EscrowState::Refunded, EscrowState::Canceled, ]; - assert_eq!(states.len(), 7); + assert_eq!(states.len(), 8); } #[test] @@ -26,10 +27,16 @@ fn legal_transitions_are_accepted() { (EscrowState::Pending, EscrowState::Funded), (EscrowState::Pending, EscrowState::Canceled), (EscrowState::Funded, EscrowState::Shipped), + (EscrowState::Funded, EscrowState::Completed), + (EscrowState::Funded, EscrowState::Disputed), + (EscrowState::Funded, EscrowState::RefundRequested), (EscrowState::Funded, EscrowState::Refunded), (EscrowState::Shipped, EscrowState::Completed), (EscrowState::Shipped, EscrowState::Disputed), + (EscrowState::Shipped, EscrowState::RefundRequested), (EscrowState::Shipped, EscrowState::Refunded), + (EscrowState::RefundRequested, EscrowState::Disputed), + (EscrowState::RefundRequested, EscrowState::Refunded), (EscrowState::Disputed, EscrowState::Completed), (EscrowState::Disputed, EscrowState::Refunded), ]; @@ -58,8 +65,6 @@ fn illegal_transitions_are_rejected() { (EscrowState::Canceled, EscrowState::Pending), // Cannot dispute a Pending escrow that was never funded. (EscrowState::Pending, EscrowState::Disputed), - // Cannot skip shipment. - (EscrowState::Funded, EscrowState::Completed), ]; for (from, to) in illegal { assert_eq!( @@ -81,6 +86,7 @@ fn self_loops_are_illegal() { EscrowState::Shipped, EscrowState::Completed, EscrowState::Disputed, + EscrowState::RefundRequested, EscrowState::Refunded, EscrowState::Canceled, ] { diff --git a/contracts/escrow/src/test_refund_flow.rs b/contracts/escrow/src/test_refund_flow.rs new file mode 100644 index 00000000..152d2922 --- /dev/null +++ b/contracts/escrow/src/test_refund_flow.rs @@ -0,0 +1,131 @@ +#![cfg(test)] +extern crate std; + +use crate::{ContractError, Escrow, EscrowClient, EscrowState}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, Symbol, +}; + +struct Fixture { + env: Env, + client: EscrowClient<'static>, + token: token::StellarAssetClient<'static>, + token_admin: token::StellarAssetClient<'static>, + seller: Address, + buyer: Address, + resolver: Address, + escrow_id: u64, +} + +impl Fixture { + fn new() -> Self { + let env = Env::default(); + let contract_id = env.register_contract(None, Escrow); + let client = EscrowClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_collector = Address::generate(&env); + client.initialize(&admin, &fee_collector, &500); + + let token_admin = Address::generate(&env); + let token_addr = env.register_stellar_asset_contract(token_admin.clone()); + let token = token::StellarAssetClient::new(&env, &token_addr); + let token_admin_client = token::StellarAssetClient::new(&env, &token_addr); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let resolver = Address::generate(&env); + + let amount = 100_000_000; + let fee_bps = 100; + let shipping_window = 86400; + + // Give buyer funds + token_admin_client.mint(&buyer, &amount); + + let escrow_id = client.create_escrow( + &seller, + &Some(buyer.clone()), + &resolver, + &token_addr, + &amount, + &fee_bps, + &shipping_window, + ); + + // Fund escrow + client.mock_all_auths().fund_escrow(&buyer, &escrow_id); + + Self { + env, + client, + token, + token_admin: token_admin_client, + seller, + buyer, + resolver, + escrow_id, + } + } +} + +#[test] +fn test_seller_approves_refund() { + let fx = Fixture::new(); + + // Buyer requests refund + fx.client.mock_all_auths().request_refund(&fx.buyer, &fx.escrow_id); + + let escrow = fx.client.get_escrow(&fx.escrow_id); + assert_eq!(escrow.state, EscrowState::RefundRequested); + + let contract_addr = fx.client.address.clone(); + let contract_balance_before = fx.token.balance(&contract_addr); + let buyer_balance_before = fx.token.balance(&fx.buyer); + + // Seller approves refund + fx.client.mock_all_auths().approve_refund(&fx.seller, &fx.escrow_id); + + let escrow_after = fx.client.get_escrow(&fx.escrow_id); + assert_eq!(escrow_after.state, EscrowState::Refunded); + + let contract_balance_after = fx.token.balance(&contract_addr); + let buyer_balance_after = fx.token.balance(&fx.buyer); + + assert_eq!(contract_balance_after, contract_balance_before - escrow.amount); + assert_eq!(buyer_balance_after, buyer_balance_before + escrow.amount); + assert_eq!(contract_balance_after, 0); // Contract is empty since we minted exact amount +} + +#[test] +fn test_invalid_state_approve_refund() { + let fx = Fixture::new(); + + // Trying to approve refund when state is Funded should fail + let res = fx.client.mock_all_auths().try_approve_refund(&fx.seller, &fx.escrow_id); + assert_eq!(res, Err(Ok(ContractError::InvalidState))); +} + +#[test] +fn test_unauthorized_seller_approve_refund() { + let fx = Fixture::new(); + + fx.client.mock_all_auths().request_refund(&fx.buyer, &fx.escrow_id); + + // Trying to approve refund with buyer instead of seller + let res = fx.client.mock_all_auths().try_approve_refund(&fx.buyer, &fx.escrow_id); + assert_eq!(res, Err(Ok(ContractError::NotAuthorized))); +} + +#[test] +fn test_double_approval() { + let fx = Fixture::new(); + + fx.client.mock_all_auths().request_refund(&fx.buyer, &fx.escrow_id); + fx.client.mock_all_auths().approve_refund(&fx.seller, &fx.escrow_id); + + // Second approval should fail + let res = fx.client.mock_all_auths().try_approve_refund(&fx.seller, &fx.escrow_id); + assert_eq!(res, Err(Ok(ContractError::InvalidState))); +} diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index 069453c5..63ef0a45 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -94,6 +94,7 @@ pub enum EscrowState { Shipped, Completed, Disputed, + RefundRequested, Refunded, Canceled, }