From f86eb2fac456a5c1f05e890131a1cba70800f71a Mon Sep 17 00:00:00 2001 From: Marvin Michael Nkut Date: Thu, 28 May 2026 04:54:04 +0000 Subject: [PATCH] feat: add invoice group all-or-nothing release (#33) - Add group_key, invoice_group_key, load_group, group_all_funded helpers - Add create_invoice_group(invoice_ids) -> group_id - pay() auto-release skips if invoice is grouped and not all members funded - release() asserts all group members funded, then releases all Pending members - Non-grouped invoices behave identically to before - 3 new tests: partial fund blocks release, all funded releases both, non-grouped unaffected - All 10 tests pass, clippy clean Closes #33 --- contracts/split/src/lib.rs | 102 +++++++++++++++++++++++++++++++- contracts/split/src/test.rs | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 9bf6632..722ce02 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -42,6 +42,35 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { .set(&invoice_key(id), invoice); } +/// Composite storage key for a group: (symbol, group_id). +fn group_key(group_id: u64) -> (Symbol, u64) { + (symbol_short!("grp"), group_id) +} + +fn load_group(env: &Env, group_id: u64) -> Vec { + env.storage() + .persistent() + .get(&group_key(group_id)) + .expect("group not found") +} + +/// Storage key mapping an invoice ID to its group ID. +fn invoice_group_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("invgrp"), invoice_id) +} + +/// Returns true only if every invoice in the group is fully funded. +fn group_all_funded(env: &Env, group_id: u64) -> bool { + for id in load_group(env, group_id).iter() { + let inv = load_invoice(env, id); + let total: i128 = inv.amounts.iter().sum(); + if inv.funded < total { + return false; + } + } + true +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -154,9 +183,17 @@ impl SplitContract { events::payment_received(&env, invoice_id, &payer, amount); - // Auto-release if fully funded. + // Auto-release if fully funded (and group constraint satisfied). if invoice.funded >= total { - Self::_release(&env, invoice_id, &mut invoice); + let group_id: Option = env + .storage() + .persistent() + .get(&invoice_group_key(invoice_id)); + if group_id.is_none_or(|gid| group_all_funded(&env, gid)) { + Self::_release(&env, invoice_id, &mut invoice); + } else { + save_invoice(&env, invoice_id, &invoice); + } } else { save_invoice(&env, invoice_id, &invoice); } @@ -165,6 +202,7 @@ impl SplitContract { /// Release funds to all recipients once the invoice is fully funded. /// /// Can be called by anyone; validates full funding internally. + /// If the invoice belongs to a group, all members must be fully funded. pub fn release(env: Env, invoice_id: u64) { let mut invoice = load_invoice(&env, invoice_id); @@ -176,7 +214,26 @@ impl SplitContract { let total: i128 = invoice.amounts.iter().sum(); assert!(invoice.funded >= total, "invoice not fully funded"); - Self::_release(&env, invoice_id, &mut invoice); + // Group check: all members must be fully funded before any releases. + let group_id: Option = env + .storage() + .persistent() + .get(&invoice_group_key(invoice_id)); + if let Some(gid) = group_id { + assert!( + group_all_funded(&env, gid), + "group members not fully funded" + ); + // Release every member in the group. + for id in load_group(&env, gid).iter() { + let mut inv = load_invoice(&env, id); + if inv.status == InvoiceStatus::Pending { + Self::_release(&env, id, &mut inv); + } + } + } else { + Self::_release(&env, invoice_id, &mut invoice); + } } /// Refund all payers if the deadline has passed and the invoice is not fully funded. @@ -214,6 +271,45 @@ impl SplitContract { load_invoice(&env, invoice_id) } + /// Link multiple invoices into a group for all-or-nothing release. + /// + /// Returns the new group ID. All invoices must exist and be Pending. + pub fn create_invoice_group(env: Env, invoice_ids: Vec) -> u64 { + assert!(invoice_ids.len() >= 2, "group must have at least 2 invoices"); + + // Validate all invoices exist and are pending. + for id in invoice_ids.iter() { + let inv = load_invoice(&env, id); + assert!( + inv.status == InvoiceStatus::Pending, + "all invoices must be pending" + ); + } + + let group_id: u64 = env + .storage() + .persistent() + .get(&symbol_short!("grpcnt")) + .unwrap_or(0u64) + + 1; + env.storage() + .persistent() + .set(&symbol_short!("grpcnt"), &group_id); + + env.storage() + .persistent() + .set(&group_key(group_id), &invoice_ids); + + // Map each invoice → group. + for id in invoice_ids.iter() { + env.storage() + .persistent() + .set(&invoice_group_key(id), &group_id); + } + + group_id + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..675d2c3 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -250,3 +250,118 @@ fn test_multi_recipient_release() { assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } + +#[test] +#[should_panic(expected = "group members not fully funded")] +fn test_group_partial_fund_blocks_release() { + 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 stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &1_000); + + env.ledger().set_timestamp(1_000); + + let mut rec1 = Vec::new(&env); + rec1.push_back(r1.clone()); + let mut amt1 = Vec::new(&env); + amt1.push_back(100_i128); + + let mut rec2 = Vec::new(&env); + rec2.push_back(r2.clone()); + let mut amt2 = Vec::new(&env); + amt2.push_back(200_i128); + + let id1 = c.create_invoice(&creator, &rec1, &amt1, &token_id, &9_999_u64); + let id2 = c.create_invoice(&creator, &rec2, &amt2, &token_id, &9_999_u64); + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + c.create_invoice_group(&ids); + + // Fund only invoice 1 fully. + c.pay(&payer, &id1, &100_i128); + + // Attempt to release invoice 1 — should panic because invoice 2 is not funded. + c.release(&id1); +} + +#[test] +fn test_group_all_funded_releases_both() { + 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 stellar_asset = StellarAssetClient::new(&env, &token_id); + stellar_asset.mint(&payer, &1_000); + + env.ledger().set_timestamp(1_000); + + let mut rec1 = Vec::new(&env); + rec1.push_back(r1.clone()); + let mut amt1 = Vec::new(&env); + amt1.push_back(100_i128); + + let mut rec2 = Vec::new(&env); + rec2.push_back(r2.clone()); + let mut amt2 = Vec::new(&env); + amt2.push_back(200_i128); + + let id1 = c.create_invoice(&creator, &rec1, &amt1, &token_id, &9_999_u64); + let id2 = c.create_invoice(&creator, &rec2, &amt2, &token_id, &9_999_u64); + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + c.create_invoice_group(&ids); + + // Fund both invoices fully. + c.pay(&payer, &id1, &100_i128); + c.pay(&payer, &id2, &200_i128); + + // Release via either member — both should be released. + c.release(&id1); + + assert_eq!(c.get_invoice(&id1).status, InvoiceStatus::Released); + assert_eq!(c.get_invoice(&id2).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&r1), 100); + assert_eq!(tk.balance(&r2), 200); +} + +#[test] +fn test_non_grouped_invoice_unaffected() { + 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, &300); + + 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(&payer, &id, &300_i128); + + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 300); +}