Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/split/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1015,6 +1015,22 @@ fn validate_milestones(env: &Env, milestones: &Vec<u32>) {
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<u32>) -> 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);
Expand Down Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u32> = 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<u32> = 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<u32> = 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<u32> = 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<u32> = 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<u32> = 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<u32> = 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);
Expand Down
6 changes: 6 additions & 0 deletions contracts/split/src/types.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -344,6 +347,9 @@ pub struct InvoiceOptions {
pub scheduled_release_at: Option<u64>,
/// 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<u32>,
/// Overflow fields that would otherwise push this struct past Soroban's
/// 40-field `#[contracttype]` limit — see [`InvoiceOptions2`].
pub ext: InvoiceOptions2,
Expand Down
1 change: 1 addition & 0 deletions fuzz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading