diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs
index 7a84e30..bdd8ad3 100644
--- a/contracts/invoice-escrow/src/errors.rs
+++ b/contracts/invoice-escrow/src/errors.rs
@@ -37,7 +37,9 @@ pub enum Error {
/// Contract is paused and the requested operation is temporarily disabled.
Paused = 15,
/// Payer is not the authorized debtor for this invoice.
- InvalidPayer = 15,
+ InvalidPayer = 16,
/// Due date is invalid (e.g., in the past or zero).
- InvalidDueDate = 16,
+ InvalidDueDate = 17,
+ /// The token used for funding or payment is not in the accepted tokens list.
+ TokenNotAccepted = 18,
}
diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs
index c480bcd..35dd1b1 100644
--- a/contracts/invoice-escrow/src/events.rs
+++ b/contracts/invoice-escrow/src/events.rs
@@ -1,8 +1,9 @@
//! Event definitions for state changes (escrow_created, escrow_funded, payment_settled).
-use soroban_sdk::{Address, Env, Symbol};
+use soroban_sdk::{Address, Env, Symbol, Vec};
/// Publish escrow_created event.
+#[allow(clippy::too_many_arguments)]
pub fn escrow_created(
env: &Env,
inv_id: Symbol,
@@ -14,7 +15,10 @@ pub fn escrow_created(
token: &Address,
inv_token: &Address,
commitment: &soroban_sdk::BytesN<32>,
+ accepted_tokens: &Vec
,
) {
+ // accepted_tokens is published as a raw Val to avoid tuple-element trait bounds
+ // that Vec does not satisfy directly. Callers decode with try_into_val.
env.events().publish(
(Symbol::new(env, "escrow_created"),),
(
@@ -27,6 +31,7 @@ pub fn escrow_created(
token,
inv_token,
commitment,
+ accepted_tokens.to_val(),
),
);
}
diff --git a/contracts/invoice-escrow/src/integration_test.rs b/contracts/invoice-escrow/src/integration_test.rs
index d152795..0e9a2bf 100644
--- a/contracts/invoice-escrow/src/integration_test.rs
+++ b/contracts/invoice-escrow/src/integration_test.rs
@@ -72,10 +72,11 @@ fn test_integration_escrow_lifecycle_happy_path() {
&payment_token_id.address(),
&inv_token_id,
&test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()],
);
// 8. Fund Escrow (Buyer buys the invoice)
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
// Verify buyer received invoice tokens and paid payment tokens
assert_eq!(inv_token_client.balance(&buyer), amount);
@@ -160,9 +161,10 @@ fn test_integration_refund_lifecycle() {
&payment_token_id.address(),
&inv_token_id,
&test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()],
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
// Attempt refund before due date (should fail)
let res = escrow_client.try_refund(&invoice_id);
@@ -231,12 +233,13 @@ fn test_integration_token_locked_during_active_escrow() {
&payment_token_id.address(),
&inv_token_id,
&test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()],
);
// Token is locked even before funding (initialized locked)
assert!(inv_token_client.transfer_locked());
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
// Token is still locked after funding — transfers are blocked while invoice is active
assert!(inv_token_client.transfer_locked());
diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs
index cddf3b7..79b02a6 100644
--- a/contracts/invoice-escrow/src/lib.rs
+++ b/contracts/invoice-escrow/src/lib.rs
@@ -10,7 +10,7 @@ mod events;
mod storage;
mod types;
-use soroban_sdk::{contract, contractimpl, token, Address, Env, IntoVal, Symbol};
+use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol, Vec};
// EscrowStatus is re-exported publicly; Config and EscrowData are crate-private.
pub use types::EscrowStatus;
@@ -32,6 +32,23 @@ fn ensure_not_paused(config: &Config) -> Result<(), Error> {
Ok(())
}
+/// Return the `Some` value inside an `Option` wrapped as a Soroban Val.
+/// Soroban SDK does not implement `IntoVal` for `Option` directly,
+/// so we convert via a helper that maps `None` → an error instead of panicking.
+fn require_funder(funder: Option) -> Result {
+ funder.ok_or(Error::EscrowNotFunded)
+}
+
+/// Check that `token` is present in the `accepted_tokens` list.
+fn is_token_accepted(accepted: &Vec, token: &Address) -> bool {
+ for i in 0..accepted.len() {
+ if &accepted.get(i).unwrap() == token {
+ return true;
+ }
+ }
+ false
+}
+
#[contractimpl]
impl InvoiceEscrow {
/// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%).
@@ -52,10 +69,16 @@ impl InvoiceEscrow {
Ok(())
}
- /// Create an escrow for an invoice. Caller (seller) must be authenticated.
- /// face_value: what the debtor owes (amount to be paid at settlement)
- /// purchase_price: what the investor pays (discount applied here)
- /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data)
+ /// Create an escrow for an invoice. Caller (seller) must be authenticated.
+ ///
+ /// # Parameters
+ /// * `accepted_tokens` – non-empty list of token contract addresses accepted
+ /// for funding and payment. The first element is the canonical token; all
+ /// tokens in the list are equally valid. Pass a one-element Vec to keep the
+ /// original single-token behaviour.
+ /// * `face_value` – what the debtor owes (amount to be paid at settlement).
+ /// * `purchase_price` – what the investor(s) pay in total (discount applied here).
+ /// * `commitment` – immutable on-chain anchor (SHA-256 hash of off-chain invoice data).
pub fn create_escrow(
env: Env,
invoice_id: Symbol,
@@ -67,6 +90,7 @@ impl InvoiceEscrow {
payment_token: Address,
invoice_token: Address,
commitment: soroban_sdk::BytesN<32>,
+ accepted_tokens: Vec,
) -> Result<(), Error> {
seller.require_auth();
if face_value <= 0 || purchase_price <= 0 {
@@ -79,7 +103,16 @@ impl InvoiceEscrow {
if due_date <= current_timestamp {
return Err(Error::InvalidDueDate);
}
- storage::get_config(&env).ok_or(Error::NotInit)?;
+ // accepted_tokens must be non-empty.
+ if accepted_tokens.is_empty() {
+ return Err(Error::InvalidAmount);
+ }
+ // payment_token must be in the accepted_tokens list.
+ if !is_token_accepted(&accepted_tokens, &payment_token) {
+ return Err(Error::TokenNotAccepted);
+ }
+
+ storage::get_config(&env).ok_or(Error::NotInit).and_then(|cfg| ensure_not_paused(&cfg))?;
if storage::has_escrow(&env, invoice_id.clone()) {
return Err(Error::EscrowExists);
}
@@ -92,11 +125,14 @@ impl InvoiceEscrow {
funded_amt: 0,
funder: None,
due_dt: due_date,
+ // `token` is set to `payment_token` (canonical token) until fund_escrow
+ // locks it to whichever accepted token the first funder uses.
token: payment_token.clone(),
inv_token: invoice_token.clone(),
paid_amt: 0,
status: EscrowStatus::Created,
commitment: commitment.clone(),
+ accepted_tokens: accepted_tokens.clone(),
};
storage::set_escrow(&env, invoice_id.clone(), &data);
events::escrow_created(
@@ -110,6 +146,7 @@ impl InvoiceEscrow {
&payment_token,
&invoice_token,
&commitment,
+ &accepted_tokens,
);
Ok(())
}
@@ -136,12 +173,20 @@ impl InvoiceEscrow {
}
/// Fund the escrow (investor buys part or all of the invoice at purchase_price).
- /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed.
+ ///
+ /// The `funding_token` parameter must be one of the escrow's `accepted_tokens`.
+ /// All funding must use the **same** token: once the first funder's token is
+ /// recorded (stored as `data.token`), subsequent partial funders must also use
+ /// that token.
+ ///
+ /// Transfers `amount` from buyer to this contract. Multiple investors can fund
+ /// until fully subscribed.
pub fn fund_escrow(
env: Env,
invoice_id: Symbol,
buyer: Address,
amount: i128,
+ funding_token: Address,
) -> Result<(), Error> {
buyer.require_auth();
// Fail fast: validate amount before hitting storage.
@@ -160,13 +205,24 @@ impl InvoiceEscrow {
return Err(Error::EscrowFunded);
}
+ // Validate that the funding token is accepted.
+ if !is_token_accepted(&data.accepted_tokens, &funding_token) {
+ return Err(Error::TokenNotAccepted);
+ }
+
+ // Once the first funder has chosen a token, all subsequent partial funders
+ // must use the same token (stored in data.token after first fund).
+ if data.funded_amt > 0 && funding_token != data.token {
+ return Err(Error::TokenNotAccepted);
+ }
+
// Check that funding doesn't exceed purchase_price
let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?;
if new_funded > data.purchase_price {
return Err(Error::InvalidAmount);
}
- let token = token::Client::new(&env, &data.token);
+ let token = token::Client::new(&env, &funding_token);
let contract = env.current_contract_address();
token.transfer(&buyer, &contract, &amount);
@@ -177,7 +233,7 @@ impl InvoiceEscrow {
soroban_sdk::vec![
&env,
buyer.to_val(),
- amount.into_val(&env),
+ soroban_sdk::IntoVal::into_val(&amount, &env),
contract.to_val()
],
);
@@ -191,6 +247,12 @@ impl InvoiceEscrow {
data.funded_amt = new_funded;
+ // Lock in the funding token for this escrow (first funder determines it).
+ if data.funded_amt == amount {
+ // This is the first funding contribution — record the chosen token.
+ data.token = funding_token.clone();
+ }
+
// MVP: Store the first funder for direct distribution
if data.funder.is_none() {
data.funder = Some(buyer.clone());
@@ -259,6 +321,7 @@ impl InvoiceEscrow {
.ok_or(Error::Overflow)?;
let investor_amount = amount.checked_sub(platform_fee).ok_or(Error::Overflow)?;
+ // Payments always use the locked-in funding token (data.token).
let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();
@@ -279,29 +342,33 @@ impl InvoiceEscrow {
if let Some(distributor) = config.payment_distributor.as_ref() {
// Forward the full payment amount to the distributor contract.
- // Fix: was `amount + amount` (double-counting); correct is investor_amount + platform_fee == amount.
let total_to_distributor = investor_amount
.checked_add(platform_fee)
.ok_or(Error::Overflow)?;
token.transfer(&contract, distributor, &total_to_distributor);
+
+ // Resolve Option to Address before building the Vec
+ // because Soroban SDK cannot convert Option into Val directly.
+ let funder_addr = require_funder(funder_opt.clone())?;
+
env.invoke_contract::<()>(
distributor,
&Symbol::new(&env, DISTRIBUTE_PAYMENT_FN),
soroban_sdk::vec![
&env,
contract.to_val(),
- invoice_id.clone().into_val(&env),
+ soroban_sdk::IntoVal::into_val(&invoice_id.clone(), &env),
soroban_sdk::vec![
&env,
data.token.clone(),
data.seller.clone(),
- funder_opt.clone().into_val(&env),
+ funder_addr,
config.admin.clone()
]
- .into_val(&env),
+ .to_val(),
soroban_sdk::vec![&env, data.paid_amt, amount, investor_amount, platform_fee]
- .into_val(&env),
- (data.status as u32).into_val(&env)
+ .to_val(),
+ soroban_sdk::IntoVal::into_val(&(data.status as u32), &env)
],
);
} else {
@@ -333,7 +400,7 @@ impl InvoiceEscrow {
env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
- soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
+ soroban_sdk::vec![&env, contract.to_val(), soroban_sdk::IntoVal::into_val(&false, &env)],
);
}
@@ -362,6 +429,7 @@ impl InvoiceEscrow {
.checked_sub(data.paid_amt)
.ok_or(Error::Overflow)?;
+ // Refunds always use the locked-in funding token (data.token).
let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();
@@ -374,21 +442,20 @@ impl InvoiceEscrow {
if amount_to_refund > 0 {
if let Some(distributor) = config.payment_distributor.as_ref() {
token.transfer(&contract, distributor, &amount_to_refund);
+
+ // Resolve Option before building Vec.
+ let funder_addr = require_funder(funder_opt.clone())?;
+
env.invoke_contract::<()>(
distributor,
&Symbol::new(&env, DISTRIBUTE_REFUND_FN),
soroban_sdk::vec![
&env,
contract.to_val(),
- invoice_id.clone().into_val(&env),
- soroban_sdk::vec![
- &env,
- data.token.clone(),
- funder_opt.clone().into_val(&env)
- ]
- .into_val(&env),
- soroban_sdk::vec![&env, amount_to_refund].into_val(&env),
- (data.status as u32).into_val(&env)
+ soroban_sdk::IntoVal::into_val(&invoice_id.clone(), &env),
+ soroban_sdk::vec![&env, data.token.clone(), funder_addr].to_val(),
+ soroban_sdk::vec![&env, amount_to_refund].to_val(),
+ soroban_sdk::IntoVal::into_val(&(data.status as u32), &env)
],
);
} else {
@@ -414,7 +481,7 @@ impl InvoiceEscrow {
env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
- soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
+ soroban_sdk::vec![&env, contract.to_val(), soroban_sdk::IntoVal::into_val(&false, &env)],
);
events::escrow_refunded(&env, invoice_id, amount_to_refund);
@@ -486,4 +553,4 @@ impl InvoiceEscrow {
#[cfg(test)]
mod integration_test;
#[cfg(test)]
-mod test;
\ No newline at end of file
+mod test;
diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs
index 944b2d0..a65c759 100644
--- a/contracts/invoice-escrow/src/test.rs
+++ b/contracts/invoice-escrow/src/test.rs
@@ -61,20 +61,12 @@ fn test_create_and_fund() {
payment_token_asset.mint(&buyer, &2000);
// Create escrow
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &1000000,
- &payment_token.address,
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &1000000, &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.address.clone()]
);
// Fund escrow
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token.address);
// Check status
let status = escrow_client.get_escrow_status(&invoice_id);
@@ -118,19 +110,11 @@ fn test_record_payment() {
// Payer gets payment tokens for settling
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &1000000,
- &payment_token.address,
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &1000000, &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.address.clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token.address);
assert_eq!(payment_token.balance(&buyer), 0);
// The contract holds the buyer's 1000
@@ -176,16 +160,8 @@ fn test_escrow_created_event() {
let amount = 5000;
let due_date = 2000000;
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
// Assert escrow_created event was emitted
@@ -210,6 +186,7 @@ fn test_escrow_created_event() {
Address,
Address,
BytesN<32>,
+ soroban_sdk::Val,
) = data.try_into_val(&env).unwrap();
assert_eq!(event_data.0, invoice_id);
assert_eq!(event_data.1, seller);
@@ -220,8 +197,6 @@ fn test_escrow_created_event() {
assert_eq!(event_data.6, payment_token_id.address());
assert_eq!(event_data.7, inv_token_id);
assert_eq!(event_data.8, test_commitment(&env, "test_invoice_data"));
- assert_eq!(event_data.6, payment_token_id.address());
- assert_eq!(event_data.7, inv_token_id);
}
#[test]
@@ -247,19 +222,11 @@ fn test_escrow_funded_event() {
payment_token_asset.mint(&buyer, &3000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
// Find escrow_funded event (should be the last event)
let events = env.events().all();
@@ -302,19 +269,11 @@ fn test_payment_settled_event() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
escrow_client.record_payment(&invoice_id, &payer, &amount);
// Find payment_settled event (should be the last event)
@@ -359,19 +318,11 @@ fn test_escrow_refunded_event() {
payment_token_asset.mint(&buyer, &2000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token_id.address());
// Set ledger timestamp past due date to allow refund
env.ledger().with_mut(|li| li.timestamp = due_date + 1);
@@ -417,16 +368,8 @@ fn test_no_settlement_event_on_invalid_state() {
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
// Try to record payment without funding first (should fail)
@@ -471,16 +414,8 @@ fn test_no_refund_event_on_invalid_state() {
let amount = 1000;
let due_date = 1000;
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
// Set ledger timestamp past due date
@@ -543,16 +478,8 @@ fn test_create_escrow_requires_seller_auth() {
escrow_client.initialize(&admin, &300);
// Without auth, should fail
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV001"),
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV001"), &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert!(result.is_err());
}
@@ -605,16 +532,8 @@ fn test_create_escrow_zero_amount() {
escrow_client.initialize(&admin, &300);
// Zero amount should fail
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV001"),
- &seller,
- &seller,
- &0,
- &0,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV001"), &seller, &seller, &0, &0, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::InvalidAmount)));
}
@@ -635,16 +554,8 @@ fn test_create_escrow_negative_amount() {
escrow_client.initialize(&admin, &300);
// Negative amount should fail
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV001"),
- &seller,
- &seller,
- &-100,
- &-100,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV001"), &seller, &seller, &-100, &-100, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::InvalidAmount)));
}
@@ -666,29 +577,13 @@ fn test_create_escrow_duplicate_invoice_id() {
escrow_client.initialize(&admin, &300);
// First create should succeed
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Second create with same invoice_id should fail
- let result = escrow_client.try_create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &2000,
- &2000,
- &2000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ let result = escrow_client.try_create_escrow(&invoice_id, &seller, &seller, &2000, &2000, &2000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::EscrowExists)));
}
@@ -744,7 +639,7 @@ fn test_fund_escrow_not_found() {
escrow_client.initialize(&admin, &300);
// Try to fund non-existent escrow
- let result = escrow_client.try_fund_escrow(&Symbol::new(&env, "NONEXISTENT"), &buyer, &1000);
+ let result = escrow_client.try_fund_escrow(&Symbol::new(&env, "NONEXISTENT"), &buyer, &1000, &Address::generate(&env));
assert_eq!(result, Err(Ok(Error::EscrowNotFound)));
}
@@ -772,23 +667,15 @@ fn test_fund_escrow_already_funded() {
payment_token_asset.mint(&buyer1, &1000);
payment_token_asset.mint(&buyer2, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
// First funding should succeed
- escrow_client.fund_escrow(&invoice_id, &buyer1, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer1, &1000, &payment_token_id.address());
// Second funding should fail
- let result = escrow_client.try_fund_escrow(&invoice_id, &buyer2, &1000);
+ let result = escrow_client.try_fund_escrow(&invoice_id, &buyer2, &1000, &payment_token_id.address());
assert_eq!(result, Err(Ok(Error::EscrowFunded)));
}
@@ -809,16 +696,8 @@ fn test_record_payment_not_funded() {
escrow_client.initialize(&admin, &300);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Try to record payment without funding first
@@ -850,19 +729,11 @@ fn test_record_payment_already_settled() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &2000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
escrow_client.record_payment(&invoice_id, &payer, &1000);
// Try to record payment again
@@ -894,19 +765,11 @@ fn test_record_payment_amount_exceeds_escrow() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &2000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
// Try to record payment with amount > escrow amount
let result = escrow_client.try_record_payment(&invoice_id, &payer, &1500);
@@ -929,16 +792,8 @@ fn test_refund_not_funded() {
escrow_client.initialize(&admin, &300);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Set time past due date
@@ -974,19 +829,11 @@ fn test_refund_before_due_date() {
payment_token_asset.mint(&buyer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
// Set time before due date
env.ledger().with_mut(|li| li.timestamp = due_date - 1);
@@ -1020,19 +867,11 @@ fn test_refund_at_due_date() {
payment_token_asset.mint(&buyer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
// Set time exactly at due date
env.ledger().with_mut(|li| li.timestamp = due_date);
@@ -1072,19 +911,11 @@ fn test_refund_after_due_date() {
payment_token_asset.mint(&buyer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
// Set time after due date
env.ledger().with_mut(|li| li.timestamp = due_date + 5000);
@@ -1125,19 +956,11 @@ fn test_refund_already_settled() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &due_date,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &due_date, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
escrow_client.record_payment(&invoice_id, &payer, &1000);
// Set time after due date
@@ -1176,19 +999,11 @@ fn test_fee_calculation_zero_fee() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
escrow_client.record_payment(&invoice_id, &payer, &1000);
// With 0% fee, buyer should get full amount
@@ -1222,19 +1037,11 @@ fn test_fee_calculation_max_fee() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &1000,
- &1000,
- &1000000,
- &payment_token_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &1000, &1000, &1000000, &payment_token_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &1000);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &1000, &payment_token_id.address());
escrow_client.record_payment(&invoice_id, &payer, &1000);
// With 100% fee, admin gets all, buyer gets nothing
@@ -1337,16 +1144,8 @@ fn test_get_escrow_data() {
escrow_client.initialize(&admin, &300);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &amount,
- &amount,
- &due_date,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &amount, &amount, &due_date, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Get escrow data and verify
@@ -1377,16 +1176,8 @@ fn test_create_escrow_not_initialized() {
let inv_token = Address::generate(&env);
// Try to create escrow without initialization
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV001"),
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "test_invoice_data"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV001"), &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::NotInit)));
}
@@ -1430,19 +1221,11 @@ fn test_partial_payment_lifecycle() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &1000000,
- &payment_token.address,
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &1000000, &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.address.clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token.address);
// First payment: 400
escrow_client.record_payment(&invoice_id, &payer, &400);
@@ -1513,19 +1296,11 @@ fn test_refund_after_partial_payment() {
payment_token_asset.mint(&buyer, &1000);
payment_token_asset.mint(&payer, &1000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &due_date,
- &payment_token.address,
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &due_date, &payment_token.address, &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, payment_token.address.clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token.address);
// Partial payment: 300
escrow_client.record_payment(&invoice_id, &payer, &300);
@@ -1579,18 +1354,10 @@ fn test_record_payment_removes_initial_fund_even_on_full_payment() {
payment_token_asset.mint(&buyer, &5000);
payment_token_asset.mint(&payer, &5000);
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &100,
- &pt_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &100, &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()]
);
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &pt_id.address());
assert_eq!(payment_token.balance(&escrow_id), 5000);
@@ -1618,16 +1385,8 @@ fn setup_escrow_created(env: &Env) -> (Address, InvoiceEscrowClient<'_>, Address
let seller = Address::generate(env);
let invoice_id = Symbol::new(env, "INV_CANC");
- client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000i128,
- &1000i128,
- &9_999_999u64,
- &pt_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ client.create_escrow(&invoice_id, &seller, &seller, &1000i128, &1000i128, &9_999_999u64, &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()]
);
let _ = (pt_asset,);
@@ -1696,18 +1455,10 @@ fn test_cancel_escrow_already_funded_rejected() {
pt_asset.mint(&buyer, &1000);
- client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000i128,
- &1000i128,
- &9_999_999u64,
- &pt_id.address(),
- &inv_token_id,
- &test_commitment(&env, "test_invoice_data"),
+ client.create_escrow(&invoice_id, &seller, &seller, &1000i128, &1000i128, &9_999_999u64, &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()]
);
- client.fund_escrow(&invoice_id, &buyer, &1000);
+ client.fund_escrow(&invoice_id, &buyer, &1000, &pt_id.address());
// Cannot cancel once funded
let res = client.try_cancel_escrow(&invoice_id, &seller);
@@ -1725,7 +1476,7 @@ fn test_fund_cancelled_escrow_rejected() {
client.cancel_escrow(&invoice_id, &seller);
let buyer = Address::generate(&env);
- let res = client.try_fund_escrow(&invoice_id, &buyer, &1000);
+ let res = client.try_fund_escrow(&invoice_id, &buyer, &1000, &Address::generate(&env));
assert_eq!(res, Err(Ok(Error::EscrowCancelled)));
}
@@ -1788,42 +1539,28 @@ fn test_pause_blocks_lifecycle_operations_and_unpause_restores() {
client.set_paused(&true);
assert!(client.paused());
- let create_while_paused = client.try_create_escrow(
- &invoice_id,
- &seller,
- &seller, // debtor == seller for this test
- &1000i128,
- &1000i128,
- &9_999_999u64,
- &pt_id.address(),
- &inv_token_id,
- &test_commitment(&env, "pause_test_invoice"),
+ let create_while_paused = client.try_create_escrow(&invoice_id, &seller, &seller, // debtor == seller for this test
+ &1000i128, &1000i128, &9_999_999u64, &pt_id.address(), &inv_token_id, &test_commitment(&env, "pause_test_invoice"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()]
);
assert_eq!(create_while_paused, Err(Ok(Error::Paused)));
// Unpause and create successfully
client.set_paused(&false);
- client.create_escrow(
- &invoice_id,
- &seller,
- &payer, // use payer as debtor so record_payment works
- &1000i128,
- &1000i128,
- &9_999_999u64,
- &pt_id.address(),
- &inv_token_id,
- &test_commitment(&env, "pause_test_invoice"),
+ client.create_escrow(&invoice_id, &seller, &payer, // use payer as debtor so record_payment works
+ &1000i128, &1000i128, &9_999_999u64, &pt_id.address(), &inv_token_id, &test_commitment(&env, "pause_test_invoice"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()]
);
// Pause and verify fund_escrow is blocked
pt_asset.mint(&buyer, &1000);
client.set_paused(&true);
- let fund_while_paused = client.try_fund_escrow(&invoice_id, &buyer, &1000i128);
+ let fund_while_paused = client.try_fund_escrow(&invoice_id, &buyer, &1000i128, &pt_id.address());
assert_eq!(fund_while_paused, Err(Ok(Error::Paused)));
// Unpause and fund successfully
client.set_paused(&false);
- client.fund_escrow(&invoice_id, &buyer, &1000i128);
+ client.fund_escrow(&invoice_id, &buyer, &1000i128, &pt_id.address());
// Pause and verify record_payment is blocked
pt_asset.mint(&payer, &1000);
@@ -1861,16 +1598,8 @@ fn test_create_escrow_with_commitment() {
let commitment = test_commitment(&env, "invoice_pdf_hash_12345");
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &commitment,
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &commitment,
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Verify escrow was created with commitment
@@ -1896,16 +1625,8 @@ fn test_commitment_immutable_after_creation() {
let original_commitment = test_commitment(&env, "original_invoice_data");
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &original_commitment,
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &original_commitment,
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Verify commitment is stored
@@ -1934,16 +1655,8 @@ fn test_commitment_included_in_created_event() {
let commitment = test_commitment(&env, "event_test_invoice");
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &commitment,
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &commitment,
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Assert escrow_created event was emitted with commitment
@@ -1968,6 +1681,7 @@ fn test_commitment_included_in_created_event() {
Address,
Address,
BytesN<32>,
+ soroban_sdk::Val,
) = data.try_into_val(&env).unwrap();
assert_eq!(event_data.0, invoice_id);
assert_eq!(event_data.1, seller);
@@ -1992,31 +1706,15 @@ fn test_different_commitments_for_different_invoices() {
// Create first invoice with commitment A
let invoice_id_1 = Symbol::new(&env, "INV_A");
let commitment_a = test_commitment(&env, "invoice_a_data");
- escrow_client.create_escrow(
- &invoice_id_1,
- &seller,
- &seller,
- &1000,
- &1000,
- &1000000,
- &payment_token,
- &inv_token,
- &commitment_a,
+ escrow_client.create_escrow(&invoice_id_1, &seller, &seller, &1000, &1000, &1000000, &payment_token, &inv_token, &commitment_a,
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Create second invoice with commitment B
let invoice_id_2 = Symbol::new(&env, "INV_B");
let commitment_b = test_commitment(&env, "invoice_b_data");
- escrow_client.create_escrow(
- &invoice_id_2,
- &seller,
- &seller,
- &2000,
- &2000,
- &2000000,
- &payment_token,
- &inv_token,
- &commitment_b,
+ escrow_client.create_escrow(&invoice_id_2, &seller, &seller, &2000, &2000, &2000000, &payment_token, &inv_token, &commitment_b,
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
// Verify each invoice has its own commitment
@@ -2057,16 +1755,8 @@ fn test_commitment_persists_through_lifecycle() {
let commitment = test_commitment(&env, "lifecycle_test_invoice");
// Create escrow with commitment
- escrow_client.create_escrow(
- &invoice_id,
- &seller,
- &payer,
- &amount,
- &amount,
- &1000000,
- &payment_token.address,
- &inv_token_id,
- &commitment,
+ escrow_client.create_escrow(&invoice_id, &seller, &payer, &amount, &amount, &1000000, &payment_token.address, &inv_token_id, &commitment,
+ &soroban_sdk::vec![&env, payment_token.address.clone()]
);
// Verify commitment after creation
@@ -2074,7 +1764,7 @@ fn test_commitment_persists_through_lifecycle() {
assert_eq!(escrow_data.commitment, commitment);
// Fund escrow
- escrow_client.fund_escrow(&invoice_id, &buyer, &amount);
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &payment_token.address);
// Verify commitment persists after funding
let escrow_data = escrow_client.get_escrow(&invoice_id);
@@ -2112,16 +1802,8 @@ fn test_create_escrow_due_date_in_past_rejected() {
// Try to create escrow with due_date in the past
let past_due_date = current_timestamp - 1000;
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV_PAST"),
- &seller,
- &seller,
- &1000,
- &950,
- &past_due_date,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "past_due_test"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV_PAST"), &seller, &seller, &1000, &950, &past_due_date, &payment_token, &inv_token, &test_commitment(&env, "past_due_test"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::InvalidDueDate)));
}
@@ -2146,16 +1828,8 @@ fn test_create_escrow_due_date_equal_to_current_timestamp_rejected() {
let current_timestamp = env.ledger().timestamp();
// Try to create escrow with due_date equal to current timestamp
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV_EQUAL"),
- &seller,
- &seller,
- &1000,
- &950,
- ¤t_timestamp,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "equal_timestamp_test"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV_EQUAL"), &seller, &seller, &1000, &950, ¤t_timestamp, &payment_token, &inv_token, &test_commitment(&env, "equal_timestamp_test"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::InvalidDueDate)));
}
@@ -2176,16 +1850,8 @@ fn test_create_escrow_due_date_zero_rejected() {
escrow_client.initialize(&admin, &300);
// Try to create escrow with due_date = 0
- let result = escrow_client.try_create_escrow(
- &Symbol::new(&env, "INV_ZERO"),
- &seller,
- &seller,
- &1000,
- &950,
- &0,
- &payment_token,
- &inv_token,
- &test_commitment(&env, "zero_due_date_test"),
+ let result = escrow_client.try_create_escrow(&Symbol::new(&env, "INV_ZERO"), &seller, &seller, &1000, &950, &0, &payment_token, &inv_token, &test_commitment(&env, "zero_due_date_test"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
);
assert_eq!(result, Err(Ok(Error::InvalidDueDate)));
}
@@ -2212,20 +1878,370 @@ fn test_create_escrow_due_date_in_future_accepted() {
// Create escrow with due_date in the future - should succeed
let future_due_date = current_timestamp + 1000000;
let invoice_id = Symbol::new(&env, "INV_FUTURE");
+ escrow_client.create_escrow(&invoice_id, &seller, &seller, &1000, &950, &future_due_date, &payment_token, &inv_token, &test_commitment(&env, "future_due_test"),
+ &soroban_sdk::vec![&env, payment_token.clone()]
+ );
+
+ // Verify escrow was created successfully
+ let escrow_data = escrow_client.get_escrow(&invoice_id);
+ assert_eq!(escrow_data.due_dt, future_due_date);
+ assert_eq!(escrow_data.status, EscrowStatus::Created);
+}
+
+// ========== Multi-Token Escrow Tests ==========
+
+#[test]
+fn test_create_escrow_with_multiple_accepted_tokens() {
+ 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 seller = Address::generate(&env);
+ let token_a = Address::generate(&env);
+ let token_b = Address::generate(&env);
+ let token_c = Address::generate(&env);
+ let inv_token = Address::generate(&env);
+ let invoice_id = Symbol::new(&env, "INV_MULTI");
+
+ escrow_client.initialize(&admin, &300);
+
+ let accepted = soroban_sdk::vec![&env, token_a.clone(), token_b.clone(), token_c.clone()];
escrow_client.create_escrow(
&invoice_id,
&seller,
&seller,
&1000,
- &950,
- &future_due_date,
- &payment_token,
+ &1000,
+ &1000000,
+ &token_a,
&inv_token,
- &test_commitment(&env, "future_due_test"),
+ &test_commitment(&env, "multi_token_test"),
+ &accepted,
);
- // Verify escrow was created successfully
- let escrow_data = escrow_client.get_escrow(&invoice_id);
- assert_eq!(escrow_data.due_dt, future_due_date);
- assert_eq!(escrow_data.status, EscrowStatus::Created);
+ let data = escrow_client.get_escrow(&invoice_id);
+ assert_eq!(data.accepted_tokens.len(), 3);
+ assert_eq!(data.status, EscrowStatus::Created);
+}
+
+#[test]
+fn test_create_escrow_payment_token_not_in_accepted_list_rejected() {
+ 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 seller = Address::generate(&env);
+ let token_a = Address::generate(&env);
+ let token_b = Address::generate(&env);
+ let unlisted = Address::generate(&env); // NOT in accepted list
+ let inv_token = Address::generate(&env);
+
+ escrow_client.initialize(&admin, &300);
+
+ // payment_token = unlisted but accepted_tokens = [token_a, token_b]
+ let result = escrow_client.try_create_escrow(
+ &Symbol::new(&env, "INV_BAD"),
+ &seller,
+ &seller,
+ &1000,
+ &1000,
+ &1000000,
+ &unlisted,
+ &inv_token,
+ &test_commitment(&env, "bad_token_test"),
+ &soroban_sdk::vec![&env, token_a.clone(), token_b.clone()],
+ );
+ assert_eq!(result, Err(Ok(Error::TokenNotAccepted)));
+}
+
+#[test]
+fn test_create_escrow_empty_accepted_tokens_rejected() {
+ 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 seller = Address::generate(&env);
+ let token_a = Address::generate(&env);
+ let inv_token = Address::generate(&env);
+
+ escrow_client.initialize(&admin, &300);
+
+ let result = escrow_client.try_create_escrow(
+ &Symbol::new(&env, "INV_EMPTY"),
+ &seller,
+ &seller,
+ &1000,
+ &1000,
+ &1000000,
+ &token_a,
+ &inv_token,
+ &test_commitment(&env, "empty_tokens_test"),
+ &soroban_sdk::vec![&env], // empty!
+ );
+ assert_eq!(result, Err(Ok(Error::InvalidAmount)));
+}
+
+#[test]
+fn test_fund_with_accepted_token_succeeds() {
+ 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);
+
+ // Register two payment tokens
+ let token_a_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
+ let token_b_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
+ let token_a = soroban_sdk::token::Client::new(&env, &token_a_id.address());
+ let token_b_asset = soroban_sdk::token::StellarAssetClient::new(&env, &token_b_id.address());
+ let token_b = soroban_sdk::token::Client::new(&env, &token_b_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 invoice_id = Symbol::new(&env, "INV_FUNDA");
+ let amount = 1000i128;
+
+ token_b_asset.mint(&buyer, &amount);
+
+ // Create with both token_a and token_b accepted; canonical = token_a
+ escrow_client.create_escrow(
+ &invoice_id,
+ &seller,
+ &seller,
+ &amount,
+ &amount,
+ &9_999_999,
+ &token_a_id.address(), // canonical
+ &inv_token_id,
+ &test_commitment(&env, "fund_accepted"),
+ &soroban_sdk::vec![&env, token_a_id.address().clone(), token_b_id.address().clone()],
+ );
+
+ // Fund using token_b (a non-canonical but accepted token)
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &token_b_id.address());
+
+ assert_eq!(escrow_client.get_escrow_status(&invoice_id), EscrowStatus::Funded);
+ // token_b should have been transferred from buyer to escrow
+ assert_eq!(token_b.balance(&buyer), 0);
+ assert_eq!(token_b.balance(&escrow_id), amount);
+ // token_a should be untouched
+ assert_eq!(token_a.balance(&buyer), 0);
+ // Escrow's locked token is now token_b
+ let data = escrow_client.get_escrow(&invoice_id);
+ assert_eq!(data.token, token_b_id.address());
+}
+
+#[test]
+fn test_fund_with_rejected_token_fails() {
+ 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 token_a_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
+ let token_bad = Address::generate(&env); // not in accepted list
+ let inv_token_id = env.register(MockInvoiceToken, ());
+
+ escrow_client.initialize(&admin, &300);
+
+ let seller = Address::generate(&env);
+ let buyer = Address::generate(&env);
+ let invoice_id = Symbol::new(&env, "INV_FUNDREJ");
+
+ escrow_client.create_escrow(
+ &invoice_id,
+ &seller,
+ &seller,
+ &1000,
+ &1000,
+ &9_999_999,
+ &token_a_id.address(),
+ &inv_token_id,
+ &test_commitment(&env, "fund_rejected"),
+ &soroban_sdk::vec![&env, token_a_id.address().clone()],
+ );
+
+ // Try to fund with an unaccepted token
+ let result = escrow_client.try_fund_escrow(&invoice_id, &buyer, &1000, &token_bad);
+ assert_eq!(result, Err(Ok(Error::TokenNotAccepted)));
+}
+
+#[test]
+fn test_payment_uses_locked_funding_token() {
+ 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);
+
+ // token_b will be used for funding; payment must also use token_b
+ let token_a_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
+ let token_b_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
+ let token_b_asset = soroban_sdk::token::StellarAssetClient::new(&env, &token_b_id.address());
+ let token_b = soroban_sdk::token::Client::new(&env, &token_b_id.address());
+ let inv_token_id = env.register(MockInvoiceToken, ());
+
+ escrow_client.initialize(&admin, &0); // 0% fee for simple math
+
+ let seller = Address::generate(&env);
+ let buyer = Address::generate(&env);
+ let payer = Address::generate(&env);
+ let invoice_id = Symbol::new(&env, "INV_PAYMT");
+ let amount = 500i128;
+
+ token_b_asset.mint(&buyer, &amount);
+ token_b_asset.mint(&payer, &amount);
+
+ escrow_client.create_escrow(
+ &invoice_id,
+ &seller,
+ &payer,
+ &amount,
+ &amount,
+ &9_999_999,
+ &token_a_id.address(), // canonical = token_a
+ &inv_token_id,
+ &test_commitment(&env, "payment_token_locked"),
+ &soroban_sdk::vec![&env, token_a_id.address().clone(), token_b_id.address().clone()],
+ );
+
+ // Fund with token_b — this locks token_b as the escrow's payment token
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &token_b_id.address());
+ assert_eq!(escrow_client.get_escrow(&invoice_id).token, token_b_id.address());
+
+ // Record payment — payer pays with token_b (the locked token)
+ escrow_client.record_payment(&invoice_id, &payer, &amount);
+
+ assert_eq!(escrow_client.get_escrow_status(&invoice_id), EscrowStatus::Settled);
+ // With 0% fee: buyer gets full amount, seller gets collateral back
+ assert_eq!(token_b.balance(&buyer), amount);
+ assert_eq!(token_b.balance(&seller), amount);
+ assert_eq!(token_b.balance(&escrow_id), 0);
+}
+
+#[test]
+fn test_single_token_backwards_compat() {
+ // Passing a single-element accepted_tokens list should work exactly like the old behavior.
+ 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 pt_asset = soroban_sdk::token::StellarAssetClient::new(&env, &pt_id.address());
+ let pt = soroban_sdk::token::Client::new(&env, &pt_id.address());
+ let inv_token_id = env.register(MockInvoiceToken, ());
+
+ escrow_client.initialize(&admin, &300); // 3%
+
+ let seller = Address::generate(&env);
+ let buyer = Address::generate(&env);
+ let payer = Address::generate(&env);
+ let invoice_id = Symbol::new(&env, "INV_COMPAT");
+ let amount = 1000i128;
+
+ pt_asset.mint(&buyer, &amount);
+ pt_asset.mint(&payer, &amount);
+
+ // Single-token list — identical to old single-token create_escrow
+ escrow_client.create_escrow(
+ &invoice_id,
+ &seller,
+ &payer,
+ &amount,
+ &amount,
+ &9_999_999,
+ &pt_id.address(),
+ &inv_token_id,
+ &test_commitment(&env, "compat_test"),
+ &soroban_sdk::vec![&env, pt_id.address().clone()],
+ );
+
+ escrow_client.fund_escrow(&invoice_id, &buyer, &amount, &pt_id.address());
+ escrow_client.record_payment(&invoice_id, &payer, &amount);
+
+ assert_eq!(escrow_client.get_escrow_status(&invoice_id), EscrowStatus::Settled);
+ assert_eq!(pt.balance(&buyer), 970); // 1000 - 3% fee
+ assert_eq!(pt.balance(&admin), 30); // 3% fee
+ assert_eq!(pt.balance(&seller), 1000); // collateral released
+ assert_eq!(pt.balance(&payer), 0);
+ assert_eq!(pt.balance(&escrow_id), 0);
+}
+
+#[test]
+fn test_accepted_tokens_emitted_in_escrow_created_event() {
+ 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 seller = Address::generate(&env);
+ let token_a = Address::generate(&env);
+ let token_b = Address::generate(&env);
+ let inv_token = Address::generate(&env);
+ let invoice_id = Symbol::new(&env, "INV_EVTMT");
+
+ escrow_client.initialize(&admin, &300);
+
+ let accepted = soroban_sdk::vec![&env, token_a.clone(), token_b.clone()];
+ escrow_client.create_escrow(
+ &invoice_id,
+ &seller,
+ &seller,
+ &1000,
+ &1000,
+ &1000000,
+ &token_a,
+ &inv_token,
+ &test_commitment(&env, "event_multi_token"),
+ &accepted,
+ );
+
+ let events = env.events().all();
+ let (_addr, topics, data) = events.last().unwrap();
+
+ let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();
+ assert_eq!(topic, Symbol::new(&env, "escrow_created"));
+
+ // Decode the 10-element event tuple; field 9 is accepted_tokens as Val
+ let event_data: (
+ Symbol,
+ Address,
+ Address,
+ i128,
+ i128,
+ u64,
+ Address,
+ Address,
+ BytesN<32>,
+ soroban_sdk::Val,
+ ) = data.try_into_val(&env).unwrap();
+
+ assert_eq!(event_data.0, invoice_id);
+ // Decode the accepted_tokens Vec from the Val
+ let decoded_tokens: soroban_sdk::Vec =
+ event_data.9.try_into_val(&env).unwrap();
+ assert_eq!(decoded_tokens.len(), 2);
+ assert_eq!(decoded_tokens.get(0).unwrap(), token_a);
+ assert_eq!(decoded_tokens.get(1).unwrap(), token_b);
}
diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs
index 7c62d73..ae518ca 100644
--- a/contracts/invoice-escrow/src/types.rs
+++ b/contracts/invoice-escrow/src/types.rs
@@ -66,7 +66,10 @@ pub struct EscrowData {
pub funder: Option,
/// Due date (ledger timestamp).
pub due_dt: u64,
- /// Payment token contract address.
+ /// Payment token contract address used to actually fund/pay this escrow.
+ /// For multi-token escrows this is set to the first token in `accepted_tokens`
+ /// when the escrow is created, and updated to the actual funding token when
+ /// `fund_escrow` is called.
pub token: soroban_sdk::Address,
/// Invoice token contract address (ownership/claim).
pub inv_token: soroban_sdk::Address,
@@ -77,4 +80,10 @@ pub struct EscrowData {
/// Commitment hash: immutable on-chain anchor for off-chain invoice data (PDF hash, ERP ID, etc.).
/// Set at creation, cannot be modified. SHA-256 hash (32 bytes).
pub commitment: soroban_sdk::BytesN<32>,
+ /// Accepted payment tokens: the set of token addresses that may be used
+ /// to fund and pay this escrow. Must be non-empty.
+ /// The first element is the canonical / default token.
+ /// All funders must use a token from this list; all payments must use the
+ /// same token that was used to fund (stored in `token` once funded).
+ pub accepted_tokens: soroban_sdk::Vec,
}