Skip to content
Merged
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
6 changes: 3 additions & 3 deletions contracts/invoice-escrow/src/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ fn test_integration_escrow_lifecycle_happy_path() {
// 9. Record Payment (Payer settles the invoice)
escrow_client.record_payment(&invoice_id, &payer, &amount);

// Verify settlement balances after the payer transfers funds into escrow.
// The investor receives the net proceeds, the admin receives the fee,
// and the original funded principal remains locked in escrow.
// and the original funded principal is released to the seller.
assert_eq!(payment_token_client.balance(&payer), 0);
assert_eq!(payment_token_client.balance(&admin), 30);
assert_eq!(payment_token_client.balance(&buyer), 970);
assert_eq!(payment_token_client.balance(&escrow_id), 1000);
assert_eq!(payment_token_client.balance(&seller), 1000);
assert_eq!(payment_token_client.balance(&escrow_id), 0);

// Status check
assert_eq!(
Expand Down
53 changes: 40 additions & 13 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl InvoiceEscrow {
token: payment_token.clone(),
inv_token: invoice_token.clone(),
funder: None,
paid_amt: 0,
status: EscrowStatus::Created,
};
storage::set_escrow(&env, invoice_id.clone(), &data);
Expand Down Expand Up @@ -123,12 +124,19 @@ impl InvoiceEscrow {
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;

if data.status != EscrowStatus::Funded {
return Err(Error::AlreadySettled);
}
if amount > data.amount {

let remaining = data
.amount
.checked_sub(data.paid_amt)
.ok_or(Error::Overflow)?;
if amount > remaining {
return Err(Error::InvalidAmount);
}

let funder = data.funder.as_ref().ok_or(Error::EscrowNotFunded)?;
let fee_bps = i128::from(config.fee_bps);
let platform_fee = amount
Expand All @@ -137,24 +145,33 @@ impl InvoiceEscrow {
.checked_div(i128::from(MAX_BPS))
.ok_or(Error::Overflow)?;
let investor_amount = amount.checked_sub(platform_fee).ok_or(Error::Overflow)?;

let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();

// Transfer funds from payer to escrow contract before distributing
// 1. Transfer payer funds into escrow
token.transfer(&payer, &contract, &amount);

// 2. Distribute payer's funds out (investor + platform fee)
token.transfer(&contract, funder, &investor_amount);
token.transfer(&contract, &config.admin, &platform_fee);
data.status = EscrowStatus::Settled;
storage::set_escrow(&env, invoice_id.clone(), &data);

// Unlock invoice token transfers now that the invoice is settled
env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
);
// 3. Release corresponding funding from initial buy-in back to the seller
token.transfer(&contract, &data.seller, &amount);

data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?;

if data.paid_amt == data.amount {
data.status = EscrowStatus::Settled;
// Unlock invoice token transfers only when the invoice is completely settled
env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
);
}

storage::set_escrow(&env, invoice_id.clone(), &data);
events::payment_settled(&env, invoice_id, amount, platform_fee, investor_amount);
Ok(())
}
Expand All @@ -171,10 +188,20 @@ impl InvoiceEscrow {
return Err(Error::RefundNotAllowed);
}
let funder = data.funder.as_ref().ok_or(Error::EscrowNotFunded)?;
let amount = data.amount;

// Refund the remaining collateral (initial buy-in minus already released partial payments)
let amount_to_refund = data
.amount
.checked_sub(data.paid_amt)
.ok_or(Error::Overflow)?;

let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();
token.transfer(&contract, funder, &amount);

if amount_to_refund > 0 {
token.transfer(&contract, funder, &amount_to_refund);
}

data.status = EscrowStatus::Refunded;
storage::set_escrow(&env, invoice_id.clone(), &data);

Expand All @@ -185,7 +212,7 @@ impl InvoiceEscrow {
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
);

events::escrow_refunded(&env, invoice_id, funder, amount);
events::escrow_refunded(&env, invoice_id, funder, amount_to_refund);
Ok(())
}

Expand Down
198 changes: 193 additions & 5 deletions contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,14 @@ fn test_record_payment() {
// Payer should have spent 1000
assert_eq!(payment_token.balance(&payer), 0);

// contract receives 1000 from payer. Then distributes to funder (970) and admin (30).
// Initial contract balance (from fund_escrow): 1000.
// + 1000 from record_payment = 2000.
// - 1000 distributed = 1000.
assert_eq!(payment_token.balance(&escrow_id), 1000); // 1000 remains (the investor's initial funding)
// contract receives 1000 from payer and distributes 1000 (970 to buyer, 30 to admin).
// AND it releases the 1000 initial funding to the seller.
// Initial: 1000. + 1000 (payer) - 1000 (distribute) - 1000 (release) = 0.
assert_eq!(payment_token.balance(&escrow_id), 0);

assert_eq!(payment_token.balance(&buyer), 970);
assert_eq!(payment_token.balance(&admin), 30);
assert_eq!(payment_token.balance(&seller), 1000);
}

#[test]
Expand Down Expand Up @@ -1294,3 +1294,191 @@ fn test_update_fee_not_initialized() {
let result = escrow_client.try_update_platform_fee_bps(&500);
assert_eq!(result, Err(Ok(Error::NotInit)));
}

#[test]
fn test_partial_payment_lifecycle() {
let env = Env::default();
env.mock_all_auths();

let escrow_id = env.register(InvoiceEscrow, ());
let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id);

let admin = Address::generate(&env);
let payment_token_admin = Address::generate(&env);
let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone());
let payment_token = TokenClient::new(&env, &payment_token_id.address());
let payment_token_asset = AssetClient::new(&env, &payment_token_id.address());
let inv_token_id = env.register(MockInvoiceToken, ());

