Skip to content

Commit d2e7e10

Browse files
authored
Merge branch 'main' into feature/issues-611-612-613-614
2 parents 2f47822 + 6080d8b commit d2e7e10

16 files changed

Lines changed: 1874 additions & 294 deletions

.gitignore

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,26 @@ wasm32-unknown-unknown/
99
.env.local
1010
.env.*.local
1111

12-
# OS files
12+
# OS / Editor files
1313
.DS_Store
1414
Thumbs.db
1515
desktop.ini
1616
.vscode/
1717
.idea/
18-
*.log
19-
*.tmp
20-
*.bak
21-
test_snapshots/
22-
snapshots/
23-
**/snapshots/
24-
*.swp
25-
*.swo
26-
*~
27-
28-
# Editor/IDE
29-
.vscode/
30-
.idea/
3118
*.swp
3219
*.swo
3320
*~
3421
*.sublime-project
3522
*.sublime-workspace
3623

37-
# Test snapshots
24+
# Test snapshots and generated artifacts
3825
**/__snapshots__/
3926
*.snap
40-
**/test_snapshots/
27+
*.snap.orig
4128
*.snapshot
42-
**/snapshots/
4329
*.snap.bak
30+
tests/snapshots/
31+
tests/fixtures/
4432

4533
# Stellar CLI
4634
.stellar/
@@ -70,7 +58,7 @@ profdata/
7058
.*.swp
7159
.*.swo
7260

73-
# Node / JS tooling (if any frontend tooling is added)
61+
# Node / JS tooling
7462
node_modules/
7563
dist/
7664
.pnp

contracts/split/src/calc.rs

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! across recipients proportionally, ensuring every stroop is accounted for
55
//! (i.e. `sum(result) == total` always holds).
66
7+
#[allow(unused_imports)]
8+
use crate::types::BASIS_POINTS_TOTAL;
79
use soroban_sdk::{Env, Vec};
810

