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
123 changes: 112 additions & 11 deletions contracts/marketx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
Expand Down
89 changes: 89 additions & 0 deletions contracts/marketx/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =========================
Expand Down
2 changes: 2 additions & 0 deletions contracts/marketx/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ pub struct Escrow {
pub status: EscrowStatus,
pub metadata: Option<Bytes>,
pub arbiter: Option<Address>,
/// Party that proposed mutual cancellation, if any.
pub cancellation_proposer: Option<Address>,
/// Individual items/milestones within this escrow
/// If empty, the entire escrow is treated as a single item
pub items: Vec<EscrowItem>,
Expand Down