escrow_client.initialize(&admin, &300); // 3% fee

let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let payer = Address::generate(&env);
let invoice_id = Symbol::new(&env, "INV_PARTIAL");
let amount = 1000;

payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);

escrow_client.create_escrow(
&invoice_id,
&seller,
&amount,
&1000000,
&payment_token.address,
&inv_token_id,
);

escrow_client.fund_escrow(&invoice_id, &buyer);

// First payment: 400
escrow_client.record_payment(&invoice_id, &payer, &400);

// Status must still be Funded
assert_eq!(
escrow_client.get_escrow_status(&invoice_id),
EscrowStatus::Funded
);

// Check balances after 400 payment:
// Payer spent 400, remains 600
assert_eq!(payment_token.balance(&payer), 600);
// Admin got 3% of 400 = 12
assert_eq!(payment_token.balance(&admin), 12);
// Buyer (funder) got 400 - 12 = 388
assert_eq!(payment_token.balance(&buyer), 388);
// Seller got 400 released
assert_eq!(payment_token.balance(&seller), 400);
// Contract had 1000. + 400 (payer) - 400 (distribute) - 400 (release) = 600.
assert_eq!(payment_token.balance(&escrow_id), 600);

// Second payment: 600 (completes the 1000)
escrow_client.record_payment(&invoice_id, &payer, &600);

// Status must be Settled
assert_eq!(
escrow_client.get_escrow_status(&invoice_id),
EscrowStatus::Settled
);

// Balances after full settlement:
assert_eq!(payment_token.balance(&payer), 0);
// Admin gets 3% of 600 = 18. Total = 12 + 18 = 30.
assert_eq!(payment_token.balance(&admin), 30);
// Buyer gets 600 - 18 = 582. Total = 388 + 582 = 970.
assert_eq!(payment_token.balance(&buyer), 970);
// Seller gets another 600 released. Total = 400 + 600 = 1000.
assert_eq!(payment_token.balance(&seller), 1000);
// Contract balance should be 0.
assert_eq!(payment_token.balance(&escrow_id), 0);
}

#[test]
fn test_refund_after_partial_payment() {
let env = Env::default();
env.mock_all_auths();

let escrow_id = env.register(InvoiceEscrow, ());
let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id);

let admin = Address::generate(&env);
let payment_token_admin = Address::generate(&env);
let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone());
let payment_token = TokenClient::new(&env, &payment_token_id.address());
let payment_token_asset = AssetClient::new(&env, &payment_token_id.address());
let inv_token_id = env.register(MockInvoiceToken, ());

escrow_client.initialize(&admin, &300);

let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let payer = Address::generate(&env);
let invoice_id = Symbol::new(&env, "INV_REF_PART");
let amount = 1000;
let due_date = 1000;

payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);

escrow_client.create_escrow(
&invoice_id,
&seller,
&amount,
&due_date,
&payment_token.address,
&inv_token_id,
);

escrow_client.fund_escrow(&invoice_id, &buyer);

// Partial payment: 300
escrow_client.record_payment(&invoice_id, &payer, &300);

// Balances now: Contract 700, Seller 300, Buyer 291, Admin 9.
assert_eq!(payment_token.balance(&escrow_id), 700);

// Advance time
env.ledger().with_mut(|li| li.timestamp = due_date + 1);

// Refund
escrow_client.refund(&invoice_id);

// Status is Refunded
assert_eq!(
escrow_client.get_escrow_status(&invoice_id),
EscrowStatus::Refunded
);

// Contract should be 0
assert_eq!(payment_token.balance(&escrow_id), 0);

// Buyer gets the remaining 700 back. Total = 291 + 700 = 991.
assert_eq!(payment_token.balance(&buyer), 991);
// Seller keeps the 300 already released
assert_eq!(payment_token.balance(&seller), 300);
}

#[test]
fn test_record_payment_removes_initial_fund_even_on_full_payment() {
// This is essentially test_record_payment but emphasizing that stranded funds are gone
let env = Env::default();
env.mock_all_auths();

let escrow_id = env.register(InvoiceEscrow, ());
let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id);
let admin = Address::generate(&env);
let pt_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
let payment_token = TokenClient::new(&env, &pt_id.address());
let payment_token_asset = AssetClient::new(&env, &pt_id.address());
let inv_token_id = env.register(MockInvoiceToken, ());

escrow_client.initialize(&admin, &0); // 0% fee to simplify math

let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let payer = Address::generate(&env);
let invoice_id = Symbol::new(&env, "INV_FULL");
let amount = 5000;

payment_token_asset.mint(&buyer, &5000);
payment_token_asset.mint(&payer, &5000);

escrow_client.create_escrow(
&invoice_id,
&seller,
&amount,
&100,
&pt_id.address(),
&inv_token_id,
);
escrow_client.fund_escrow(&invoice_id, &buyer);

assert_eq!(payment_token.balance(&escrow_id), 5000);

escrow_client.record_payment(&invoice_id, &payer, &5000);

assert_eq!(payment_token.balance(&escrow_id), 0);
assert_eq!(payment_token.balance(&seller), 5000);
assert_eq!(payment_token.balance(&buyer), 5000);
}
2 changes: 2 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ pub struct EscrowData {
pub inv_token: soroban_sdk::Address,
/// Investor who funded the escrow (None until funded).
pub funder: Option<soroban_sdk::Address>,
/// Amount already paid by payer.
pub paid_amt: i128,
/// Current status.
pub status: EscrowStatus,
}
Loading
Loading