Skip to content

Commit 28bb91d

Browse files
committed
fix: implement validation and fund transfer for release_partial
1 parent 5ecb5c1 commit 28bb91d

4 files changed

Lines changed: 243 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,9 @@ Persistent entries on Soroban expire unless their TTL is extended. The contract
364364

365365
## Current Implementation Notes
366366

367-
The current public flows are `create_escrow`, `fund_escrow`, `release_escrow`, `resolve_dispute`, pause/unpause, fee updates, fee-collector rotation, storage rent estimation, and `bump_escrow`.
367+
The current public flows are `create_escrow`, `fund_escrow`, `release_escrow`, `release_partial`, `release_item`, `refund_escrow`, `resolve_dispute`, pause/unpause, fee updates, fee-collector rotation, storage rent estimation, and `bump_escrow`.
368368

369-
`release_partial`, `refund_escrow`, and broader pending-state transitions are still placeholders and should not yet be treated as production-ready flows.
369+
`release_partial` supports arbitrary releases only for non-itemized funded escrows. Itemized escrows must use `release_item`; the contract rejects attempts to mix these release paths. `refund_escrow` is implemented and persists refund requests for dispute resolution.
370370

371371
## License
372372

contracts/marketx/src/lifecycle.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ impl Contract {
178178

179179
Self::assert_escrow_funded(&escrow)?;
180180

181+
// Itemized escrows must be released through release_item so each item
182+
// can only be paid once.
183+
if !escrow.items.is_empty() {
184+
return Err(ContractError::InvalidEscrowState);
185+
}
186+
181187
escrow.buyer.require_auth();
182188
let actor = escrow.buyer.clone();
183189
let from_status = escrow.status.clone();
@@ -210,9 +216,79 @@ impl Contract {
210216

211217
Ok(())
212218
}
213-
pub fn release_partial(env: Env, _escrow_id: u64, _amount: i128) -> Result<(), ContractError> {
219+
220+
/// Release part of a non-itemized funded escrow to the seller.
221+
///
222+
/// Only the buyer can authorize a release. The escrow amount is reduced
223+
/// by the released amount and becomes Released when its balance reaches
224+
/// zero. Itemized escrows must use `release_item` instead.
225+
///
226+
/// # Arguments
227+
/// * `escrow_id` - The ID of the escrow
228+
/// * `amount` - The positive amount to release
229+
///
230+
/// # Errors
231+
/// * `EscrowNotFound` - If the escrow doesn't exist
232+
/// * `InvalidEscrowState` - If the escrow is not funded or is itemized
233+
/// * `InvalidEscrowAmount` - If the amount is non-positive or exceeds the remaining balance
234+
/// * `FeatureDisabled` - If partial releases are disabled
235+
pub fn release_partial(env: Env, escrow_id: u64, amount: i128) -> Result<(), ContractError> {
214236
Self::assert_not_paused(&env)?;
215237
Self::assert_partial_releases_enabled(&env)?;
238+
239+
let mut escrow: Escrow = env
240+
.storage()
241+
.persistent()
242+
.get(&DataKey::Escrow(escrow_id))
243+
.ok_or(ContractError::EscrowNotFound)?;
244+
245+
Self::assert_escrow_funded(&escrow)?;
246+
if !escrow.items.is_empty() {
247+
return Err(ContractError::InvalidEscrowState);
248+
}
249+
if amount <= 0 || amount > escrow.amount {
250+
return Err(ContractError::InvalidEscrowAmount);
251+
}
252+
253+
escrow.buyer.require_auth();
254+
let from_status = escrow.status.clone();
255+
let fee = Self::process_seller_transfer(
256+
&env,
257+
escrow_id,
258+
amount,
259+
&escrow.token,
260+
&escrow.seller,
261+
&escrow.buyer,
262+
)?;
263+
264+
escrow.amount -= amount;
265+
if escrow.amount == 0 {
266+
escrow.status = EscrowStatus::Released;
267+
escrow.cancellation_proposer = None;
268+
}
269+
env.storage()
270+
.persistent()
271+
.set(&DataKey::Escrow(escrow_id), &escrow);
272+
273+
FundsReleasedEvent {
274+
escrow_id,
275+
amount,
276+
fee,
277+
}
278+
.publish(&env);
279+
280+
if escrow.status != from_status {
281+
Self::emit_status_change(
282+
&env,
283+
escrow_id,
284+
from_status,
285+
escrow.status.clone(),
286+
escrow.buyer.clone(),
287+
);
288+
}
289+
290+
Self::add_i128(&env, DataKey::TotalReleasedAmount, amount);
291+
Self::add_u32(&env, DataKey::TotalReleasedCount);
216292
Ok(())
217293
}
218294

contracts/marketx/src/test.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,169 @@ fn disabled_feature_flags_block_paths() {
274274
assert_eq!(resolve_dispute, Err(Ok(ContractError::FeatureDisabled)));
275275
}
276276

277+
#[test]
278+
fn partial_release_validates_amount_and_escrow() {
279+
let (env, client) = setup();
280+
let admin = Address::generate(&env);
281+
let buyer = Address::generate(&env);
282+
let seller = Address::generate(&env);
283+
let token_id = env.register_stellar_asset_contract_v2(admin.clone());
284+
let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address());
285+
286+
env.mock_all_auths();
287+
client.initialize(&admin, &admin, &0, &0, &0);
288+
token_admin.mint(&buyer, &1000);
289+
290+
let escrow_id = client.create_escrow(
291+
&buyer,
292+
&seller,
293+
&token_id.address(),
294+
&1000,
295+
&None,
296+
&None,
297+
&None,
298+
&None,
299+
);
300+
client.fund_escrow(&escrow_id);
301+
302+
assert_eq!(
303+
client.try_release_partial(&999_999, &1),
304+
Err(Ok(ContractError::EscrowNotFound))
305+
);
306+
assert_eq!(
307+
client.try_release_partial(&escrow_id, &0),
308+
Err(Ok(ContractError::InvalidEscrowAmount))
309+
);
310+
assert_eq!(
311+
client.try_release_partial(&escrow_id, &-1),
312+
Err(Ok(ContractError::InvalidEscrowAmount))
313+
);
314+
assert_eq!(
315+
client.try_release_partial(&escrow_id, &1001),
316+
Err(Ok(ContractError::InvalidEscrowAmount))
317+
);
318+
}
319+
320+
#[test]
321+
#[should_panic]
322+
fn non_buyer_cannot_make_partial_release() {
323+
let (env, client) = setup();
324+
let admin = Address::generate(&env);
325+
let buyer = Address::generate(&env);
326+
let seller = Address::generate(&env);
327+
let token_id = env.register_stellar_asset_contract_v2(admin.clone());
328+
let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address());
329+
330+
env.mock_all_auths();
331+
client.initialize(&admin, &admin, &0, &0, &0);
332+
token_admin.mint(&buyer, &1000);
333+
let escrow_id = client.create_escrow(
334+
&buyer,
335+
&seller,
336+
&token_id.address(),
337+
&1000,
338+
&None,
339+
&None,
340+
&None,
341+
&None,
342+
);
343+
client.fund_escrow(&escrow_id);
344+
345+
client
346+
.mock_auths(&[MockAuth {
347+
address: &seller,
348+
invoke: &MockAuthInvoke {
349+
contract: &client.address,
350+
fn_name: "release_partial",
351+
args: (&escrow_id, 100i128).into_val(&env),
352+
sub_invokes: &[],
353+
},
354+
}])
355+
.release_partial(&escrow_id, &100);
356+
}
357+
358+
#[test]
359+
fn partial_release_transfers_remaining_balance_and_updates_analytics() {
360+
let (env, client) = setup();
361+
let admin = Address::generate(&env);
362+
let buyer = Address::generate(&env);
363+
let seller = Address::generate(&env);
364+
let token_id = env.register_stellar_asset_contract_v2(admin.clone());
365+
let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address());
366+
let token = soroban_sdk::token::Client::new(&env, &token_id.address());
367+
368+
env.mock_all_auths();
369+
client.initialize(&admin, &admin, &0, &0, &0);
370+
token_admin.mint(&buyer, &1000);
371+
let escrow_id = client.create_escrow(
372+
&buyer,
373+
&seller,
374+
&token_id.address(),
375+
&1000,
376+
&None,
377+
&None,
378+
&None,
379+
&None,
380+
);
381+
client.fund_escrow(&escrow_id);
382+
383+
client.release_partial(&escrow_id, &400);
384+
let escrow = client.get_escrow(&escrow_id).unwrap();
385+
assert_eq!(escrow.amount, 600);
386+
assert_eq!(escrow.status, crate::types::EscrowStatus::Funded);
387+
assert_eq!(token.balance(&seller), 400);
388+
assert_eq!(client.get_total_released_amount(), 400);
389+
assert_eq!(client.get_total_released_count(), 1);
390+
391+
client.release_partial(&escrow_id, &600);
392+
let escrow = client.get_escrow(&escrow_id).unwrap();
393+
assert_eq!(escrow.amount, 0);
394+
assert_eq!(escrow.status, crate::types::EscrowStatus::Released);
395+
assert_eq!(token.balance(&seller), 1000);
396+
assert_eq!(client.get_total_released_amount(), 1000);
397+
assert_eq!(client.get_total_released_count(), 2);
398+
}
399+
400+
#[test]
401+
fn partial_release_cannot_be_used_with_itemized_escrow() {
402+
let (env, client) = setup();
403+
let admin = Address::generate(&env);
404+
let buyer = Address::generate(&env);
405+
let seller = Address::generate(&env);
406+
let token_id = env.register_stellar_asset_contract_v2(admin.clone());
407+
let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_id.address());
408+
let mut items = Vec::new(&env);
409+
items.push_back(EscrowItem {
410+
amount: 1000,
411+
released: false,
412+
description: None,
413+
});
414+
415+
env.mock_all_auths();
416+
client.initialize(&admin, &admin, &0, &0, &0);
417+
token_admin.mint(&buyer, &1000);
418+
let escrow_id = client.create_escrow(
419+
&buyer,
420+
&seller,
421+
&token_id.address(),
422+
&1000,
423+
&None,
424+
&None,
425+
&Some(items),
426+
&None,
427+
);
428+
client.fund_escrow(&escrow_id);
429+
430+
assert_eq!(
431+
client.try_release_partial(&escrow_id, &500),
432+
Err(Ok(ContractError::InvalidEscrowState))
433+
);
434+
assert_eq!(
435+
client.try_release_escrow(&escrow_id),
436+
Err(Ok(ContractError::InvalidEscrowState))
437+
);
438+
}
439+
277440
#[test]
278441
fn escrow_ids_increment_sequentially() {
279442
let (env, client) = setup();

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)