diff --git a/contracts/marketx/src/lib.rs b/contracts/marketx/src/lib.rs index 31a9b5c..2d55ec7 100644 --- a/contracts/marketx/src/lib.rs +++ b/contracts/marketx/src/lib.rs @@ -266,6 +266,33 @@ fn add_u32(env: &Env, key: DataKey) { } .publish(env); } + + fn is_escrow_party(escrow: &Escrow, actor: &Address) -> bool { + *actor == escrow.buyer || *actor == escrow.seller + } + + fn has_released_items(escrow: &Escrow) -> bool { + for item in escrow.items.iter() { + if item.released { + return true; + } + } + + false + } + + fn refund_buyer(env: &Env, escrow: &mut Escrow) { + let token_client = soroban_sdk::token::Client::new(env, &escrow.token); + token_client.transfer( + &env.current_contract_address(), + &escrow.buyer, + &escrow.amount, + ); + + Self::add_i128(env, DataKey::TotalRefundedAmount, escrow.amount); + escrow.status = EscrowStatus::Refunded; + escrow.cancellation_proposer = None; + } } #[contractimpl] @@ -464,6 +491,7 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); status: EscrowStatus::Pending, metadata: metadata.clone(), arbiter: arbiter.clone(), + cancellation_proposer: None, items: escrow_items, }; @@ -676,6 +704,7 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); // 7. Update escrow status to Released // 5. Update escrow status to Released escrow.status = EscrowStatus::Released; + escrow.cancellation_proposer = None; env.storage() .persistent() .set(&DataKey::Escrow(escrow_id), &escrow); @@ -810,6 +839,85 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); Ok(()) } + pub fn propose_cancellation( + env: Env, + escrow_id: u64, + actor: Address, + ) -> Result<(), ContractError> { + Self::assert_not_paused(&env)?; + actor.require_auth(); + + let mut escrow: Escrow = env + .storage() + .persistent() + .get(&DataKey::Escrow(escrow_id)) + .ok_or(ContractError::EscrowNotFound)?; + + if !Self::is_escrow_party(&escrow, &actor) { + return Err(ContractError::Unauthorized); + } + + if escrow.status != EscrowStatus::Pending || Self::has_released_items(&escrow) { + return Err(ContractError::InvalidEscrowState); + } + + if let Some(existing) = &escrow.cancellation_proposer { + if *existing == actor { + return Ok(()); + } + + return Err(ContractError::InvalidEscrowState); + } + + escrow.cancellation_proposer = Some(actor); + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &escrow); + + Ok(()) + } + + pub fn accept_cancellation( + env: Env, + escrow_id: u64, + actor: Address, + ) -> Result<(), ContractError> { + Self::assert_not_paused(&env)?; + actor.require_auth(); + + let mut escrow: Escrow = env + .storage() + .persistent() + .get(&DataKey::Escrow(escrow_id)) + .ok_or(ContractError::EscrowNotFound)?; + + if !Self::is_escrow_party(&escrow, &actor) { + return Err(ContractError::Unauthorized); + } + + if escrow.status != EscrowStatus::Pending || Self::has_released_items(&escrow) { + return Err(ContractError::InvalidEscrowState); + } + + let proposer = escrow + .cancellation_proposer + .clone() + .ok_or(ContractError::InvalidEscrowState)?; + + if proposer == actor { + return Err(ContractError::Unauthorized); + } + + let from_status = escrow.status.clone(); + Self::refund_buyer(&env, &mut escrow); + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &escrow); + Self::emit_status_change(&env, escrow_id, from_status, escrow.status.clone(), actor); + + Ok(()) + } + pub fn refund_escrow( env: Env, escrow_id: u64, @@ -869,6 +977,7 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); let from_status = escrow.status.clone(); escrow.status = EscrowStatus::Disputed; + escrow.cancellation_proposer = None; Self::add_u32(&env, DataKey::TotalDisputedCount); env.storage() .persistent() @@ -950,18 +1059,16 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); }; let from_status = escrow.status.clone(); - let token_client = soroban_sdk::token::Client::new(&env, &escrow.token); - - - if resolution == 0 { // Release to seller + let token_client = soroban_sdk::token::Client::new(&env, &escrow.token); token_client.transfer( &env.current_contract_address(), &escrow.seller, &escrow.amount, ); escrow.status = EscrowStatus::Released; + escrow.cancellation_proposer = None; let current_released_total: i128 = env .storage() @@ -974,13 +1081,7 @@ env.storage().persistent().set(&DataKey::TotalFeesCollected, &0i128); } else if resolution == 1 { // Refund to buyer - token_client.transfer( - &env.current_contract_address(), - &escrow.buyer, - &escrow.amount, - ); - Self::add_i128(&env, DataKey::TotalRefundedAmount, escrow.amount); - escrow.status = EscrowStatus::Refunded; + Self::refund_buyer(&env, &mut escrow); } else { return Err(ContractError::InvalidEscrowState); } diff --git a/contracts/marketx/src/test.rs b/contracts/marketx/src/test.rs index c2e9652..5eb1279 100644 --- a/contracts/marketx/src/test.rs +++ b/contracts/marketx/src/test.rs @@ -673,6 +673,95 @@ fn fund_fails_if_buyer_has_insufficient_balance() { assert!(result.is_err()); } +#[test] +fn seller_can_accept_buyer_cancellation_and_refund_immediately() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address()); + let token = soroban_sdk::token::Client::new(&env, &token_id.address()); + + env.mock_all_auths(); + client.initialize(&admin, &admin, &0); + + token_admin.mint(&buyer, &1000); + let escrow_id = client.create_escrow(&buyer, &seller, &token_id.address(), &1000, &None, &None, &None); + client.fund_escrow(&escrow_id); + + client.propose_cancellation(&escrow_id, &buyer); + client.accept_cancellation(&escrow_id, &seller); + + assert_eq!(token.balance(&buyer), 1000); + assert_eq!(token.balance(&client.address), 0); + assert_eq!(client.get_total_refunded_amount(), 1000); + + let escrow = client.get_escrow(&escrow_id).unwrap(); + assert_eq!(escrow.status, crate::types::EscrowStatus::Refunded); + assert_eq!(escrow.cancellation_proposer, None); +} + +#[test] +fn accept_cancellation_fails_without_prior_proposal() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let token = Address::generate(&env); + + env.mock_all_auths(); + client.initialize(&admin, &admin, &0); + + let escrow_id = client.create_escrow(&buyer, &seller, &token, &1000, &None, &None, &None); + + let result = client.try_accept_cancellation(&escrow_id, &seller); + assert_eq!(result, Err(Ok(ContractError::InvalidEscrowState))); +} + +#[test] +fn cancellation_fails_after_partial_item_release() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(admin.clone()); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address()); + + env.mock_all_auths(); + client.initialize(&admin, &admin, &0); + + let mut items = Vec::new(&env); + items.push_back(EscrowItem { + amount: 400, + released: false, + description: None, + }); + items.push_back(EscrowItem { + amount: 600, + released: false, + description: None, + }); + + token_admin.mint(&client.address, &1000); + let escrow_id = client.create_escrow( + &buyer, + &seller, + &token_id.address(), + &1000, + &None, + &None, + &Some(items), + ); + + client.release_item(&escrow_id, &0u32); + + let result = client.try_propose_cancellation(&escrow_id, &buyer); + assert_eq!(result, Err(Ok(ContractError::InvalidEscrowState))); +} + // ========================= // ARBITER TESTS // ========================= diff --git a/contracts/marketx/src/types.rs b/contracts/marketx/src/types.rs index ebcc913..c73047f 100644 --- a/contracts/marketx/src/types.rs +++ b/contracts/marketx/src/types.rs @@ -86,6 +86,8 @@ pub struct Escrow { pub status: EscrowStatus, pub metadata: Option, pub arbiter: Option
, + /// Party that proposed mutual cancellation, if any. + pub cancellation_proposer: Option
, /// Individual items/milestones within this escrow /// If empty, the entire escrow is treated as a single item pub items: Vec,