diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 6d8b831..6f82aef 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -304,6 +304,7 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { auction_end: 0, bids: Vec::new(env), min_payment: 0, + min_funding_amount: 0, }); Invoice::assemble(core, ext, ext2) } @@ -696,6 +697,7 @@ impl SplitContract { options.auto_resolve_rules, options.cross_chain_ref, options.allowed_payers, + options.min_funding_amount.unwrap_or(0), ) } @@ -739,6 +741,7 @@ impl SplitContract { auto_resolve_rules: Vec, cross_chain_ref: Option, allowed_payers: Option>, + min_funding_amount: i128, ) -> u64 { assert!( recipients.len() == amounts.len(), @@ -962,6 +965,7 @@ impl SplitContract { auction_end: 0, bids: Vec::new(env), min_payment: 0, + min_funding_amount, }; save_invoice(env, id, &invoice); @@ -1086,6 +1090,7 @@ impl SplitContract { Vec::new(&env), None, None, + 0, ); ids.push_back(id); } @@ -1153,6 +1158,7 @@ impl SplitContract { Vec::new(&env), None, None, + 0, ); if months > 1 { @@ -1285,7 +1291,8 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met); + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_amount > 0 && invoice.funded < invoice.min_funding_amount); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -1534,7 +1541,8 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met); + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_amount > 0 && invoice.funded < invoice.min_funding_amount); if guarded { save_invoice(env, invoice_id, &invoice); } else { @@ -1633,7 +1641,8 @@ impl SplitContract { || !invoice.tranches.is_empty() || !invoice.release_stages.is_empty() || in_group - || !invoice.co_signers.is_empty(); + || !invoice.co_signers.is_empty() + || (invoice.min_funding_amount > 0 && invoice.funded < invoice.min_funding_amount); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -1695,7 +1704,8 @@ impl SplitContract { || !invoice.release_stages.is_empty() || in_group || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met); + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_amount > 0 && invoice.funded < invoice.min_funding_amount); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -1771,7 +1781,8 @@ impl SplitContract { || !inv.release_stages.is_empty() || in_group || !inv.co_signers.is_empty() - || (inv.oracle_address.is_some() && !inv.condition_met); + || (inv.oracle_address.is_some() && !inv.condition_met) + || (inv.min_funding_amount > 0 && inv.funded < inv.min_funding_amount); if guarded { save_invoice(&env, p.invoice_id, &inv); } else { @@ -2732,6 +2743,7 @@ impl SplitContract { Vec::new(env), None, None, + 0, ); env.storage() .persistent() @@ -3260,6 +3272,7 @@ impl SplitContract { old_invoice.auto_resolve_rules.clone(), old_invoice.cross_chain_ref.clone(), None, + old_invoice.min_funding_amount, ); // Load the newly created invoice and copy over the payments. @@ -3472,6 +3485,7 @@ impl SplitContract { Vec::new(&env), None, None, + 0, ) } @@ -3900,7 +3914,7 @@ impl SplitContract { .unwrap_or_else(|| InvoiceExt2 { notification_contract: None, overflow_behavior: OverflowBehavior::Reject, cross_chain_ref: None, require_kyc: false, auction_on_expiry: false, - auction_end: 0, bids: Vec::new(&env), min_payment: 0, + auction_end: 0, bids: Vec::new(&env), min_payment: 0, min_funding_amount: 0, }); // Copy to instance storage. diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 9db42f6..0213e92 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -69,6 +69,7 @@ fn default_options(env: &Env) -> InvoiceOptions { oracle_address: None, cross_chain_ref: None, allowed_payers: None, + min_funding_amount: None, } } @@ -4118,3 +4119,61 @@ fn test_creator_stats_increments_on_operations() { assert_eq!(released, 2); assert_eq!(refunded, 1); } + +#[test] +fn test_min_funding_amount_delays_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 recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + // Total = 100 but min_funding_amount = 200, so fully paying 100 should not auto-release. + let mut opts = default_options(&env); + opts.min_funding_amount = Some(200); + + 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, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + c.pay(&payer, &id, &100_i128, &0_u64, &false); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 100); + assert_eq!(invoice.status, InvoiceStatus::Pending); +} + +#[test] +fn test_min_funding_amount_releases_when_met() { + 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, &500); + env.ledger().set_timestamp(1_000); + + // Total = 100, min_funding_amount = 50 — fully paying 100 meets both thresholds. + let mut opts = default_options(&env); + opts.min_funding_amount = Some(50); + + 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, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + c.pay(&payer, &id, &100_i128, &0_u64, &false); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 100); + assert_eq!(invoice.status, InvoiceStatus::Released); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 0e7ade9..d251307 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -203,6 +203,8 @@ pub struct InvoiceOptions { pub cross_chain_ref: Option, /// Issue #98: restrict payments to this allowlist; None = open. pub allowed_payers: Option>, + /// Absolute minimum funded amount required before auto-release triggers. + pub min_funding_amount: Option, } /// Legacy invoice layout used by stored invoices created before the `version` @@ -312,6 +314,7 @@ pub struct InvoiceExt2 { pub auction_end: u64, pub bids: Vec, pub min_payment: i128, + pub min_funding_amount: i128, } /// Full invoice — assembled from InvoiceCore + InvoiceExt + InvoiceExt2. @@ -377,6 +380,7 @@ pub struct Invoice { pub auction_end: u64, pub bids: Vec, pub min_payment: i128, + pub min_funding_amount: i128, } impl Invoice { @@ -446,6 +450,7 @@ impl Invoice { auction_end: self.auction_end, bids: self.bids, min_payment: self.min_payment, + min_funding_amount: self.min_funding_amount, }, ) } @@ -511,6 +516,7 @@ impl Invoice { auction_end: ext2.auction_end, bids: ext2.bids, min_payment: ext2.min_payment, + min_funding_amount: ext2.min_funding_amount, } } } @@ -597,6 +603,7 @@ impl Invoice { notification_contract: None, overflow_behavior: OverflowBehavior::Reject, cross_chain_ref: None, + min_funding_amount: 0, } } }