911
use crate::error::ContractError;
@@ -15,16 +17,21 @@ use crate::error::ContractError;
1517
/// * `env` – Soroban environment (needed to allocate the result `Vec`)
1618
/// * `total` – total amount to distribute (stroops); must be ≥ 0
1719
/// * `ratios` – relative weight of each recipient (must be non-empty, all ≥ 0)
18-
/// * `denom` – sum of all ratios (must be > 0)
20+
/// * `denom` – sum of all ratios (must be > 0); typically [`BASIS_POINTS_TOTAL`]
1921
///
2022
/// # Guarantees
2123
/// * `result.iter().sum::<i128>() == total` always
2224
/// * recipients with larger fractional remainders receive an extra stroop
2325
/// * pure function: no side effects, no storage access
2426
///
25-
/// # Errors
26-
/// * [`ContractError::InvalidAmount`] if `ratios` is empty
27-
/// * [`ContractError::InvalidAmount`] if `denom` is zero or negative
27+
/// # Panics
28+
/// * if `ratios` is empty
29+
/// * if `denom` is zero
30+
// NOTE: if you call this function and ignore its return value the Rust
31+
// compiler will emit a `#[must_use]` warning:
32+
// warning: unused return value of `distribute_with_remainder` that must be used
33+
// This ensures callers never silently drop the distribution result.
34+
#[must_use = "the distribution result must be applied to recipients"]
2835
pub fn distribute_with_remainder(
2936
env: &Env,
3037
total: i128,
@@ -260,6 +267,34 @@ mod tests {
260267
assert_exact(&env, 1_000_000_000, &[100_000, 200_000, 300_000], 600_000);
261268
}
262269

270+
#[test]
271+
fn single_recipient_gets_full_amount() {
272+
let env = Env::default();
273+
let r = distribute_with_remainder(&env, 12345, &make_ratios(&env, &[1]), 1);
274+
assert_eq!(r.len(), 1);
275+
assert_eq!(r.get(0), Some(12345));
276+
}
277+
278+
#[test]
279+
fn sum_invariant_holds_with_unequal_ratios() {
280+
let env = Env::default();
281+
// Case 1: 3 recipients with ratios [1, 1, 1] and total=10
282+
// Total is not evenly divisible by denom (10 % 3 != 0)
283+
let r1 = distribute_with_remainder(&env, 10, &make_ratios(&env, &[1, 1, 1]), 3);
284+
let sum1: i128 = r1.iter().sum();
285+
assert_eq!(sum1, 10);
286+
287+
// Case 2: 4 recipients with ratios [2, 3, 1, 4] and total=100
288+
let r2 = distribute_with_remainder(&env, 100, &make_ratios(&env, &[2, 3, 1, 4]), 10);
289+
let sum2: i128 = r2.iter().sum();
290+
assert_eq!(sum2, 100);
291+
292+
// Case 3: 2 recipients with ratios [1, 3] and total=999
293+
let r3 = distribute_with_remainder(&env, 999, &make_ratios(&env, &[1, 3]), 4);
294+
let sum3: i128 = r3.iter().sum();
295+
assert_eq!(sum3, 999);
296+
}
297+
263298
/// Property-based style test: exhaustively verify sum == total for many inputs.
264299
#[test]
265300
fn test_property_sum_equals_total() {
@@ -282,29 +317,33 @@ mod tests {
282317
}
283318
}
284319

285-
/// Confirms that an empty ratios list returns Err(InvalidAmount) rather than panicking.
320+
// -----------------------------------------------------------------------
321+
// calc_platform_fee tests
322+
// -----------------------------------------------------------------------
323+
286324
#[test]
287-
fn test_empty_ratios_returns_err() {
288-
let env = Env::default();
289-
let empty = Vec::new(&env);
290-
let result = distribute_with_remainder(&env, 1000, &empty, 1);
291-
assert_eq!(
292-
result,
293-
Err(ContractError::InvalidAmount),
294-
"expected Err(InvalidAmount) for empty ratios"
295-
);
325+
fn test_calc_platform_fee_normal() {
326+
// 1_000_000 funded at 250 bps (2.5%) → fee = 25_000
327+
let fee = calc_platform_fee(1_000_000, 250).unwrap();
328+
assert_eq!(fee, 25_000);
296329
}
297330

298-
/// Confirms that denom == 0 returns Err(InvalidAmount) rather than panicking.
299331
#[test]
300-
fn test_zero_denom_returns_err() {
301-
let env = Env::default();
302-
let ratios = make_ratios(&env, &[1, 2, 3]);
303-
let result = distribute_with_remainder(&env, 1000, &ratios, 0);
304-
assert_eq!(
305-
result,
306-
Err(ContractError::InvalidAmount),
307-
"expected Err(InvalidAmount) for zero denom"
308-
);
332+
fn test_calc_platform_fee_zero_bps() {
333+
// Zero fee rate → always zero fee regardless of funded amount
334+
assert_eq!(calc_platform_fee(999_999_999, 0).unwrap(), 0);
335+
}
336+
337+
#[test]
338+
fn test_calc_platform_fee_max_bps() {
339+
// 10_000 bps = 100% → fee equals funded
340+
assert_eq!(calc_platform_fee(500, 10_000).unwrap(), 500);
341+
}
342+
343+
#[test]
344+
fn test_calc_platform_fee_overflow() {
345+
// i128::MAX * any fee_bps > 0 will overflow the intermediate multiplication
346+
let result = calc_platform_fee(i128::MAX, 1);
347+
assert_eq!(result, Err(crate::error::ContractError::ArithmeticOverflow));
309348
}
310349
}

contracts/split/src/constants.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//! Centralized constant definitions for the StellarSplit contract.
2+
3+
/// Issue #563: Minimum invoice TTL in ledgers.
4+
/// Set to ~60 days of ledgers (assuming ~5 seconds per ledger on Soroban).
5+
/// This ensures invoices remain accessible during typical dispute/resolution windows.
6+
pub const MIN_INVOICE_TTL_LEDGERS: u32 = 518_400;
7+
8+
/// Issue #563: Maximum invoice TTL in ledgers.
9+
/// Set to ~1 year of ledgers to allow long-term invoice archival and dispute resolution.
10+
/// Invoices can be bumped multiple times within this window to extend their lifetime.
11+
pub const MAX_INVOICE_TTL_LEDGERS: u32 = 31_536_000;

contracts/split/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,8 @@ pub enum ContractError {
118118
RecipientNotFound = 62,
119119
/// Issue #522: Parent chain depth exceeds the allowed maximum.
120120
ParentChainTooDeep = 63,
121+
/// Issue #564: Checkpoint index does not match stored value during payout recovery.
122+
CheckpointMismatch = 64,
123+
/// Issue #564: Recipient at this index has already been paid in a prior payout attempt.
124+
AlreadyPaid = 65,
121125
}

0 commit comments

Comments
 (0)