From 90ec22478e8a33b48ee8edf1ec587888f2bd9e50 Mon Sep 17 00:00:00 2001 From: obedebuka41-dotcom Date: Wed, 29 Jul 2026 23:38:56 +0000 Subject: [PATCH] feat: implement #522 #523 #524 #525 invoice enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #522 — Cross-Invoice Split Linkage - Add parent_invoice_id: Option to Invoice struct - Block _release if parent is not Released/Finalised - Detect invalid/too-deep parent chains at creation time (MAX_PARENT_DEPTH=10) - Emit ChildInvoiceUnblocked(child_id, parent_id) on successful child release - Add ParentInvoiceNotFinalised, CircularParentReference, ParentChainTooDeep errors #523 — Late Payment Penalty Fee - Add late_penalty_bps: u32 to Invoice struct - Accept contributions within GRACE_WINDOW (86400s) after deadline - Charge ceil(amount * bps / 10000) penalty and transfer to treasury - Emit LatePaymentPenaltyCharged(invoice_id, payer, penalty_amount) - Zero bps disables penalty; on-time payments unaffected #524 — Invoice Batch Creation - Add InvoiceParams struct mirroring create_invoice params - Add batch_create_invoices(invoices: Vec) -> Vec - Enforce MAX_BATCH_SIZE=50; return BatchTooLarge error if exceeded - Emit individual InvoiceCreated per entry + one BatchInvoiceCreated - Atomic: all succeed or transaction aborts #525 — Recipient Share Precision Rounding - Add calc.rs module with distribute_with_remainder (largest-remainder method) - Guarantees sum(shares) == total for any input (no stroop lost) - Largest fractional remainder recipients receive the extra stroop - Fully unit-tested in calc.rs including property-based test - _release uses distribute_with_remainder for all payouts chore: update .gitignore with test snapshots, IDE, OS, and stellar-cli artifacts --- .gitignore | 19 + contracts/split/src/calc.rs | 224 +++++++++ contracts/split/src/events.rs | 54 ++ contracts/split/src/lib.rs | 298 +++++++++-- contracts/split/src/test.rs | 900 ++++++++++++++++++++++++++++++---- contracts/split/src/types.rs | 85 ++++ 6 files changed, 1440 insertions(+), 140 deletions(-) create mode 100644 contracts/split/src/calc.rs diff --git a/.gitignore b/.gitignore index c56dddb..a24df95 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,22 @@ Cargo.lock .env.local .DS_Store Thumbs.db + +# Test snapshots +**/__snapshots__/ +*.snap + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +Thumbs.db +ehthumbs.db +Desktop.ini + +# Stellar CLI artifacts +.stellar/ +identity/ 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 f753e7a..6a816c7 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1,5 +1,9 @@ use soroban_sdk::{symbol_short, Address, Env, Vec}; +// --------------------------------------------------------------------------- +// Existing events +// --------------------------------------------------------------------------- + /// Emitted when a new invoice is created. pub fn invoice_created(env: &Env, invoice_id: u64, creator: &Address, total: i128) { env.events().publish( @@ -29,3 +33,53 @@ pub fn invoice_refunded(env: &Env, invoice_id: u64) { env.events() .publish((symbol_short!("inv_ref"), invoice_id), ()); } + +// --------------------------------------------------------------------------- +// #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 9bf6632..bc3e706 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -3,17 +3,44 @@ //! Allows a creator to define an invoice with multiple recipients and amounts. //! Payers contribute funds; once fully funded the contract auto-routes USDC to //! each recipient. If the deadline passes unfunded, payers are refunded. +//! +//! ## Features added in this branch +//! * **#522** — Cross-invoice linkage: a child invoice's release is blocked +//! until its parent is `Released` / `Finalised`. +//! * **#523** — Late-payment penalty: contributions after the deadline but +//! within a grace window incur an extra penalty transferred to the treasury. +//! * **#524** — Batch creation: `batch_create_invoices` creates up to +//! `MAX_BATCH_SIZE` invoices atomically. +//! * **#525** — Largest-remainder rounding: `_release` uses +//! `calc::distribute_with_remainder` so every stroop is accounted for. #![no_std] +mod calc; mod events; mod types; #[cfg(test)] mod test; -use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; -use types::{Invoice, InvoiceStatus, Payment}; +use soroban_sdk::{ + contract, contractimpl, panic_with_error, symbol_short, token, Address, Env, Symbol, Vec, +}; +use types::{ContractError, Invoice, InvoiceParams, InvoiceStatus, Payment}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum number of invoices that can be created in a single batch (#524). +const MAX_BATCH_SIZE: u32 = 50; + +/// Maximum parent-chain depth allowed before we stop recursing (#522). +const MAX_PARENT_DEPTH: u32 = 10; + +/// Grace period (in seconds) after the deadline during which a contribution +/// is accepted but treated as "late" (#523). +const GRACE_WINDOW: u64 = 86_400; // 24 hours // --------------------------------------------------------------------------- // Storage helpers @@ -24,7 +51,7 @@ fn counter_key() -> Symbol { symbol_short!("counter") } -/// Composite storage key for an invoice: (symbol, id). +/// Composite storage key for an invoice: `(symbol, id)`. fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("inv"), id) } @@ -51,14 +78,20 @@ pub struct SplitContract; #[contractimpl] impl SplitContract { + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + /// Create a new invoice. /// /// # Arguments - /// * `creator` – address that owns the invoice (must authorise) - /// * `recipients` – ordered list of recipient addresses - /// * `amounts` – amount owed to each recipient (parallel to `recipients`) - /// * `token` – USDC token contract address - /// * `deadline` – Unix timestamp; after this refunds become available + /// * `creator` – address that owns the invoice (must authorise) + /// * `recipients` – ordered list of recipient addresses + /// * `amounts` – amount owed to each recipient (parallel to `recipients`) + /// * `token` – USDC token contract address + /// * `deadline` – Unix timestamp; after this refunds become available + /// * `parent_invoice_id` – optional ID of a parent invoice (#522) + /// * `late_penalty_bps` – penalty in basis points for late contributions (#523) /// /// # Returns /// The new invoice ID (monotonically increasing u64). @@ -69,6 +102,8 @@ impl SplitContract { amounts: Vec, token: Address, deadline: u64, + parent_invoice_id: Option, + late_penalty_bps: u32, ) -> u64 { creator.require_auth(); @@ -86,32 +121,21 @@ impl SplitContract { assert!(amt > 0, "amounts must be positive"); } - // Increment and persist the invoice counter. - let id: u64 = env - .storage() - .persistent() - .get(&counter_key()) - .unwrap_or(0u64) - + 1; - env.storage().persistent().set(&counter_key(), &id); - - let total: i128 = amounts.iter().sum(); + // #522 — validate parent reference before creating the invoice. + if let Some(parent_id) = parent_invoice_id { + Self::_validate_parent(&env, parent_id, 0); + } - let invoice = Invoice { - creator: creator.clone(), - recipients: recipients.clone(), + Self::_create_invoice_inner( + &env, + creator, + recipients, amounts, token, deadline, - funded: 0, - status: InvoiceStatus::Pending, - payments: Vec::new(&env), - }; - - save_invoice(&env, id, &invoice); - events::invoice_created(&env, id, &creator, total); - - id + parent_invoice_id, + late_penalty_bps, + ) } /// Pay toward an invoice. @@ -119,11 +143,17 @@ impl SplitContract { /// Transfers `amount` of the invoice token from `payer` to this contract. /// Auto-releases funds if the invoice becomes fully funded. /// + /// Contributions after the `deadline` but within [`GRACE_WINDOW`] are + /// accepted and flagged as late; the `late_penalty_bps` fee is charged + /// on top and transferred to `treasury` (#523). + /// /// # Arguments /// * `payer` – address making the payment (must authorise) /// * `invoice_id` – target invoice - /// * `amount` – amount to pay in stroops - pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128) { + /// * `amount` – amount to pay in stroops (before any penalty) + /// * `treasury` – address that receives late-payment penalties; + /// ignored for on-time contributions + pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128, treasury: Address) { payer.require_auth(); let mut invoice = load_invoice(&env, invoice_id); @@ -132,19 +162,42 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + + let now = env.ledger().timestamp(); + + // Reject anything past the grace window. assert!( - env.ledger().timestamp() <= invoice.deadline, + now <= invoice.deadline + GRACE_WINDOW, "invoice deadline has passed" ); + assert!(amount > 0, "payment amount must be positive"); let total: i128 = invoice.amounts.iter().sum(); let remaining = total - invoice.funded; assert!(amount <= remaining, "payment exceeds remaining balance"); - // Transfer tokens from payer to this contract. + // #523 — determine whether this contribution is late. + let is_late = now > invoice.deadline; + let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer(&payer, &env.current_contract_address(), &amount); + + if is_late && invoice.late_penalty_bps > 0 { + // penalty = ceil(amount * late_penalty_bps / 10_000) + let penalty: i128 = + (amount * invoice.late_penalty_bps as i128 + 9_999) / 10_000; + + // Transfer principal to the contract. + token_client.transfer(&payer, &env.current_contract_address(), &amount); + + // Transfer penalty to treasury. + token_client.transfer(&payer, &treasury, &penalty); + + events::late_payment_penalty_charged(&env, invoice_id, &payer, penalty); + } else { + // On-time (or zero-penalty late) — transfer principal only. + token_client.transfer(&payer, &env.current_contract_address(), &amount); + } invoice.payments.push_back(Payment { payer: payer.clone(), @@ -165,6 +218,8 @@ impl SplitContract { /// Release funds to all recipients once the invoice is fully funded. /// /// Can be called by anyone; validates full funding internally. + /// For child invoices (#522), the parent must be `Released` or `Finalised` + /// before this call can succeed (enforced inside `_release`). pub fn release(env: Env, invoice_id: u64) { let mut invoice = load_invoice(&env, invoice_id); @@ -179,9 +234,8 @@ impl SplitContract { Self::_release(&env, invoice_id, &mut invoice); } - /// Refund all payers if the deadline has passed and the invoice is not fully funded. - /// - /// Can be called by anyone after the deadline. + /// Refund all payers if the deadline (+ grace window) has passed and the + /// invoice is not fully funded. pub fn refund(env: Env, invoice_id: u64) { let mut invoice = load_invoice(&env, invoice_id); @@ -190,7 +244,7 @@ impl SplitContract { "invoice is not pending" ); assert!( - env.ledger().timestamp() > invoice.deadline, + env.ledger().timestamp() > invoice.deadline + GRACE_WINDOW, "deadline has not passed" ); @@ -214,20 +268,180 @@ impl SplitContract { load_invoice(&env, invoice_id) } + /// Create multiple invoices atomically in a single transaction (#524). + /// + /// All invoices are created or none are (the transaction rolls back on any + /// failure). Returns the IDs of the newly created invoices, in order. + /// + /// # Arguments + /// * `invoices` – up to `MAX_BATCH_SIZE` invoice parameter structs + /// + /// # Errors + /// * `BatchTooLarge` if `invoices.len() > MAX_BATCH_SIZE` + pub fn batch_create_invoices(env: Env, invoices: Vec) -> Vec { + if invoices.len() > MAX_BATCH_SIZE { + panic_with_error!(&env, ContractError::BatchTooLarge); + } + + assert!(!invoices.is_empty(), "batch must not be empty"); + + let mut ids: Vec = Vec::new(&env); + + for params in invoices.iter() { + params.creator.require_auth(); + + assert!( + params.recipients.len() == params.amounts.len(), + "recipients and amounts length mismatch" + ); + assert!( + !params.recipients.is_empty(), + "must have at least one recipient" + ); + assert!( + params.deadline > env.ledger().timestamp(), + "deadline must be in the future" + ); + for amt in params.amounts.iter() { + assert!(amt > 0, "amounts must be positive"); + } + + // #522 — validate parent reference. + if let Some(parent_id) = params.parent_invoice_id { + Self::_validate_parent(&env, parent_id, 0); + } + + let id = Self::_create_invoice_inner( + &env, + params.creator.clone(), + params.recipients.clone(), + params.amounts.clone(), + params.token.clone(), + params.deadline, + params.parent_invoice_id, + params.late_penalty_bps, + ); + ids.push_back(id); + } + + // #524 — emit one BatchInvoiceCreated event with all IDs. + events::batch_invoice_created(&env, &ids); + + ids + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- - /// Route funds to all recipients and mark the invoice as released. + /// Core invoice creation logic, shared by `create_invoice` and + /// `batch_create_invoices`. + fn _create_invoice_inner( + env: &Env, + creator: Address, + recipients: Vec
, + amounts: Vec, + token: Address, + deadline: u64, + parent_invoice_id: Option, + late_penalty_bps: u32, + ) -> u64 { + // Increment and persist the invoice counter. + let id: u64 = env + .storage() + .persistent() + .get(&counter_key()) + .unwrap_or(0u64) + + 1; + env.storage().persistent().set(&counter_key(), &id); + + let total: i128 = amounts.iter().sum(); + + let invoice = Invoice { + creator: creator.clone(), + recipients: recipients.clone(), + amounts, + token, + deadline, + funded: 0, + status: InvoiceStatus::Pending, + payments: Vec::new(env), + parent_invoice_id, + late_penalty_bps, + }; + + save_invoice(env, id, &invoice); + events::invoice_created(env, id, &creator, total); + + id + } + + /// Route funds to all recipients using the largest-remainder method (#525) + /// and mark the invoice as `Released`. + /// + /// Also enforces the #522 parent-gate so both manual `release` calls and + /// auto-release triggered from `pay` respect the parent constraint. fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice) { + // #522 — block release if parent is not yet finalised. + if let Some(parent_id) = invoice.parent_invoice_id { + let parent = load_invoice(env, parent_id); + let parent_ok = parent.status == InvoiceStatus::Released + || parent.status == InvoiceStatus::Finalised; + if !parent_ok { + panic_with_error!(env, ContractError::ParentInvoiceNotFinalised); + } + } + let token_client = token::Client::new(env, &invoice.token); - for (recipient, amount) in invoice.recipients.iter().zip(invoice.amounts.iter()) { - token_client.transfer(&env.current_contract_address(), &recipient, &amount); + // #525 — use amounts as ratios; denom = sum of amounts. + let denom: i128 = invoice.amounts.iter().sum(); + let funded = invoice.funded; + + let shares = if denom == 0 { + // Edge case: all amounts are zero — nothing to distribute. + let mut z = Vec::new(env); + for _ in 0..invoice.recipients.len() { + z.push_back(0i128); + } + z + } else { + calc::distribute_with_remainder(env, funded, &invoice.amounts, denom) + }; + + for (i, recipient) in invoice.recipients.iter().enumerate() { + let share = shares.get(i as u32).unwrap_or(0); + if share > 0 { + token_client.transfer(&env.current_contract_address(), &recipient, &share); + } + } + + // #522 — emit ChildInvoiceUnblocked if this is a child invoice. + if let Some(parent_id) = invoice.parent_invoice_id { + events::child_invoice_unblocked(env, invoice_id, parent_id); } invoice.status = InvoiceStatus::Released; save_invoice(env, invoice_id, invoice); events::invoice_released(env, invoice_id, &invoice.recipients); } + + /// #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); + } + } } diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..d33c2d5 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -11,16 +11,18 @@ use soroban_sdk::{ // Test helpers // --------------------------------------------------------------------------- -/// Deploy the split contract and a mock USDC token; return (env, contract_id, token_id). +/// 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(); let contract_id = env.register(SplitContract, ()); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); - // Mint tokens to test accounts via the admin interface. let stellar_asset = StellarAssetClient::new(&env, &token_id); stellar_asset.mint(&token_admin, &1_000_000_000); @@ -35,32 +37,58 @@ 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 +} + // --------------------------------------------------------------------------- -// Tests +// Original tests (updated for new `pay` signature with `treasury` param) // --------------------------------------------------------------------------- #[test] 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); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); - - // Set ledger time so deadline is in the future. env.ledger().set_timestamp(1_000); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[100]), + &token_id, + &2_000_u64, + &None, + &0_u32, + ); 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); } #[test] @@ -68,31 +96,28 @@ 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); - - // Fund payer. - let stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&payer, &500); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 500); env.ledger().set_timestamp(1_000); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(200_i128); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[200]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); - - // Pay full amount — should auto-release. - c.pay(&payer, &id, &200_i128); + c.pay(&payer, &id, &200_i128, &treasury); let invoice = c.get_invoice(&id); assert_eq!(invoice.status, InvoiceStatus::Released); - - // Recipient should have received 200. assert_eq!(tk.balance(&recipient), 200); } @@ -101,32 +126,31 @@ 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 stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&payer1, &150); - stellar_asset.mint(&payer2, &150); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer1, 150); + mint(&env, &token_id, &payer2, 150); env.ledger().set_timestamp(1_000); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(300_i128); - - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); - - c.pay(&payer1, &id, &150_i128); - let invoice = c.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Pending); - - c.pay(&payer2, &id, &150_i128); - let invoice = c.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Released); + 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); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); assert_eq!(tk.balance(&recipient), 300); } @@ -135,34 +159,32 @@ 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 stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&payer, &100); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 100); env.ledger().set_timestamp(1_000); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(500_i128); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient]), + &amounts(&env, &[500]), + &token_id, + &2_000_u64, + &None, + &0_u32, + ); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); + c.pay(&payer, &id, &100_i128, &treasury); - // Partial payment. - c.pay(&payer, &id, &100_i128); - - // Advance past deadline. - env.ledger().set_timestamp(3_000); + // Advance past deadline + grace window (86400s) + env.ledger().set_timestamp(2_000 + 86_400 + 1); c.refund(&id); - let invoice = c.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Refunded); - // Payer should be refunded. + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Refunded); assert_eq!(tk.balance(&payer), 100); } @@ -171,82 +193,764 @@ 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); + + 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, + ); + + // Advance past deadline + grace window + env.ledger().set_timestamp(2_000 + 86_400 + 1); + c.pay(&payer, &id, &100_i128, &treasury); +} +#[test] +#[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); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 1_000); - let stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + 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] +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_timestamp(1_000); + + let id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone(), r2.clone(), r3.clone()]), + &amounts(&env, &[100, 200, 300]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + 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_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); + let treasury = Address::generate(&env); + mint(&env, &token_id, &payer, 100); env.ledger().set_timestamp(1_000); - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); + let id = c.create_invoice( + &creator, + &addrs(&env, &[recipient.clone()]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + + c.pay(&payer, &id, &100_i128, &treasury); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 100); +} - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64); +/// A child invoice cannot be released before the parent is released. +/// Paying a child fully triggers auto-release which checks the parent gate. +#[test] +#[should_panic] +fn test_522_child_blocked_until_parent_released() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_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); - env.ledger().set_timestamp(3_000); - c.pay(&payer, &id, &100_i128); + // 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); } +/// A child invoice releases successfully after the parent is released. #[test] -#[should_panic(expected = "payment exceeds remaining balance")] -fn test_overpayment_panics() { +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 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); + + // Create parent and fully pay it (auto-releases). + let parent_id = c.create_invoice( + &creator, + &addrs(&env, &[r1.clone()]), + &amounts(&env, &[100]), + &token_id, + &9_999_u64, + &None, + &0_u32, + ); + c.pay(&payer, &parent_id, &100_i128, &treasury); + assert_eq!(c.get_invoice(&parent_id).status, InvoiceStatus::Released); + + // 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, + ); + + // 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] +#[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 r = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + // 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() { 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); - let stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&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); 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); +} - let mut recipients = Vec::new(&env); - recipients.push_back(recipient.clone()); - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); +/// 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); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); - c.pay(&payer, &id, &200_i128); + 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); } +/// Penalty is calculated correctly for various bps values. #[test] -fn test_multi_recipient_release() { +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); +} +// --------------------------------------------------------------------------- +// #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 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); - let stellar_asset = StellarAssetClient::new(&env, &token_id); - stellar_asset.mint(&payer, &600); + 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 mut recipients = Vec::new(&env); - recipients.push_back(r1.clone()); - recipients.push_back(r2.clone()); - recipients.push_back(r3.clone()); + // 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); +} - let mut amounts = Vec::new(&env); - amounts.push_back(100_i128); - amounts.push_back(200_i128); - amounts.push_back(300_i128); +/// 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); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64); - c.pay(&payer, &id, &600_i128); + env.ledger().set_timestamp(1_000); - assert_eq!(tk.balance(&r1), 100); - assert_eq!(tk.balance(&r2), 200); - assert_eq!(tk.balance(&r3), 300); + 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); } diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 2f3d615..4376da3 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1,5 +1,9 @@ use soroban_sdk::{contracttype, Address, Vec}; +// --------------------------------------------------------------------------- +// Invoice status +// --------------------------------------------------------------------------- + /// Status of an invoice lifecycle. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -10,8 +14,15 @@ pub enum InvoiceStatus { Released, /// Deadline passed before full funding; payers refunded. 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. #[contracttype] #[derive(Clone, Debug)] @@ -22,6 +33,10 @@ pub struct Payment { pub amount: i128, } +// --------------------------------------------------------------------------- +// Invoice +// --------------------------------------------------------------------------- + /// An on-chain invoice splitting payment among multiple recipients. #[contracttype] #[derive(Clone, Debug)] @@ -42,4 +57,74 @@ pub struct Invoice { pub status: InvoiceStatus, /// All payments made toward this invoice. pub payments: Vec, + + // ----------------------------------------------------------------------- + // #522 — Cross-Invoice Split Linkage + // ----------------------------------------------------------------------- + + /// Optional parent invoice ID. + /// + /// When `Some(id)`, this child invoice's release is blocked until the + /// parent invoice is in the `Released` / `Finalised` state. + /// `None` means no dependency and the invoice releases normally. + pub parent_invoice_id: Option, + + // ----------------------------------------------------------------------- + // #523 — Late Payment Penalty Fee + // ----------------------------------------------------------------------- + + /// Penalty applied to late contributions, expressed in basis points. + /// + /// A contribution is "late" when it arrives after the invoice `deadline` + /// but within any grace window the application may enforce. The penalty + /// is charged on top of any platform fee and transferred to the treasury. + /// + /// `0` disables the penalty (late contributions are treated identically to + /// on-time contributions). + pub late_penalty_bps: u32, +} + +// --------------------------------------------------------------------------- +// #524 — Batch Invoice Creation +// --------------------------------------------------------------------------- + +/// Parameters for a single invoice inside a batch creation request. +/// +/// Mirrors the arguments of `create_invoice` so that a `Vec` +/// can be passed to `batch_create_invoices`. +#[contracttype] +#[derive(Clone, Debug)] +pub struct InvoiceParams { + /// Address that owns the invoice (must authorise the batch call). + pub creator: Address, + /// Ordered list of recipient addresses. + pub recipients: Vec
, + /// Amount owed to each recipient (parallel to `recipients`). + pub amounts: Vec, + /// USDC token contract address. + pub token: Address, + /// Unix timestamp after which unfunded invoices can be refunded. + pub deadline: u64, + /// Optional parent invoice ID (see `Invoice::parent_invoice_id`). + pub parent_invoice_id: Option, + /// Late-payment penalty in basis points (see `Invoice::late_penalty_bps`). + pub late_penalty_bps: u32, +} + +// --------------------------------------------------------------------------- +// Contract errors +// --------------------------------------------------------------------------- + +/// Error codes returned by the contract. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + /// #522 — A child invoice cannot be released until the parent is finalised. + ParentInvoiceNotFinalised, + /// #522 — A circular parent reference was detected at creation time. + CircularParentReference, + /// #522 — The parent chain exceeds the maximum allowed depth. + ParentChainTooDeep, + /// #524 — The batch size exceeds the `MaxBatchSize` cap. + BatchTooLarge, }