Skip to content
Open
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
42 changes: 42 additions & 0 deletions contracts/escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,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(),
},
);
}
61 changes: 61 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ pub mod storage;
pub mod types;
pub use crate::errors::ContractError;
pub use crate::events::{
AdminRotated, AutoReleased, ContractInitialized, ContractPausedEvent, ContractUnpausedEvent,
DeliveryRecorded, DisputeRaised, DisputeResolved, EscrowCancelled, EscrowCompleted,
EscrowCreated, EscrowFunded, EscrowShipped, FeeUpdated, FeesWithdrawn, ArbitrationFeeUpdated,
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_refund_requested, emit_refund_approved,
emit_admin_rotated, emit_arbitration_fee_updated, emit_auto_released,
emit_contract_initialized, emit_contract_paused, emit_contract_unpaused,
emit_delivery_recorded, emit_dispute_raised, emit_dispute_resolved, emit_escrow_cancelled,
Expand Down Expand Up @@ -79,6 +88,21 @@ pub const MAX_ESCROW_AMOUNT: i128 = i128::MAX / 10_000;
/// every legal edge in one place.
pub fn transition_state(from: &EscrowState, to: &EscrowState) -> Result<(), ContractError> {
use EscrowState::*;
let allowed = matches!(
(from, to),
(Pending, Funded)
| (Pending, Canceled)
| (Funded, Shipped)
| (Funded, Completed)
| (Funded, Disputed)
| (Funded, RefundRequested)
| (Funded, Refunded)
| (Shipped, Completed)
| (Shipped, Disputed)
| (Shipped, RefundRequested)
| (Shipped, Refunded)
| (RefundRequested, Disputed)
| (RefundRequested, Refunded)
let allowed = matches!(
(from, to),
(Pending, Funded)
Expand Down Expand Up @@ -1055,6 +1079,8 @@ impl Escrow {
Ok(())
}

pub fn request_refund(env: Env, caller: Address, escrow_id: u64) -> Result<(), ContractError> {
caller.require_auth();
pub fn raise_dispute(
env: Env,
caller: Address,
Expand All @@ -1074,6 +1100,40 @@ impl Escrow {
}

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);
return Err(ContractError::InvalidStateTransition);
}

Expand Down Expand Up @@ -1489,6 +1549,7 @@ mod test_overflow;
mod test_pause;
mod test_resolution;
mod test_resolver_rotation;
mod test_refund_flow;
mod test_set_fee_boundary;
mod test_string_length;
mod test_ttl;
Expand Down
12 changes: 9 additions & 3 deletions contracts/escrow/src/test_escrow_states.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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),
];
Expand Down Expand Up @@ -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!(
Expand All @@ -81,6 +86,7 @@ fn self_loops_are_illegal() {
EscrowState::Shipped,
EscrowState::Completed,
EscrowState::Disputed,
EscrowState::RefundRequested,
EscrowState::Refunded,
EscrowState::Canceled,
] {
Expand Down
131 changes: 131 additions & 0 deletions contracts/escrow/src/test_refund_flow.rs
Original file line number Diff line number Diff line change
@@ -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)));
}
1 change: 1 addition & 0 deletions contracts/escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub enum EscrowState {
Shipped,
Completed,
Disputed,
RefundRequested,
Refunded,
Canceled,
}
Loading