From 8f10fe39e887f3f6bc03d3db36840b6dc24a8eee Mon Sep 17 00:00:00 2001 From: Umar faruk Date: Mon, 27 Jul 2026 21:07:50 +0000 Subject: [PATCH] feat: add validate_ratios and BASIS_POINTS_TOTAL for invoice ratio integrity - Add BASIS_POINTS_TOTAL = 10_000u32 constant to types.rs - Add ratios: Vec field to InvoiceOptions (empty = no constraint) - Add InvalidRatioSum = 33 and EmptyRecipientList = 34 to ContractError - Add pub(crate) validate_ratios() private helper: rejects empty vecs with EmptyRecipientList and non-10000 sums with InvalidRatioSum - Call validate_ratios from create_invoice before any storage writes, using env.panic_with_error() to surface the typed error on-chain - Update default_options() in test.rs and fuzz/src/lib.rs with ratios field - Add 7 unit tests covering all acceptance criteria --- contracts/split/src/error.rs | 4 ++ contracts/split/src/lib.rs | 25 +++++++- contracts/split/src/test.rs | 115 +++++++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 6 ++ fuzz/src/lib.rs | 1 + 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index b66d59e..258970f 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -53,4 +53,8 @@ pub enum ContractError { MemoMismatch = 31, /// Issue #439: Creator is in cooldown after cancelling an invoice. CreatorCooldownActive = 31, + /// The provided ratios do not sum to exactly BASIS_POINTS_TOTAL (10 000). + InvalidRatioSum = 33, + /// The recipient/ratio list is empty; at least one entry is required. + EmptyRecipientList = 34, } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index be40701..d40303d 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -62,7 +62,7 @@ use types::{ LegacyInvoice, OverflowBehavior, Payment, PaymentCertificate, PaymentCommitment, PaymentProof, ProtocolFeeConfig, QueuedAction, Recipient, RebateTier, RepScore, ResolveAction, ResolveRule, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tranche, TreasuryRecord, - UpgradeProposal, + UpgradeProposal, BASIS_POINTS_TOTAL, }; // --------------------------------------------------------------------------- @@ -842,6 +842,22 @@ fn validate_milestones(env: &Env, milestones: &Vec) { let _ = env; } +/// Validate that `ratios` is non-empty and sums to exactly [`BASIS_POINTS_TOTAL`] (10 000). +/// +/// Returns `Ok(())` on success, or: +/// - [`ContractError::EmptyRecipientList`] when the slice is empty. +/// - [`ContractError::InvalidRatioSum`] when the sum differs from 10 000. +pub(crate) fn validate_ratios(ratios: &Vec) -> Result<(), ContractError> { + if ratios.is_empty() { + return Err(ContractError::EmptyRecipientList); + } + let sum: u32 = ratios.iter().fold(0u32, |acc, r| acc.saturating_add(r)); + if sum != BASIS_POINTS_TOTAL { + return Err(ContractError::InvalidRatioSum); + } + Ok(()) +} + /// Issue #299: Update creator stats on invoice creation. fn update_creator_stats_on_creation(env: &Env, creator: &Address) { let count_key = creator_stats_count_key(creator); @@ -3352,6 +3368,13 @@ impl SplitContract { assert!(balance > 0, "nft gate: not a holder"); } + // Validate split ratios (if provided) before any storage is touched. + if !options.ratios.is_empty() { + if let Err(e) = validate_ratios(&options.ratios) { + env.panic_with_error(e); + } + } + Self::_create_invoice_inner( &env, creator, diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 78ff5b0..1504054 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -106,6 +106,7 @@ fn default_options(env: &Env) -> InvoiceOptions { priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + ratios: Vec::new(env), ext: types::InvoiceOptions2 { target_usd_cents: None, payment_token: None, @@ -195,6 +196,7 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + ratios: Vec::new(env), ext: types::InvoiceOptions2 { target_usd_cents: None, payment_token: None, @@ -9521,3 +9523,116 @@ fn test_milestones_auto_release() { assert_eq!(tk.balance(&recipient), 100); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); } + +// --------------------------------------------------------------------------- +// validate_ratios unit tests +// --------------------------------------------------------------------------- + +#[test] +fn test_validate_ratios_exact_sum_accepted() { + // A single entry of 10 000 must be accepted. + let env = Env::default(); + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(10_000u32); + assert!(validate_ratios(&ratios).is_ok()); +} + +#[test] +fn test_validate_ratios_multi_entry_accepted() { + // Multiple entries summing to exactly 10 000 must be accepted. + let env = Env::default(); + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(5_000u32); + ratios.push_back(3_000u32); + ratios.push_back(2_000u32); + assert!(validate_ratios(&ratios).is_ok()); +} + +#[test] +fn test_validate_ratios_under_sum_rejected() { + // Sum < 10 000 must return InvalidRatioSum. + let env = Env::default(); + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(4_000u32); + ratios.push_back(4_000u32); // sum = 8 000 + assert_eq!( + validate_ratios(&ratios), + Err(ContractError::InvalidRatioSum) + ); +} + +#[test] +fn test_validate_ratios_over_sum_rejected() { + // Sum > 10 000 must return InvalidRatioSum. + let env = Env::default(); + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(6_000u32); + ratios.push_back(6_000u32); // sum = 12 000 + assert_eq!( + validate_ratios(&ratios), + Err(ContractError::InvalidRatioSum) + ); +} + +#[test] +fn test_validate_ratios_empty_rejected() { + // An empty ratios vec must return EmptyRecipientList. + let env = Env::default(); + let ratios: Vec = Vec::new(&env); + assert_eq!( + validate_ratios(&ratios), + Err(ContractError::EmptyRecipientList) + ); +} + +#[test] +fn test_create_invoice_valid_ratios_accepted() { + // create_invoice with a valid ratios vec (sums to 10 000) should succeed. + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + set_ledger(&env, 1, 1_000); + + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(10_000u32); + + let mut opts = default_options(&env); + opts.ratios = ratios; + + let id = c.create_invoice( + &creator, + &one_address_vec(&env, &recipient), + &one_amount_vec(&env, 100_i128), + &token_id, + &9_999_u64, + &opts, + ); + assert!(id > 0); +} + +#[test] +#[should_panic] +fn test_create_invoice_invalid_ratios_panics() { + // create_invoice with ratios not summing to 10 000 must panic. + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + set_ledger(&env, 1, 1_000); + + let mut ratios: Vec = Vec::new(&env); + ratios.push_back(5_000u32); // sum = 5 000, not 10 000 + + let mut opts = default_options(&env); + opts.ratios = ratios; + + c.create_invoice( + &creator, + &one_address_vec(&env, &recipient), + &one_amount_vec(&env, 100_i128), + &token_id, + &9_999_u64, + &opts, + ); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 7d7e183..47a38d7 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1,5 +1,8 @@ use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env, String, Symbol, Vec}; +/// Total basis points representing 100% — ratio vecs must sum to exactly this value. +pub const BASIS_POINTS_TOTAL: u32 = 10_000; + /// (base, quote) asset pair for oracle-priced invoices. #[contracttype] #[derive(Clone, Debug)] @@ -330,6 +333,9 @@ pub struct InvoiceOptions { pub scheduled_release_at: Option, /// KYC verification requirement. pub require_kyc: bool, + /// Per-recipient split ratios in basis points (must sum to [`BASIS_POINTS_TOTAL`] = 10 000 + /// when non-empty). Empty vec means "no ratio constraint — use amounts directly." + pub ratios: Vec, /// Overflow fields that would otherwise push this struct past Soroban's /// 40-field `#[contracttype]` limit — see [`InvoiceOptions2`]. pub ext: InvoiceOptions2, diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 30b5720..98bd3b8 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -82,6 +82,7 @@ pub fn default_options(env: &Env) -> InvoiceOptions { refund_grace_secs: None, scheduled_release_at: None, require_kyc: false, + ratios: Vec::new(env), ext: InvoiceOptions2 { target_usd_cents: None, payment_token: None,