From 2ec6b11c7e4cdf360aadcf2a70cbe0e37e2d0cdc Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Sat, 20 Jun 2026 13:42:12 +0100 Subject: [PATCH] feat: add multi-token batch payment support to pool_pay Group payments by token across invoices and perform one transfer per token, enabling batch payment of invoices using different tokens in a single pool_pay call. Also fixes missing escrow release timestamp when an escrow invoice is fully funded via pool_pay. Closes #3 --- contracts/sharpy/src/lib.rs | 26 +++++-- contracts/sharpy/src/test.rs | 140 ++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 7cd864d..0814f5a 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -241,20 +241,26 @@ impl SharpyContract { payer.require_auth(); assert!(!payments.is_empty(), "payments must not be empty"); - let mut total: i128 = 0; + // Phase 1: Validate all invoices and group totals by token + let mut token_totals: Map = Map::new(&env); for p in payments.iter() { let inv = load_invoice(&env, p.invoice_id); assert!(inv.status == InvoiceStatus::Pending, "invoice is not pending"); assert!(p.amount > 0, "payment amount must be positive"); let inv_total: i128 = inv.amounts.iter().sum(); assert!(inv.funded + p.amount <= inv_total, "payment exceeds remaining balance"); - total += p.amount; + let token = inv.tokens.get(0).expect("no token"); + let prev = token_totals.get(token.clone()).unwrap_or(0); + token_totals.set(token, prev + p.amount); } - let first = load_invoice(&env, payments.get(0).unwrap().invoice_id); - let token_client = token::Client::new(&env, &first.tokens.get(0).expect("no token")); - token_client.transfer(&payer, &env.current_contract_address(), &total); + // Phase 2: Transfer tokens — one transfer per unique token + for (token, amount) in token_totals.iter() { + let token_client = token::Client::new(&env, &token); + token_client.transfer(&payer, &env.current_contract_address(), &amount); + } + // Phase 3: Update each invoice's state for p in payments.iter() { let mut inv = load_invoice(&env, p.invoice_id); inv.payments.push_back(Payment { payer: payer.clone(), amount: p.amount, tip: 0 }); @@ -262,8 +268,14 @@ impl SharpyContract { append_audit(&env, p.invoice_id, symbol_short!("pool_pay"), &payer); events::payment_received(&env, p.invoice_id, &payer, p.amount); let inv_total: i128 = inv.amounts.iter().sum(); - if inv.funded >= inv_total && !inv.escrow_enabled { - Self::_release(&env, p.invoice_id, &mut inv, &payer); + if inv.funded >= inv_total { + if inv.escrow_enabled { + let release_at = env.ledger().timestamp() + inv.escrow_release_delay; + env.storage().persistent().set(&escrow_state_key(p.invoice_id), &release_at); + save_invoice(&env, p.invoice_id, &inv); + } else { + Self::_release(&env, p.invoice_id, &mut inv, &payer); + } } else { save_invoice(&env, p.invoice_id, &inv); } diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 7b7395f..c24f9bb 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -1,12 +1,27 @@ #[cfg(test)] mod tests { - use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; + use soroban_sdk::{testutils::Address as _, token, Address, Env, Vec}; use soroban_sdk::testutils::Ledger as _; use crate::{ types::{CreateInvoiceParams, InvoiceOptions, InvoicePayment, InvoiceStatus, SplitRule}, SharpyContractClient, }; + fn setup_with_tokens( + env: &Env, + payer: &Address, + amounts: &[i128], + ) -> (Address, Address) { + let admin = Address::generate(env); + let token_a = env.register_stellar_asset_contract(admin.clone()); + let token_b = env.register_stellar_asset_contract(admin); + let sac_a = token::StellarAssetClient::new(env, &token_a); + let sac_b = token::StellarAssetClient::new(env, &token_b); + sac_a.mint(payer, &amounts[0]); + sac_b.mint(payer, &amounts[1]); + (token_a, token_b) + } + fn setup() -> (Env, SharpyContractClient<'static>) { let env = Env::default(); env.mock_all_auths(); @@ -390,4 +405,127 @@ mod tests { &default_options(&env), ); } + + #[test] + fn test_pool_pay_multi_token() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_a, token_b) = setup_with_tokens(&env, &payer, &[1000i128, 1000i128]); + let deadline = env.ledger().timestamp() + 86400; + + let id1 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient.clone()]), + &Vec::from_array(&env, [500i128]), + &Vec::from_array(&env, [token_a.clone()]), + &deadline, + &default_options(&env), + ); + let id2 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient.clone()]), + &Vec::from_array(&env, [300i128]), + &Vec::from_array(&env, [token_b.clone()]), + &deadline, + &default_options(&env), + ); + + let payments = Vec::from_array(&env, [ + InvoicePayment { invoice_id: id1, amount: 500i128 }, + InvoicePayment { invoice_id: id2, amount: 300i128 }, + ]); + client.pool_pay(&payer, &payments); + + let inv1 = client.get_invoice(&id1); + assert_eq!(inv1.funded, 500i128); + assert_eq!(inv1.status, InvoiceStatus::Released); + + let inv2 = client.get_invoice(&id2); + assert_eq!(inv2.funded, 300i128); + assert_eq!(inv2.status, InvoiceStatus::Released); + } + + #[test] + fn test_pool_pay_multi_token_same_token_grouped() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let (token, _) = setup_with_tokens(&env, &payer, &[1000, 1000]); + let deadline = env.ledger().timestamp() + 86400; + + let id1 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient.clone()]), + &Vec::from_array(&env, [200i128]), + &Vec::from_array(&env, [token.clone()]), + &deadline, + &default_options(&env), + ); + let id2 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient.clone()]), + &Vec::from_array(&env, [300i128]), + &Vec::from_array(&env, [token.clone()]), + &deadline, + &default_options(&env), + ); + + let payments = Vec::from_array(&env, [ + InvoicePayment { invoice_id: id1, amount: 200i128 }, + InvoicePayment { invoice_id: id2, amount: 300i128 }, + ]); + client.pool_pay(&payer, &payments); + + let inv1 = client.get_invoice(&id1); + assert_eq!(inv1.funded, 200i128); + assert_eq!(inv1.status, InvoiceStatus::Released); + + let inv2 = client.get_invoice(&id2); + assert_eq!(inv2.funded, 300i128); + assert_eq!(inv2.status, InvoiceStatus::Released); + } + + #[test] + fn test_pool_pay_partial_multi_token() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_a, token_b) = setup_with_tokens(&env, &payer, &[1000i128, 1000i128]); + let deadline = env.ledger().timestamp() + 86400; + + let id1 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient.clone()]), + &Vec::from_array(&env, [1000i128]), + &Vec::from_array(&env, [token_a.clone()]), + &deadline, + &default_options(&env), + ); + let id2 = client.create_invoice( + &creator, + &Vec::from_array(&env, [recipient]), + &Vec::from_array(&env, [1000i128]), + &Vec::from_array(&env, [token_b]), + &deadline, + &default_options(&env), + ); + + let payments = Vec::from_array(&env, [ + InvoicePayment { invoice_id: id1, amount: 400i128 }, + InvoicePayment { invoice_id: id2, amount: 600i128 }, + ]); + client.pool_pay(&payer, &payments); + + let inv1 = client.get_invoice(&id1); + assert_eq!(inv1.funded, 400i128); + assert_eq!(inv1.status, InvoiceStatus::Pending); + + let inv2 = client.get_invoice(&id2); + assert_eq!(inv2.funded, 600i128); + assert_eq!(inv2.status, InvoiceStatus::Pending); + } }