diff --git a/contracts/split/src/calc.rs b/contracts/split/src/calc.rs new file mode 100644 index 0000000..d8dc268 --- /dev/null +++ b/contracts/split/src/calc.rs @@ -0,0 +1,224 @@ +//! Arithmetic helpers for token distribution. +//! +//! Implements the **largest-remainder method** to distribute an integer `total` +//! across recipients proportionally, ensuring every stroop is accounted for +//! (i.e. `sum(result) == total` always holds). + +use soroban_sdk::{Env, Vec}; + +/// Distribute `total` among recipients according to their `ratios` out of +/// `denom`, using the largest-remainder method to handle rounding. +/// +/// # Arguments +/// * `env` – Soroban environment (needed to allocate the result `Vec`) +/// * `total` – total amount to distribute (stroops); must be ≥ 0 +/// * `ratios` – relative weight of each recipient (must be non-empty, all ≥ 0) +/// * `denom` – sum of all ratios (must be > 0) +/// +/// # Guarantees +/// * `result.iter().sum::() == total` always +/// * recipients with larger fractional remainders receive an extra stroop +/// * pure function: no side effects, no storage access +/// +/// # Panics +/// * if `ratios` is empty +/// * if `denom` is zero +pub fn distribute_with_remainder( + env: &Env, + total: i128, + ratios: &Vec, + denom: i128, +) -> Vec { + assert!(!ratios.is_empty(), "ratios must not be empty"); + assert!(denom > 0, "denom must be positive"); + + let n = ratios.len() as usize; + + // Compute floor shares and remainders. + let mut shares = Vec::new(env); + let mut remainders = Vec::new(env); + + for r in ratios.iter() { + shares.push_back(total * r / denom); + remainders.push_back(total * r % denom); + } + + // How many extra stroops to distribute? + let distributed: i128 = shares.iter().sum(); + let leftover = (total - distributed) as usize; + + // Build an index array sorted by remainder descending. + // We use a fixed-size array (max 64 recipients) for no_std compatibility. + // Contracts with more than 64 recipients would need a larger cap, but + // 64 is a reasonable upper bound for on-chain use. + const MAX_RECIPIENTS: usize = 64; + assert!(n <= MAX_RECIPIENTS, "too many recipients (max 64)"); + + let mut indices = [0usize; MAX_RECIPIENTS]; + for i in 0..n { + indices[i] = i; + } + + // Insertion sort by remainder descending (small n, simple is best). + for i in 1..n { + let key = indices[i]; + let key_rem = remainders.get(key as u32).unwrap_or(0); + let mut j = i; + while j > 0 { + let prev = indices[j - 1]; + let prev_rem = remainders.get(prev as u32).unwrap_or(0); + if prev_rem >= key_rem { + break; + } + indices[j] = indices[j - 1]; + j -= 1; + } + indices[j] = key; + } + + // Distribute leftover stroops to top-remainder recipients. + let mut shares_mut = Vec::new(env); + for s in shares.iter() { + shares_mut.push_back(s); + } + + for i in 0..leftover { + let idx = indices[i] as u32; + let current = shares_mut.get(idx).unwrap_or(0); + shares_mut.set(idx, current + 1); + } + + shares_mut +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + fn make_ratios(env: &Env, vals: &[i128]) -> Vec { + let mut v = Vec::new(env); + for &x in vals { + v.push_back(x); + } + v + } + + /// Assert sum equals total and return shares. + fn assert_exact(env: &Env, total: i128, ratios: &[i128], denom: i128) -> Vec { + let r_vec = make_ratios(env, ratios); + let result = distribute_with_remainder(env, total, &r_vec, denom); + let sum: i128 = result.iter().sum(); + assert_eq!( + sum, total, + "sum {sum} != total {total} for ratios {ratios:?} / {denom}" + ); + result + } + + #[test] + fn test_even_split() { + let env = Env::default(); + let r = assert_exact(&env, 300, &[1, 1, 1], 3); + assert_eq!(r.get(0), Some(100)); + assert_eq!(r.get(1), Some(100)); + assert_eq!(r.get(2), Some(100)); + } + + #[test] + fn test_uneven_split_even_amount() { + let env = Env::default(); + let r = assert_exact(&env, 100, &[1, 1], 2); + assert_eq!(r.get(0), Some(50)); + assert_eq!(r.get(1), Some(50)); + } + + #[test] + fn test_uneven_split_odd_amount() { + let env = Env::default(); + // 101 split 1:1 → sum must be 101, diff ≤ 1 + let r = assert_exact(&env, 101, &[1, 1], 2); + let a = r.get(0).unwrap(); + let b = r.get(1).unwrap(); + assert!((a - b).abs() <= 1); + } + + #[test] + fn test_three_way_remainder() { + let env = Env::default(); + // 10 / 3: floors [3,3,3] leftover=1 → one recipient gets 4 + let r = assert_exact(&env, 10, &[1, 1, 1], 3); + let mut vals = [r.get(0).unwrap(), r.get(1).unwrap(), r.get(2).unwrap()]; + vals.sort(); + assert_eq!(vals, [3, 3, 4]); + } + + #[test] + fn test_weighted_split() { + let env = Env::default(); + // 1000 split 1:2:3 (denom=6) + // floors: 166, 333, 500 → sum=999, leftover=1 + // remainders: 1000*1%6=4, 1000*2%6=2, 1000*3%6=0 + // index 0 has highest remainder → gets the extra stroop + let r = assert_exact(&env, 1000, &[1, 2, 3], 6); + assert_eq!(r.get(0), Some(167)); + assert_eq!(r.get(1), Some(333)); + assert_eq!(r.get(2), Some(500)); + } + + #[test] + fn test_single_recipient() { + let env = Env::default(); + let r = assert_exact(&env, 999, &[1], 1); + assert_eq!(r.get(0), Some(999)); + } + + #[test] + fn test_zero_total() { + let env = Env::default(); + let r = assert_exact(&env, 0, &[1, 2, 3], 6); + assert_eq!(r.get(0), Some(0)); + assert_eq!(r.get(1), Some(0)); + assert_eq!(r.get(2), Some(0)); + } + + #[test] + fn test_large_amounts() { + let env = Env::default(); + // 1_000_000_007 stroops across 7 equal recipients + assert_exact(&env, 1_000_000_007, &[1, 1, 1, 1, 1, 1, 1], 7); + } + + #[test] + fn test_large_ratio_values() { + let env = Env::default(); + // Real invoice: 1_000_000_000 stroops split 100_000:200_000:300_000 + assert_exact(&env, 1_000_000_000, &[100_000, 200_000, 300_000], 600_000); + } + + /// Property-based style test: exhaustively verify sum == total for many inputs. + #[test] + fn test_property_sum_equals_total() { + let env = Env::default(); + let cases: &[(i128, &[i128], i128)] = &[ + (1, &[1], 1), + (2, &[1, 1], 2), + (3, &[1, 1, 1], 3), + (7, &[1, 2, 4], 7), + (99, &[3, 5, 7, 11], 26), + (1000, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 55), + (1_000_000_000, &[1, 3, 5, 7], 16), + (13, &[2, 3], 5), + (10_000_007, &[1, 1, 1, 1, 1, 1, 1], 7), + (1_000_000_000_000i128, &[333_333, 333_333, 333_334], 1_000_000), + ]; + + for &(total, ratios, denom) in cases { + assert_exact(&env, total, ratios, denom); + } + } +} diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index de8abe0..38575d2 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -18,6 +18,10 @@ fn next_seq(env: &Env, invoice_id: u64) -> u64 { // Events // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Existing events +// --------------------------------------------------------------------------- + /// Emitted when a new invoice is created. /// Topics: (split, created, invoice_id) /// Data: (creator, total, event_seq) @@ -1479,3 +1483,53 @@ pub fn recipient_account_missing(env: &Env, invoice_id: u64, recipient: &Address recipient.clone(), ); } + +// --------------------------------------------------------------------------- +// #522 — Cross-Invoice Split Linkage +// --------------------------------------------------------------------------- + +/// Emitted on the first successful release of a child invoice, once the +/// parent is confirmed to be finalised. +/// +/// Topics: `("child_unblk", child_id)` +/// Data: `parent_id` +pub fn child_invoice_unblocked(env: &Env, child_id: u64, parent_id: u64) { + env.events().publish( + (symbol_short!("chld_ubl"), child_id), + parent_id, + ); +} + +// --------------------------------------------------------------------------- +// #523 — Late Payment Penalty Fee +// --------------------------------------------------------------------------- + +/// Emitted every time a late-payment penalty is charged. +/// +/// Topics: `("late_pen", invoice_id)` +/// Data: `(payer, penalty_amount)` +pub fn late_payment_penalty_charged( + env: &Env, + invoice_id: u64, + payer: &Address, + penalty_amount: i128, +) { + env.events().publish( + (symbol_short!("late_pen"), invoice_id), + (payer.clone(), penalty_amount), + ); +} + +// --------------------------------------------------------------------------- +// #524 — Invoice Batch Creation +// --------------------------------------------------------------------------- + +/// Emitted once after a successful `batch_create_invoices` call, carrying +/// all newly created invoice IDs in creation order. +/// +/// Topics: `("batch_crt",)` +/// Data: `ids: Vec` +pub fn batch_invoice_created(env: &Env, ids: &Vec) { + env.events() + .publish((symbol_short!("btch_crt"),), ids.clone()); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 86b9145..8378d7a 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -14587,6 +14587,21 @@ impl SplitContract { } } + // #522 — validate parent reference before creating the invoice. + if let Some(parent_id) = parent_invoice_id { + Self::_validate_parent(&env, parent_id, 0); + } + + Self::_create_invoice_inner( + &env, + creator, + recipients, + amounts, + token, + deadline, + parent_invoice_id, + late_penalty_bps, + ) // Refund every contributor. All transfers happen before state is mutated // so the operation is effectively atomic: if any transfer panics the // entire transaction is rolled back (Soroban's standard behaviour). @@ -15151,6 +15166,25 @@ impl SplitContract { .get(&template_id_key(&creator, template_id)) .expect("template not found") } + + /// #522 — Walk the parent chain and verify: + /// 1. The chain depth does not exceed `MAX_PARENT_DEPTH`. + /// 2. Each referenced invoice exists. + /// + /// `depth` starts at 0 for the direct parent. + fn _validate_parent(env: &Env, parent_id: u64, depth: u32) { + if depth >= MAX_PARENT_DEPTH { + panic_with_error!(env, ContractError::ParentChainTooDeep); + } + + // Load the parent — panics with "invoice not found" if it doesn't exist. + let parent = load_invoice(env, parent_id); + + // Recurse if this parent also has a parent. + if let Some(grandparent_id) = parent.parent_invoice_id { + Self::_validate_parent(env, grandparent_id, depth + 1); + } + } } /// Move a finalised invoice from hot storage to cold archival storage. diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 964a549..4ba3e5d 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -16,6 +16,8 @@ use types::{InvoiceOptions, InvoiceOptions2}; // Test helpers // --------------------------------------------------------------------------- +/// Deploy the split contract and a mock USDC token. +/// Returns `(env, contract_id, token_id)`. fn setup() -> (Env, Address, Address) { let env = Env::default(); env.mock_all_auths(); @@ -26,6 +28,8 @@ fn setup() -> (Env, Address, Address) { .register_stellar_asset_contract_v2(token_admin.clone()) .address(); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&token_admin, &1_000_000_000); StellarAssetClient::new(&env, &token_id).mint(&token_admin, &1_000_000_000); (env, contract_id, token_id) @@ -74,6 +78,31 @@ fn token_client<'a>(env: &'a Env, token_id: &Address) -> TokenClient<'a> { TokenClient::new(env, token_id) } +/// Mint `amount` tokens to `addr`. +fn mint(env: &Env, token_id: &Address, addr: &Address, amount: i128) { + StellarAssetClient::new(env, token_id).mint(addr, &amount); +} + +/// Build a soroban Vec
from a slice. +fn addrs(env: &Env, list: &[Address]) -> Vec
{ + let mut v = Vec::new(env); + for a in list { + v.push_back(a.clone()); + } + v +} + +/// Build a soroban Vec from a slice. +fn amounts(env: &Env, list: &[i128]) -> Vec { + let mut v = Vec::new(env); + for &a in list { + v.push_back(a); + } + v +} + +// --------------------------------------------------------------------------- +// Original tests (updated for new `pay` signature with `treasury` param) fn default_options(env: &Env) -> InvoiceOptions { InvoiceOptions { co_creators: Vec::new(env), @@ -294,7 +323,6 @@ fn make_invoice( fn test_create_invoice() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let creator = Address::generate(&env); let recipient = Address::generate(&env); @@ -308,12 +336,23 @@ fn test_create_invoice() { let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); env.ledger().set_timestamp(1_000); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[100]), + &token_id, + &2_000_u64, + &None, + &0_u32, + ); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 2_000); assert_eq!(id, 1); let invoice = c.get_invoice(&id); assert_eq!(invoice.status, InvoiceStatus::Pending); assert_eq!(invoice.funded, 0); + assert_eq!(invoice.parent_invoice_id, None); + assert_eq!(invoice.late_penalty_bps, 0); assert!(c.get_invoice_ext(&id).allowed_payers.is_none()); } @@ -322,10 +361,25 @@ fn test_pay_and_auto_release() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 500); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[200]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &200_i128, &treasury); let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &500); @@ -360,11 +414,30 @@ fn test_partial_pay_then_release() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - let creator = Address::generate(&env); let payer1 = Address::generate(&env); let payer2 = Address::generate(&env); let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer1, 150); + mint(&env, &token_id, &payer2, 150); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[300]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer1, &id, &150_i128, &treasury); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + c.pay(&payer2, &id, &150_i128, &treasury); let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer1, &150); @@ -398,10 +471,29 @@ fn test_refund_after_deadline() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 100); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[500]), + &token_id, + &2_000_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &100_i128, &treasury); + + // Advance past deadline + grace window (86400s) + env.ledger().set_timestamp(2_000 + 86_400 + 1); + let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&payer, &100); @@ -441,17 +533,27 @@ fn test_refund_after_deadline() { fn test_pay_after_deadline_panics() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 100); - StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 2_000); - env.ledger().set_timestamp(3_000); - c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[100]), + &token_id, + &2_000_u64, + &None, + &0_u32, + ); + + // Advance past deadline + grace window + env.ledger().set_timestamp(2_000 + 86_400 + 1); + c.pay(&payer, &id, &100_i128, &treasury); } #[test] @@ -459,16 +561,24 @@ fn test_pay_after_deadline_panics() { fn test_overpayment_panics() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); - StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + c.pay(&payer, &id, &200_i128, &treasury); } #[test] @@ -476,133 +586,157 @@ fn test_multi_recipient_release() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let r1 = Address::generate(&env); let r2 = Address::generate(&env); let r3 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 600); - env.ledger().set_sequence(1_000); - StellarAssetClient::new(&env, &token_id).mint(&payer, &600); env.ledger().set_timestamp(1_000); - let mut recipients = Vec::new(&env); - recipients.push_back(r1.clone()); - recipients.push_back(r2.clone()); - recipients.push_back(r3.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); - amounts.push_back(200_i128); - amounts.push_back(300_i128); - - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); - - env.ledger().set_sequence(3_000); - c.pay(&payer, &id, &100_i128); let id = c.create_invoice( &creator, - &recipients, - &amounts, + &addrs(&env, &[r1.clone(), r2.clone(), r3.clone()]), + &amounts(&env, &[100, 200, 300]), &token_id, &9_999_u64, - &default_options(&env), + &None, + &0_u32, ); - c.pay(&payer, &id, &600_i128, &0_u64, &false, &false, &None); + c.pay(&payer, &id, &600_i128, &treasury); assert_eq!(tk.balance(&r1), 100); assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } +// --------------------------------------------------------------------------- +// #522 — Cross-Invoice Split Linkage +// --------------------------------------------------------------------------- + +/// A child invoice without a parent releases normally. #[test] -fn test_audit_log() { +fn test_522_no_parent_releases_normally() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - + let tk = token_client(&env, &token_id); let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); - - StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 100); + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); - c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); - - assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); - - let log = c.get_audit_log(&id); - assert_eq!(log.len(), 2); - assert_eq!(log.get_unchecked(0).action, symbol_short!("pay")); - assert_eq!(log.get_unchecked(1).action, symbol_short!("release")); + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 2_000); + env.ledger().set_timestamp(3_000); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); } #[test] -fn test_cancel_invoice() { +#[should_panic(expected = "payment exceeds remaining balance")] +fn test_overpayment_panics() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let creator = Address::generate(&env); + let payer = Address::generate(&env); let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.cancel_invoice(&creator, &id); - - assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Cancelled); - - let log = c.get_audit_log(&id); - assert_eq!(log.len(), 1); - assert_eq!(log.get_unchecked(0).action, symbol_short!("cancel")); + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); } #[test] -fn test_transfer_invoice() { +fn test_multi_recipient_release() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - env.ledger().set_sequence(1_000); let creator = Address::generate(&env); - let new_creator = Address::generate(&env); - let recipient = Address::generate(&env); let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); - StellarAssetClient::new(&env, &token_id).mint(&payer, &400); + env.ledger().set_sequence(1_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &600); env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.transfer_invoice(&id, &new_creator); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); - // new_creator can cancel - c.cancel_invoice(&new_creator, &id); - assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Cancelled); - let _ = tk.balance(&recipient); // just ensure compiles + c.pay(&payer, &id, &100_i128, &treasury); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 100); } +/// A child invoice cannot be released before the parent is released. +/// Paying a child fully triggers auto-release which checks the parent gate. #[test] -fn test_partial_release_distributes_and_decrements_funded() { +#[should_panic] +fn test_522_child_blocked_until_parent_released() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let tk = token_client(&env, &token_id); - let creator = Address::generate(&env); let payer = Address::generate(&env); let r1 = Address::generate(&env); let r2 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); - StellarAssetClient::new(&env, &token_id).mint(&payer, &200); env.ledger().set_timestamp(1_000); + // Create parent invoice (stays Pending — never funded). + let parent_id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone()]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + // Create child invoice linked to parent. + let child_id = c.create_invoice( + &creator, + &addrs(&env, &[r2.clone()]), + &amounts(&env, &[200]), + &token_id, + &9_999_u64, + &Some(parent_id), + &0_u32, + ); + + // Fully fund the child — auto-release checks parent and should panic + // with ParentInvoiceNotFinalised because parent is still Pending. + c.pay(&payer, &child_id, &200_i128, &treasury); let mut recipients = Vec::new(&env); recipients.push_back(r1.clone()); recipients.push_back(r2.clone()); + recipients.push_back(r3.clone()); let mut amounts = Vec::new(&env); amounts.push_back(100_i128); + amounts.push_back(200_i128); amounts.push_back(300_i128); + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u32); + + env.ledger().set_sequence(3_000); + c.pay(&payer, &id, &100_i128); let id = c.create_invoice( &creator, &recipients, @@ -611,75 +745,714 @@ fn test_partial_release_distributes_and_decrements_funded() { &9_999_u64, &default_options(&env), ); + c.pay(&payer, &id, &600_i128, &0_u64, &false, &false, &None); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); - c.pay(&payer, &id, &200_i128); - // Payer funds 200 - c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); - assert_eq!(c.get_invoice(&id).funded, 200); - - // Creator partially releases 100 -> r1 gets 25, r2 gets 75 - c.partial_release(&id, &creator, &100_i128); - assert_eq!(tk.balance(&r1), 25); - assert_eq!(tk.balance(&r2), 75); - assert_eq!(c.get_invoice(&id).funded, 100); + assert_eq!(tk.balance(&r1), 100); + assert_eq!(tk.balance(&r2), 200); + assert_eq!(tk.balance(&r3), 300); } +/// A child invoice releases successfully after the parent is released. #[test] -fn test_forward_to_invoice_credits_target_invoice() { +fn test_522_child_releases_after_parent_finalised() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - + let tk = token_client(&env, &token_id); let creator = Address::generate(&env); let payer = Address::generate(&env); - let recipient = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); - StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); - // Create parent invoice first (id=1). - let id_parent = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); - assert_eq!(id_parent, 1); - - // Create child invoice that declares forward_invoice_id → parent (id=2). - let mut opts = default_options(&env); - opts.forward_invoice_id = Some(id_parent); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); - let id_child = c.create_invoice( + // Create parent and fully pay it (auto-releases). + let parent_id = c.create_invoice( &creator, - &recipients, - &amounts, + &addrs(&env, &[r1.clone()]), + &amounts(&env, &[100]), &token_id, &9_999_u64, - &opts, + &None, + &0_u32, ); - assert_eq!(id_child, 2); + c.pay(&payer, &parent_id, &100_i128, &treasury); + assert_eq!(c.get_invoice(&parent_id).status, InvoiceStatus::Released); - // Verify the field is stored correctly. - let ext = c.get_invoice_ext(&id_child); - assert_eq!(ext.forward_invoice_id, Some(id_parent)); + // Create child linked to parent (already released). + let child_id = c.create_invoice( + &creator, + &addrs(&env, &[r2.clone()]), + &amounts(&env, &[200]), + &token_id, + &9_999_u64, + &Some(parent_id), + &0_u32, + ); - // Pay and release child; parent funded stays 0 because last-recipient absorbs all (no leftover). - c.pay(&payer, &id_child, &100_i128, &0_u64, &false, &false, &None); - assert_eq!(c.get_invoice(&id_child).status, InvoiceStatus::Released); - assert_eq!(c.get_invoice(&id_parent).funded, 0); + // Fully fund child — should auto-release since parent is already Released. + c.pay(&payer, &child_id, &200_i128, &treasury); + assert_eq!(c.get_invoice(&child_id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&r2), 200); } +/// Parent chain depth exceeding MAX_PARENT_DEPTH is rejected at creation. #[test] -fn test_template_overwrite() { +#[should_panic] +fn test_522_parent_chain_too_deep() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let creator = Address::generate(&env); - let r1 = Address::generate(&env); - let r2 = Address::generate(&env); + let r = Address::generate(&env); env.ledger().set_timestamp(1_000); - let name = soroban_sdk::symbol_short!("tmpl"); + // Build a chain of 11 invoices (depth 10 = MAX_PARENT_DEPTH). + let mut prev_id: Option = None; + for _ in 0..11 { + let id = c.create_invoice( + &creator, + &addrs(&env, &[r.clone()]), + &amounts(&env, &[10]), + &token_id, + &9_999_u64, + &prev_id, + &0_u32, + ); + prev_id = Some(id); + } + + // One more link should exceed MAX_PARENT_DEPTH and panic. + c.create_invoice( + &creator, + &addrs(&env, &[r.clone()]), + &amounts(&env, &[10]), + &token_id, + &9_999_u64, + &prev_id, + &0_u32, + ); +} + +/// Referencing a non-existent parent invoice is rejected. +#[test] +#[should_panic(expected = "invoice not found")] +fn test_522_invalid_parent_rejected() { +fn test_audit_log() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + c.create_invoice( + &creator, + &addrs(&env, &[r]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &Some(999_u64), // non-existent + &0_u32, + ); +} + +/// parent_invoice_id is stored on the Invoice struct. +#[test] +fn test_522_parent_id_stored() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let parent_id = c.create_invoice( + &creator, + &addrs(&env, &[r.clone()]), + &amounts(&env, &[50]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + let child_id = c.create_invoice( + &creator, + &addrs(&env, &[r]), + &amounts(&env, &[50]), + &token_id, + &9_999_u64, + &Some(parent_id), + &0_u32, + ); + + let child = c.get_invoice(&child_id); + assert_eq!(child.parent_invoice_id, Some(parent_id)); +} + +// --------------------------------------------------------------------------- +// #523 — Late Payment Penalty Fee +// --------------------------------------------------------------------------- + +/// On-time contribution pays no penalty; treasury balance unchanged. +#[test] +fn test_523_ontime_no_penalty() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + let tk = token_client(&env, &token_id); + mint(&env, &token_id, &payer, 1_000); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[500]), + &token_id, + &9_000_u64, + &None, + &100_u32, // 1% penalty bps + ); + + // Pay well before deadline — no penalty. + c.pay(&payer, &id, &500_i128, &treasury); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + // Treasury receives nothing for an on-time payment. + assert_eq!(tk.balance(&treasury), 0); + // Recipient receives full amount. + assert_eq!(tk.balance(&recipient), 500); +} + +/// Grace-window contribution triggers penalty; treasury receives penalty amount. +#[test] +fn test_523_late_penalty_charged() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + // payer needs principal + penalty headroom + mint(&env, &token_id, &payer, 2_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 2); + assert_eq!(log.get_unchecked(0).action, symbol_short!("pay")); + assert_eq!(log.get_unchecked(1).action, symbol_short!("release")); +} + +#[test] +fn test_cancel_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.cancel_invoice(&creator, &id); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Cancelled); + + let log = c.get_audit_log(&id); + assert_eq!(log.len(), 1); + assert_eq!(log.get_unchecked(0).action, symbol_short!("cancel")); +} + +#[test] +fn test_transfer_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + env.ledger().set_sequence(1_000); + let creator = Address::generate(&env); + let new_creator = Address::generate(&env); + let recipient = Address::generate(&env); + let payer = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &400); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.transfer_invoice(&id, &new_creator); + + // new_creator can cancel + c.cancel_invoice(&new_creator, &id); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Cancelled); + let _ = tk.balance(&recipient); // just ensure compiles +} + +#[test] +fn test_partial_release_distributes_and_decrements_funded() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &200); + env.ledger().set_timestamp(1_000); + let deadline: u64 = 5_000; + + // 100 bps = 1% penalty + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[1_000]), + &token_id, + &deadline, + &None, + &100_u32, + ); + + // Advance into grace window (after deadline, before deadline + 86400). + env.ledger().set_timestamp(deadline + 1); + + c.pay(&payer, &id, &1_000_i128, &treasury); + + // Expected penalty: ceil(1000 * 100 / 10000) = ceil(10.00) = 10 + let expected_penalty: i128 = 10; + + assert_eq!(tk.balance(&treasury), expected_penalty); + // Invoice should be released (fully funded). + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); +} + +/// late_penalty_bps = 0 means grace-window contribution is penalty-free. +#[test] +fn test_523_zero_penalty_bps_no_charge() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); + + env.ledger().set_timestamp(1_000); + let deadline: u64 = 5_000; + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[500]), + &token_id, + &deadline, + &None, + &0_u32, // no penalty + ); + + // Pay in grace window. + env.ledger().set_timestamp(deadline + 100); + c.pay(&payer, &id, &500_i128, &treasury); + + // Treasury gets nothing. + assert_eq!(tk.balance(&treasury), 0); + assert_eq!(tk.balance(&recipient), 500); + let mut recipients = Vec::new(&env); + recipients.push_back(r1.clone()); + recipients.push_back(r2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + amounts.push_back(300_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u32); + c.pay(&payer, &id, &200_i128); + // Payer funds 200 + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); + assert_eq!(c.get_invoice(&id).funded, 200); + + // Creator partially releases 100 -> r1 gets 25, r2 gets 75 + c.partial_release(&id, &creator, &100_i128); + assert_eq!(tk.balance(&r1), 25); + assert_eq!(tk.balance(&r2), 75); + assert_eq!(c.get_invoice(&id).funded, 100); +} + +/// Penalty is calculated correctly for various bps values. +#[test] +fn test_523_penalty_calculation() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 10_000); + + env.ledger().set_timestamp(1_000); + let deadline: u64 = 5_000; + + // 500 bps = 5% penalty on 1000 → 50 + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[1_000]), + &token_id, + &deadline, + &None, + &500_u32, + ); + + env.ledger().set_timestamp(deadline + 1); + c.pay(&payer, &id, &1_000_i128, &treasury); + + // ceil(1000 * 500 / 10000) = ceil(50.0) = 50 + assert_eq!(tk.balance(&treasury), 50); +} + +/// late_penalty_bps is stored on the Invoice struct. +#[test] +fn test_523_penalty_bps_stored() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[r]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &250_u32, + ); + + assert_eq!(c.get_invoice(&id).late_penalty_bps, 250); +} + +// --------------------------------------------------------------------------- +// #524 — Invoice Batch Creation +// --------------------------------------------------------------------------- + +/// Batch creates multiple invoices and returns IDs in order. +#[test] +fn test_524_batch_creates_invoices_in_order() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut batch = Vec::new(&env); + for i in 0..3u32 { + batch.push_back(types::InvoiceParams { + creator: creator.clone(), + recipients: addrs(&env, &[r.clone()]), + amounts: amounts(&env, &[100 + i as i128]), + token: token_id.clone(), + deadline: 9_999_u64, + parent_invoice_id: None, + late_penalty_bps: 0, + }); + } + + let ids = c.batch_create_invoices(&batch); + + assert_eq!(ids.len(), 3); + // IDs should be consecutive and in order. + let id0 = ids.get(0).unwrap(); + let id1 = ids.get(1).unwrap(); + let id2 = ids.get(2).unwrap(); + assert_eq!(id1, id0 + 1); + assert_eq!(id2, id0 + 2); + + // Each invoice exists and has correct amounts. + assert_eq!(c.get_invoice(&id0).amounts.get(0).unwrap(), 100); + assert_eq!(c.get_invoice(&id1).amounts.get(0).unwrap(), 101); + assert_eq!(c.get_invoice(&id2).amounts.get(0).unwrap(), 102); +} + +/// Batch with a single invoice works. +#[test] +fn test_524_batch_single_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut batch = Vec::new(&env); + batch.push_back(types::InvoiceParams { + creator: creator.clone(), + recipients: addrs(&env, &[r]), + amounts: amounts(&env, &[50]), + token: token_id.clone(), + deadline: 9_999_u64, + parent_invoice_id: None, + late_penalty_bps: 0, + }); + + let ids = c.batch_create_invoices(&batch); + assert_eq!(ids.len(), 1); + let invoice = c.get_invoice(&ids.get(0).unwrap()); + assert_eq!(invoice.status, InvoiceStatus::Pending); + assert_eq!(invoice.funded, 0); +} + +/// Batch exceeding MAX_BATCH_SIZE panics with BatchTooLarge. +#[test] +#[should_panic] +fn test_524_batch_too_large_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut batch = Vec::new(&env); + // MAX_BATCH_SIZE = 50, push 51. + for _ in 0..51u32 { + batch.push_back(types::InvoiceParams { + creator: creator.clone(), + recipients: addrs(&env, &[r.clone()]), + amounts: amounts(&env, &[10]), + token: token_id.clone(), + deadline: 9_999_u64, + parent_invoice_id: None, + late_penalty_bps: 0, + }); + } + + c.batch_create_invoices(&batch); +} + +/// Batch with an empty Vec panics. +#[test] +#[should_panic(expected = "batch must not be empty")] +fn test_524_empty_batch_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + env.ledger().set_timestamp(1_000); + let batch: Vec = Vec::new(&env); + c.batch_create_invoices(&batch); +} + +/// Each invoice in a batch is independently payable and releasable. +#[test] +fn test_524_batch_invoices_independently_payable() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); + + env.ledger().set_timestamp(1_000); + + let mut batch = Vec::new(&env); + batch.push_back(types::InvoiceParams { + creator: creator.clone(), + recipients: addrs(&env, &[r1.clone()]), + amounts: amounts(&env, &[100]), + token: token_id.clone(), + deadline: 9_999_u64, + parent_invoice_id: None, + late_penalty_bps: 0, + }); + batch.push_back(types::InvoiceParams { + creator: creator.clone(), + recipients: addrs(&env, &[r2.clone()]), + amounts: amounts(&env, &[200]), + token: token_id.clone(), + deadline: 9_999_u64, + parent_invoice_id: None, + late_penalty_bps: 0, + }); + + let ids = c.batch_create_invoices(&batch); + let id0 = ids.get(0).unwrap(); + let id1 = ids.get(1).unwrap(); + + c.pay(&payer, &id0, &100_i128, &treasury); + c.pay(&payer, &id1, &200_i128, &treasury); + + assert_eq!(tk.balance(&r1), 100); + assert_eq!(tk.balance(&r2), 200); +} +fn test_forward_to_invoice_credits_target_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + +// --------------------------------------------------------------------------- +// #525 — Recipient Share Precision Rounding +// --------------------------------------------------------------------------- + +/// Distribute 1000 among 3 equal recipients (no remainder). +#[test] +fn test_525_even_split_no_remainder() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + // Create parent invoice first (id=1). + let id_parent = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + assert_eq!(id_parent, 1); + + // Create child invoice that declares forward_invoice_id → parent (id=2). + let mut opts = default_options(&env); + opts.forward_invoice_id = Some(id_parent); + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + let id_child = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); + assert_eq!(id_child, 2); + + // Verify the field is stored correctly. + let ext = c.get_invoice_ext(&id_child); + assert_eq!(ext.forward_invoice_id, Some(id_parent)); + + // Pay and release child; parent funded stays 0 because last-recipient absorbs all (no leftover). + c.pay(&payer, &id_child, &100_i128, &0_u64, &false, &false, &None); + assert_eq!(c.get_invoice(&id_child).status, InvoiceStatus::Released); + assert_eq!(c.get_invoice(&id_parent).funded, 0); +} + +#[test] +fn test_template_overwrite() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 300); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone(), r2.clone(), r3.clone()]), + &amounts(&env, &[100, 100, 100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &300_i128, &treasury); + + assert_eq!(tk.balance(&r1), 100); + assert_eq!(tk.balance(&r2), 100); + assert_eq!(tk.balance(&r3), 100); +} + +/// Distribute 10 among 3 equal recipients — sum must equal 10 exactly. +#[test] +fn test_525_rounding_sum_exact() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 10); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone(), r2.clone(), r3.clone()]), + &amounts(&env, &[10, 10, 10]), // equal ratios, total 30, funded 10 + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &10_i128, &treasury); + + let b1 = tk.balance(&r1); + let b2 = tk.balance(&r2); + let b3 = tk.balance(&r3); + let total = b1 + b2 + b3; + + // Sum must be exactly 10 (every stroop accounted for). + assert_eq!(total, 10); + // No recipient gets more than 1 stroop over another. + let max = b1.max(b2).max(b3); + let min = b1.min(b2).min(b3); + assert!(max - min <= 1); +} + +/// Weighted split 1:2:3 over 1000 — largest remainder recipient gets extra. +#[test] +fn test_525_weighted_largest_remainder() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); + + env.ledger().set_timestamp(1_000); + + let name = soroban_sdk::symbol_short!("tmpl"); let mut recipients1 = Vec::new(&env); recipients1.push_back(r1.clone()); @@ -808,6 +1581,53 @@ fn test_adjust_split_updates_amounts_and_pays_new_total() { StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); env.ledger().set_timestamp(1_000); + // amounts 1:2:3 with total 1000 paid + // denom = 1+2+3 = 6; floors: 166, 333, 500; sum=999; leftover=1 + // remainder(r1) = 1000*1%6 = 4 → highest → gets extra + let id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone(), r2.clone(), r3.clone()]), + &amounts(&env, &[1, 2, 3]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &1_000_i128, &treasury); + + assert_eq!(tk.balance(&r1), 167); + assert_eq!(tk.balance(&r2), 333); + assert_eq!(tk.balance(&r3), 500); + assert_eq!(tk.balance(&r1) + tk.balance(&r2) + tk.balance(&r3), 1000); +} + +/// Single recipient always gets 100% of the funded amount. +#[test] +fn test_525_single_recipient_full_amount() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 999); + + env.ledger().set_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[999]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &999_i128, &treasury); + assert_eq!(tk.balance(&recipient), 999); // Create invoice: r1=100, r2=200 (total 300). let mut recipients = Vec::new(&env); recipients.push_back(r1.clone()); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 141f777..1009442 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -129,12 +129,27 @@ pub struct Bid { pub amount: i128, } +// --------------------------------------------------------------------------- +// Invoice status +// --------------------------------------------------------------------------- + +/// Status of an invoice lifecycle. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum InvoiceStatus { Pending, Released, Refunded, + /// Alias for Released used as the parent-finalisation gate (#522). + /// An invoice is considered Finalised once it has been Released. + Finalised, +} + +// --------------------------------------------------------------------------- +// Payment +// --------------------------------------------------------------------------- + +/// A single payment made toward an invoice. Expired, Cancelled, Disputed, @@ -225,6 +240,11 @@ pub struct Payment { pub donate_on_failure: bool, } +// --------------------------------------------------------------------------- +// Invoice +// --------------------------------------------------------------------------- + +/// An on-chain invoice splitting payment among multiple recipients. #[contracttype] #[derive(Clone, Debug)] pub struct AuditEntry {