diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index 2958ba7..4d21c4c 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -53,6 +53,10 @@ pub enum ContractError { MemoMismatch = 33, /// 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, /// Reentrant call detected: a fund-moving function was invoked recursively /// within the same transaction. Cleared automatically at transaction boundary /// because the lock lives in temporary storage. diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index ee88af4..8ddf23d 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -77,7 +77,7 @@ use types::{ TreasuryRecord, UpgradeProposal, ProtocolFeeConfig, QueuedAction, Recipient, RecipientAddress, RebateTier, RepScore, ResolveAction, ResolveRule, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tranche, TreasuryRecord, - UpgradeProposal, + UpgradeProposal, BASIS_POINTS_TOTAL, }; // --------------------------------------------------------------------------- @@ -1015,6 +1015,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); @@ -3879,6 +3895,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 1e24d67..bce0799 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, @@ -199,6 +200,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, @@ -9822,6 +9824,117 @@ fn test_milestones_auto_release() { 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, + ); fn configured_checkpoint_setup() -> (Env, Address, Address, Address) { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 7fc5c14..662e1e1 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)] @@ -344,6 +347,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,