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, }