diff --git a/contracts/invoice-escrow/src/integration_test.rs b/contracts/invoice-escrow/src/integration_test.rs index ec86aee..cbd4089 100644 --- a/contracts/invoice-escrow/src/integration_test.rs +++ b/contracts/invoice-escrow/src/integration_test.rs @@ -1233,3 +1233,439 @@ fn test_integration_get_config_returns_correct_values() { assert!(!cfg.paused); assert!(cfg.payment_distributor.is_none()); } + +// ────────────────────────────────────────────────────────────────────────────── +// Issue #170: Multi-Currency Escrow Settlement Integration Tests +// +// These tests validate that the InvoiceEscrow contract correctly handles +// escrows denominated in different payment tokens. Each test uses the real +// InvoiceToken and Stellar-asset-contract instances so cross-contract calls +// (mint, transfer, set_transfer_locked) execute as they would on-chain. +// +// Coverage: +// 26. Two simultaneous escrows in different tokens settle independently. +// 27. Token balances are fully isolated between currency A and currency B. +// 28. Refund returns the correct token to the funder. +// 29. Platform fee is calculated and collected in the correct token. +// 30. Persistent state for each currency-specific escrow is independent. +// ────────────────────────────────────────────────────────────────────────────── + +/// Helper: register a second InvoiceToken and Stellar asset for multi-currency tests. +/// Returns (invoice_token_id, invoice_token_client, payment_token_id, token_client, asset_client). +fn register_currency<'a>( + env: &'a Env, + admin: &Address, + escrow_id: &Address, + token_name: &str, + token_symbol: &str, + inv_id: &Symbol, +) -> ( + Address, + invoice_token::InvoiceTokenClient<'a>, + Address, + TokenClient<'a>, + AssetClient<'a>, +) { + use soroban_sdk::String as SorobanString; + + let inv_token_id = env.register(InvoiceToken, ()); + let inv_token = invoice_token::InvoiceTokenClient::new(env, &inv_token_id); + inv_token.initialize( + admin, + &SorobanString::from_str(env, token_name), + &SorobanString::from_str(env, token_symbol), + &7, + inv_id, + escrow_id, + ); + + let token_admin = Address::generate(env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin); + let payment_token = TokenClient::new(env, &token_contract.address()); + let payment_asset = AssetClient::new(env, &token_contract.address()); + + ( + inv_token_id, + inv_token, + token_contract.address(), + payment_token, + payment_asset, + ) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 26. Two simultaneous escrows in different tokens settle independently +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_integration_multi_currency_two_escrows_settle_independently() { + let env = Env::default(); + env.mock_all_auths(); + use soroban_sdk::String as SorobanString; + + // Shared escrow contract and participants. + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + escrow.initialize(&admin, &300); // 3% fee + + // Currency A setup. + let inv_a = Symbol::new(&env, "INVMC26A"); + let (inv_tok_a_id, inv_tok_a, pay_tok_a_id, pay_tok_a, pay_ast_a) = + register_currency(&env, &admin, &escrow_id, "Invoice A Token", "ITKA", &inv_a); + + // Currency B setup. + let inv_b = Symbol::new(&env, "INVMC26B"); + let (inv_tok_b_id, inv_tok_b, pay_tok_b_id, pay_tok_b, pay_ast_b) = + register_currency(&env, &admin, &escrow_id, "Invoice B Token", "ITKB", &inv_b); + + // Participants. + let seller = Address::generate(&env); + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + + // Mint tokens for each currency. + pay_ast_a.mint(&buyer_a, &1_000); + pay_ast_a.mint(&payer_a, &1_000); + pay_ast_b.mint(&buyer_b, &500); + pay_ast_b.mint(&payer_b, &500); + + // Create both escrows. + escrow.create_escrow( + &inv_a, + &seller, + &payer_a, + &1_000, + &1_000, + &99_999, + &pay_tok_a_id, + &inv_tok_a_id, + &test_commitment(&env, "mc_a"), + &None, + ); + escrow.create_escrow( + &inv_b, + &seller, + &payer_b, + &500, + &500, + &99_999, + &pay_tok_b_id, + &inv_tok_b_id, + &test_commitment(&env, "mc_b"), + &None, + ); + + // Fund both escrows. + escrow.fund_escrow(&inv_a, &buyer_a, &1_000); + escrow.fund_escrow(&inv_b, &buyer_b, &500); + + assert_eq!(escrow.get_escrow_status(&inv_a), EscrowStatus::Funded); + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Funded); + + // Settle A. + escrow.record_payment(&inv_a, &payer_a, &1_000); + assert_eq!(escrow.get_escrow_status(&inv_a), EscrowStatus::Settled); + // B must still be Funded. + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Funded); + + // Settle B. + escrow.record_payment(&inv_b, &payer_b, &500); + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Settled); + + // Invoice tokens unlocked independently after each settlement. + assert!(!inv_tok_a.transfer_locked()); + assert!(!inv_tok_b.transfer_locked()); +} + +// ────────────────────────────────────────────────────────────────────────────── +// 27. Token balances are fully isolated — currency A payment never touches B +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_integration_multi_currency_settlement_token_isolation() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + escrow.initialize(&admin, &300); + + let inv_a = Symbol::new(&env, "INVMC27A"); + let inv_b = Symbol::new(&env, "INVMC27B"); + + let (inv_tok_a_id, _inv_tok_a, pay_tok_a_id, pay_tok_a, pay_ast_a) = + register_currency(&env, &admin, &escrow_id, "Token A", "TKNA", &inv_a); + let (inv_tok_b_id, _inv_tok_b, pay_tok_b_id, pay_tok_b, pay_ast_b) = + register_currency(&env, &admin, &escrow_id, "Token B", "TKNB", &inv_b); + + let seller = Address::generate(&env); + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + + pay_ast_a.mint(&buyer_a, &1_000); + pay_ast_a.mint(&payer_a, &1_000); + pay_ast_b.mint(&buyer_b, &800); + pay_ast_b.mint(&payer_b, &800); + + escrow.create_escrow( + &inv_a, &seller, &payer_a, &1_000, &1_000, &99_999, + &pay_tok_a_id, &inv_tok_a_id, &test_commitment(&env, "iso_a"), &None, + ); + escrow.create_escrow( + &inv_b, &seller, &payer_b, &800, &800, &99_999, + &pay_tok_b_id, &inv_tok_b_id, &test_commitment(&env, "iso_b"), &None, + ); + + escrow.fund_escrow(&inv_a, &buyer_a, &1_000); + escrow.fund_escrow(&inv_b, &buyer_b, &800); + + // Settle A only. + escrow.record_payment(&inv_a, &payer_a, &1_000); + + // Token A: distributed to buyer_a (970 after 3% fee) and seller_a. + assert_eq!(pay_tok_a.balance(&buyer_a), 970); + assert_eq!(pay_tok_a.balance(&seller), 1_000); + assert_eq!(pay_tok_a.balance(&admin), 30); + assert_eq!(pay_tok_a.balance(&escrow_id), 0); + + // Token B: completely untouched — still in escrow. + assert_eq!(pay_tok_b.balance(&escrow_id), 800); + assert_eq!(pay_tok_b.balance(&buyer_b), 0); + assert_eq!(pay_tok_b.balance(&seller), 0); + + // Settle B. + escrow.record_payment(&inv_b, &payer_b, &800); + + // 3% of 800 = 24 fee. + assert_eq!(pay_tok_b.balance(&buyer_b), 800 - 24); + assert_eq!(pay_tok_b.balance(&admin), 24); + assert_eq!(pay_tok_b.balance(&escrow_id), 0); + + // Token A balances must be unchanged after B settlement. + assert_eq!(pay_tok_a.balance(&buyer_a), 970); + assert_eq!(pay_tok_a.balance(&admin), 30); +} + +// ────────────────────────────────────────────────────────────────────────────── +// 28. Refund returns the correct token to the funder +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_integration_multi_currency_refund_returns_correct_token() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(0); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + escrow.initialize(&admin, &300); + + let inv_a = Symbol::new(&env, "INVMC28A"); + let inv_b = Symbol::new(&env, "INVMC28B"); + let due_date = 5_000u64; + + let (inv_tok_a_id, _inv_tok_a, pay_tok_a_id, pay_tok_a, pay_ast_a) = + register_currency(&env, &admin, &escrow_id, "Refund A", "REFA", &inv_a); + let (inv_tok_b_id, _inv_tok_b, pay_tok_b_id, pay_tok_b, pay_ast_b) = + register_currency(&env, &admin, &escrow_id, "Refund B", "REFB", &inv_b); + + let seller = Address::generate(&env); + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + + pay_ast_a.mint(&buyer_a, &1_000); + pay_ast_b.mint(&buyer_b, &600); + + escrow.create_escrow( + &inv_a, &seller, &payer_a, &1_000, &1_000, &due_date, + &pay_tok_a_id, &inv_tok_a_id, &test_commitment(&env, "refund_a"), &None, + ); + escrow.create_escrow( + &inv_b, &seller, &payer_b, &600, &600, &due_date, + &pay_tok_b_id, &inv_tok_b_id, &test_commitment(&env, "refund_b"), &None, + ); + + escrow.fund_escrow(&inv_a, &buyer_a, &1_000); + escrow.fund_escrow(&inv_b, &buyer_b, &600); + + // Settle escrow A so it is no longer refundable. + pay_ast_a.mint(&payer_a, &1_000); + escrow.record_payment(&inv_a, &payer_a, &1_000); + assert_eq!(escrow.get_escrow_status(&inv_a), EscrowStatus::Settled); + + // Advance past due_date for escrow B. + env.ledger().set_timestamp(due_date + 1); + escrow.refund(&inv_b); + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Refunded); + + // buyer_b gets back 600 in token B — not token A. + assert_eq!(pay_tok_b.balance(&buyer_b), 600); + assert_eq!(pay_tok_b.balance(&escrow_id), 0); + + // Token A is unaffected by the B refund. + assert_eq!(pay_tok_a.balance(&escrow_id), 0); // already settled +} + +// ────────────────────────────────────────────────────────────────────────────── +// 29. Platform fee is calculated and collected in the correct token per escrow +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_integration_multi_currency_fee_collected_in_correct_token() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + escrow.initialize(&admin, &500); // 5% fee + + let inv_a = Symbol::new(&env, "INVMC29A"); + let inv_b = Symbol::new(&env, "INVMC29B"); + + let (inv_tok_a_id, _inv_tok_a, pay_tok_a_id, pay_tok_a, pay_ast_a) = + register_currency(&env, &admin, &escrow_id, "Fee A Token", "FEEA", &inv_a); + let (inv_tok_b_id, _inv_tok_b, pay_tok_b_id, pay_tok_b, pay_ast_b) = + register_currency(&env, &admin, &escrow_id, "Fee B Token", "FEEB", &inv_b); + + let seller = Address::generate(&env); + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + + // Escrow A: 2_000 in token A; escrow B: 1_000 in token B. + pay_ast_a.mint(&buyer_a, &2_000); + pay_ast_a.mint(&payer_a, &2_000); + pay_ast_b.mint(&buyer_b, &1_000); + pay_ast_b.mint(&payer_b, &1_000); + + escrow.create_escrow( + &inv_a, &seller, &payer_a, &2_000, &2_000, &99_999, + &pay_tok_a_id, &inv_tok_a_id, &test_commitment(&env, "fee_a"), &None, + ); + escrow.create_escrow( + &inv_b, &seller, &payer_b, &1_000, &1_000, &99_999, + &pay_tok_b_id, &inv_tok_b_id, &test_commitment(&env, "fee_b"), &None, + ); + + escrow.fund_escrow(&inv_a, &buyer_a, &2_000); + escrow.fund_escrow(&inv_b, &buyer_b, &1_000); + + // Settle both. + escrow.record_payment(&inv_a, &payer_a, &2_000); + escrow.record_payment(&inv_b, &payer_b, &1_000); + + // 5% of 2000 = 100 in token A to admin. + // 5% of 1000 = 50 in token B to admin. + assert_eq!(pay_tok_a.balance(&admin), 100, "admin must receive 100 token A"); + assert_eq!(pay_tok_b.balance(&admin), 50, "admin must receive 50 token B"); + + // Investors receive the net amount in their respective tokens. + assert_eq!(pay_tok_a.balance(&buyer_a), 2_000 - 100); + assert_eq!(pay_tok_b.balance(&buyer_b), 1_000 - 50); + + // Escrow contract must be empty for both currencies. + assert_eq!(pay_tok_a.balance(&escrow_id), 0); + assert_eq!(pay_tok_b.balance(&escrow_id), 0); +} + +// ────────────────────────────────────────────────────────────────────────────── +// 30. Persistent state for each currency-specific escrow is fully independent +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_integration_multi_currency_state_persists_independently() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + let admin = Address::generate(&env); + escrow.initialize(&admin, &300); + + let inv_a = Symbol::new(&env, "INVMC30A"); + let inv_b = Symbol::new(&env, "INVMC30B"); + + let (inv_tok_a_id, _inv_tok_a, pay_tok_a_id, _pay_tok_a, pay_ast_a) = + register_currency(&env, &admin, &escrow_id, "State A", "STKA", &inv_a); + let (inv_tok_b_id, _inv_tok_b, pay_tok_b_id, _pay_tok_b, pay_ast_b) = + register_currency(&env, &admin, &escrow_id, "State B", "STKB", &inv_b); + + let seller = Address::generate(&env); + let buyer_a = Address::generate(&env); + let buyer_b = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + + let commitment_a = test_commitment(&env, "state_a"); + let commitment_b = test_commitment(&env, "state_b"); + + pay_ast_a.mint(&buyer_a, &1_200); + pay_ast_a.mint(&payer_a, &1_200); + pay_ast_b.mint(&buyer_b, &700); + pay_ast_b.mint(&payer_b, &700); + + escrow.create_escrow( + &inv_a, &seller, &payer_a, &1_200, &1_200, &88_888, + &pay_tok_a_id, &inv_tok_a_id, &commitment_a, &None, + ); + escrow.create_escrow( + &inv_b, &seller, &payer_b, &700, &700, &77_777, + &pay_tok_b_id, &inv_tok_b_id, &commitment_b, &None, + ); + + // Verify initial state for both escrows from storage. + let data_a = escrow.get_escrow(&inv_a); + let data_b = escrow.get_escrow(&inv_b); + + assert_eq!(data_a.face_value, 1_200); + assert_eq!(data_a.purchase_price, 1_200); + assert_eq!(data_a.status, EscrowStatus::Created); + assert_eq!(data_a.token, pay_tok_a_id); + assert_eq!(data_a.commitment, commitment_a); + assert_eq!(data_a.due_dt, 88_888); + + assert_eq!(data_b.face_value, 700); + assert_eq!(data_b.purchase_price, 700); + assert_eq!(data_b.status, EscrowStatus::Created); + assert_eq!(data_b.token, pay_tok_b_id); + assert_eq!(data_b.commitment, commitment_b); + assert_eq!(data_b.due_dt, 77_777); + + // Fund only escrow A — B must remain Created. + escrow.fund_escrow(&inv_a, &buyer_a, &1_200); + assert_eq!(escrow.get_escrow_status(&inv_a), EscrowStatus::Funded); + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Created); + + // Check A's stored funded_amt and funder without touching B. + let data_a_after_fund = escrow.get_escrow(&inv_a); + assert_eq!(data_a_after_fund.funded_amt, 1_200); + assert_eq!(data_a_after_fund.funder, Some(buyer_a.clone())); + + let data_b_after_fund = escrow.get_escrow(&inv_b); + assert_eq!(data_b_after_fund.funded_amt, 0); + assert!(data_b_after_fund.funder.is_none()); + + // Settle A — B must remain unaffected. + escrow.record_payment(&inv_a, &payer_a, &1_200); + assert_eq!(escrow.get_escrow_status(&inv_a), EscrowStatus::Settled); + assert_eq!(escrow.get_escrow_status(&inv_b), EscrowStatus::Created); + + let data_a_settled = escrow.get_escrow(&inv_a); + assert_eq!(data_a_settled.paid_amt, 1_200); + + // B's paid_amt must still be 0. + let data_b_settled = escrow.get_escrow(&inv_b); + assert_eq!(data_b_settled.paid_amt, 0); + assert_eq!(data_b_settled.commitment, commitment_b); // commitment is immutable +} diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index bbbb882..37a4374 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -3816,6 +3816,19 @@ fn test_whitelist_disabled_allows_any_funder() { ); } + +// ========== Issue #160: Negative Tests for Expired Off-Chain Signature Submissions ========== +// +// The `fund_escrow_signed` entry-point carries an `expiry` timestamp. The contract +// rejects any call where `env.ledger().timestamp() > expiry`, returning +// `Error::SignatureExpired`. The tests below cover every negative/boundary edge-case +// around that expiry check and verify that a rejected submission leaves no side-effects +// (nonce unchanged, escrow status unchanged, no token transfer). + +/// Calling `fund_escrow_signed` after the expiry timestamp must return +/// `Error::SignatureExpired` and leave the escrow in `Created` status. +#[test] +fn test_fund_escrow_signed_rejects_expired_signature() { // ── Admin Pause as Dispute Resolution ───────────────────────────── #[test] @@ -4613,6 +4626,18 @@ fn test_error_paused() { 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 = AssetClient::new(&env, &pt_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_EXP1"); + let amount = 1_000i128; + + pt_asset.mint(&buyer, &amount); let seller = Address::generate(&env); let buyer = Address::generate(&env); let payer = Address::generate(&env); @@ -4705,6 +4730,35 @@ fn test_error_invalid_payer() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "expired_sig_test"), + &None, + ); + + // Advance the ledger past the expiry timestamp. + let expiry: u64 = 5_000; + env.ledger().with_mut(|li| li.timestamp = expiry + 1); + + let result = escrow_client.try_fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &expiry); + assert_eq!(result, Err(Ok(Error::SignatureExpired))); + + // Escrow must remain in Created state — no transition occurred. + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Created + ); +} + +/// When the ledger timestamp equals `expiry + 1` (strictly greater) the signature +/// is expired. When it equals `expiry` exactly the call must succeed — the contract +/// uses `current_ts > expiry` so equality is still valid. +#[test] +fn test_fund_escrow_signed_rejects_at_exact_expiry_plus_one_boundary() { &payer, &1000, &1000, @@ -4876,6 +4930,8 @@ fn test_error_escrow_not_settled_and_cleanup() { 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 = AssetClient::new(&env, &pt_id.address()); let seller = Address::generate(&env); let buyer = Address::generate(&env); let payer = Address::generate(&env); @@ -5012,6 +5068,10 @@ fn test_settlement_at_exact_due_date() { let seller = Address::generate(&env); let buyer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EXP2"); + let amount = 500i128; + + pt_asset.mint(&buyer, &amount); let payer = Address::generate(&env); let invoice_id = Symbol::new(&env, "INV_EXACT_DT"); let amount = 1000i128; @@ -5025,6 +5085,31 @@ fn test_settlement_at_exact_due_date() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "expiry_boundary"), + &None, + ); + + let expiry: u64 = 10_000; + + // ledger_ts == expiry: NOT expired (current_ts > expiry is false). + env.ledger().with_mut(|li| li.timestamp = expiry); + escrow_client.fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &expiry); + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Funded + ); +} + +/// When the ledger timestamp is strictly less than `expiry`, the signature is valid +/// and the escrow should be funded successfully. +#[test] +fn test_fund_escrow_signed_succeeds_just_before_expiry() { &payer, &amount, &amount, @@ -5064,6 +5149,19 @@ fn test_settlement_after_due_date_before_refund() { 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 = AssetClient::new(&env, &pt_id.address()); + let pt_token = TokenClient::new(&env, &pt_id.address()); + let inv_token_id = env.register(MockInvoiceToken, ()); + + escrow_client.initialize(&admin, &0); // 0% fee for clean balance checks + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EXP3"); + let amount = 800i128; + + pt_asset.mint(&buyer, &amount); let payment_token_admin = Address::generate(&env); let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); let payment_token = TokenClient::new(&env, &payment_token_id.address()); @@ -5087,6 +5185,35 @@ fn test_settlement_after_due_date_before_refund() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "just_before_expiry"), + &None, + ); + + let expiry: u64 = 20_000; + + // Set ledger to one tick before expiry — must succeed. + env.ledger().with_mut(|li| li.timestamp = expiry - 1); + escrow_client.fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &expiry); + + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Funded + ); + // Tokens must have moved to escrow. + assert_eq!(pt_token.balance(&escrow_id), amount); + assert_eq!(pt_token.balance(&buyer), 0); +} + +/// A rejected expired-signature call must leave the escrow status completely +/// unchanged so that a subsequent valid call can still succeed. +#[test] +fn test_fund_escrow_signed_expired_does_not_change_escrow_state() { &payer, &amount, &amount, @@ -5132,6 +5259,9 @@ fn test_settlement_at_exact_due_date_with_partial_payment() { 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 = AssetClient::new(&env, &pt_id.address()); + let pt_token = TokenClient::new(&env, &pt_id.address()); let payment_token_admin = Address::generate(&env); let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); let payment_token = TokenClient::new(&env, &payment_token_id.address()); @@ -5142,6 +5272,10 @@ fn test_settlement_at_exact_due_date_with_partial_payment() { let seller = Address::generate(&env); let buyer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EXP4"); + let amount = 1_000i128; + + pt_asset.mint(&buyer, &amount); let payer = Address::generate(&env); let invoice_id = Symbol::new(&env, "INV_PART_DT"); let amount = 1000i128; @@ -5155,6 +5289,45 @@ fn test_settlement_at_exact_due_date_with_partial_payment() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "state_unchanged_on_expiry"), + &None, + ); + + // First attempt: expired — must fail. + let expired_expiry: u64 = 3_000; + env.ledger().with_mut(|li| li.timestamp = expired_expiry + 10); + let result = + escrow_client.try_fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &expired_expiry); + assert_eq!(result, Err(Ok(Error::SignatureExpired))); + + // State must be fully unchanged. + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Created + ); + // No tokens must have moved. + assert_eq!(pt_token.balance(&buyer), amount); + assert_eq!(pt_token.balance(&escrow_id), 0); + + // Second attempt with a valid (far-future) expiry — must succeed now. + escrow_client.fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &u64::MAX); + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Funded + ); +} + +/// When a signature is rejected because it has expired the nonce must NOT be +/// consumed. A subsequent call with the same nonce (and a valid expiry) must +/// succeed, proving the nonce counter was not incremented by the failed attempt. +#[test] +fn test_fund_escrow_signed_nonce_not_consumed_on_expiry() { &payer, &amount, &amount, @@ -5209,6 +5382,8 @@ fn test_settlement_at_exact_due_date_state_persistence() { 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 = AssetClient::new(&env, &pt_id.address()); let payment_token_admin = Address::generate(&env); let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); let payment_token_asset = AssetClient::new(&env, &payment_token_id.address()); @@ -5218,6 +5393,10 @@ fn test_settlement_at_exact_due_date_state_persistence() { let seller = Address::generate(&env); let buyer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EXP5"); + let amount = 600i128; + + pt_asset.mint(&buyer, &amount); let payer = Address::generate(&env); let invoice_id = Symbol::new(&env, "INV_STATE_DT"); let amount = 2000i128; @@ -5234,6 +5413,42 @@ fn test_settlement_at_exact_due_date_state_persistence() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "nonce_not_consumed"), + &None, + ); + + // Attempt with expired signature using nonce = 42. + let expired_expiry: u64 = 1_000; + env.ledger().with_mut(|li| li.timestamp = expired_expiry + 1); + let result = + escrow_client.try_fund_escrow_signed(&invoice_id, &buyer, &amount, &42u64, &expired_expiry); + assert_eq!(result, Err(Ok(Error::SignatureExpired))); + + // Now use the SAME nonce (42) with a valid (far-future) expiry. + // If the nonce had been consumed by the failed call this would return + // NonceAlreadyUsed — confirming that expiry rejection is pre-nonce. + let result_valid = + escrow_client.try_fund_escrow_signed(&invoice_id, &buyer, &amount, &42u64, &u64::MAX); + assert!( + result_valid.is_ok(), + "Nonce must not be consumed by a signature-expired rejection" + ); + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Funded + ); +} + +/// Submitting a signature whose expiry is set to 0 (the epoch) is always expired +/// once the ledger is past timestamp 0. The contract must reject it. +#[test] +fn test_fund_escrow_signed_zero_expiry_always_expired() { &payer, &amount, &purchase_price, @@ -5277,6 +5492,8 @@ fn test_refund_prevented_after_settlement_at_exact_due_date() { 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 = AssetClient::new(&env, &pt_id.address()); let payment_token_admin = Address::generate(&env); let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); let payment_token = TokenClient::new(&env, &payment_token_id.address()); @@ -5287,6 +5504,10 @@ fn test_refund_prevented_after_settlement_at_exact_due_date() { let seller = Address::generate(&env); let buyer = Address::generate(&env); + let invoice_id = Symbol::new(&env, "INV_EXP6"); + let amount = 400i128; + + pt_asset.mint(&buyer, &amount); let payer = Address::generate(&env); let invoice_id = Symbol::new(&env, "INV_NO_REF_DT"); let amount = 1000i128; @@ -5300,6 +5521,33 @@ fn test_refund_prevented_after_settlement_at_exact_due_date() { escrow_client.create_escrow( &invoice_id, &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "zero_expiry"), + &None, + ); + + // Advance ledger past epoch 0 (any positive timestamp makes expiry = 0 expired). + env.ledger().with_mut(|li| li.timestamp = 1); + + let result = escrow_client.try_fund_escrow_signed(&invoice_id, &buyer, &amount, &1u64, &0u64); + assert_eq!(result, Err(Ok(Error::SignatureExpired))); + + assert_eq!( + escrow_client.get_escrow_status(&invoice_id), + EscrowStatus::Created + ); +} + +/// Expiry check must take precedence over the nonce check: even if the nonce is +/// already used, a call with an expired signature must return `SignatureExpired` +/// rather than `NonceAlreadyUsed`, confirming evaluation order. +#[test] +fn test_fund_escrow_signed_expiry_checked_before_nonce() { &payer, &amount, &amount, @@ -5344,6 +5592,8 @@ fn test_settlement_at_exact_due_date_emits_correct_events() { 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 = AssetClient::new(&env, &pt_id.address()); let payment_token_admin = Address::generate(&env); let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone()); let payment_token_asset = AssetClient::new(&env, &payment_token_id.address()); @@ -5353,6 +5603,41 @@ fn test_settlement_at_exact_due_date_emits_correct_events() { let seller = Address::generate(&env); let buyer = Address::generate(&env); + let invoice_id_a = Symbol::new(&env, "INV_EXP7A"); + let invoice_id_b = Symbol::new(&env, "INV_EXP7B"); + let amount = 300i128; + + pt_asset.mint(&buyer, &(amount * 2)); + + for inv in [&invoice_id_a, &invoice_id_b] { + escrow_client.create_escrow( + inv, + &seller, + &seller, + &amount, + &amount, + &9_999_999u64, + &pt_id.address(), + &inv_token_id, + &test_commitment(&env, "expiry_before_nonce"), + &None, + ); + } + + // Consume nonce 1 with a valid expiry against invoice_a. + escrow_client.fund_escrow_signed(&invoice_id_a, &buyer, &amount, &1u64, &u64::MAX); + + // Now attempt against invoice_b with the same (already-used) nonce AND an + // expired expiry timestamp. Expiry check fires first → SignatureExpired. + let expired_expiry: u64 = 100; + env.ledger().with_mut(|li| li.timestamp = expired_expiry + 1); + let result = + escrow_client.try_fund_escrow_signed(&invoice_id_b, &buyer, &amount, &1u64, &expired_expiry); + assert_eq!( + result, + Err(Ok(Error::SignatureExpired)), + "SignatureExpired must be returned before NonceAlreadyUsed is evaluated" + ); let payer = Address::generate(&env); let invoice_id = Symbol::new(&env, "INV_EVTS_DT"); let amount = 1000i128;