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
32 changes: 32 additions & 0 deletions packages/common/src/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,35 @@ pub fn safe_div(a: i128, b: i128) -> Result<i128, MathError> { if b == 0 { retur
pub fn calculate_percentage(amount: i128, bps: i128) -> Result<i128, MathError> { if bps < 0 || bps > 10_000 { return Err(MathError::Overflow); } safe_div(safe_mul(amount, bps)?, 10_000) }
pub fn apply_fee(amount: i128, fee_bps: i128) -> Result<(i128, i128), MathError> { let fee = calculate_percentage(amount, fee_bps)?; Ok((safe_sub(amount, fee)?, fee)) }
pub fn calculate_penalty(amount: i128, penalty_bps: i128) -> Result<i128, MathError> { calculate_percentage(amount, penalty_bps) }

/// Converts a member's individual shares into a proportional token amount from
/// a pool.
///
/// # Arguments
/// * `member_shares` – the number of shares attributed to the member (must be ≥ 0)
/// * `total_shares` – the total shares outstanding across all members
/// * `pool_amount` – the total token amount to be distributed
///
/// # Errors
/// Returns [`MathError::DivisionByZero`] when `total_shares` is zero (pool has
/// no share-holders), preventing a panic inside the contract host.
/// Returns [`MathError::Overflow`] / [`MathError::Underflow`] on arithmetic
/// overflow / underflow in intermediate computations.
pub fn convert_shares(
member_shares: i128,
total_shares: i128,
pool_amount: i128,
) -> Result<i128, MathError> {
// Guard: total_shares == 0 would cause a division-by-zero panic on-chain.
// This can happen when a vault or pool loses all members due to a rounding
// edge case. Returning a typed error lets the caller handle this gracefully
// instead of trapping the entire host execution.
if total_shares == 0 {
return Err(MathError::DivisionByZero);
}
// member_payout = (member_shares * pool_amount) / total_shares
// Multiplication is performed first to preserve precision; overflow is
// checked explicitly via safe_mul.
let numerator = safe_mul(member_shares, pool_amount)?;
safe_div(numerator, total_shares)
}
64 changes: 63 additions & 1 deletion packages/common/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use proptest::prelude::*;

use crate::math::{apply_fee, calculate_penalty, calculate_percentage};
use crate::math::{apply_fee, calculate_penalty, calculate_percentage, convert_shares, MathError};

proptest! {
#[test]
Expand All @@ -27,4 +27,66 @@ proptest! {
prop_assert!(penalty >= 0);
prop_assert!(penalty <= amount);
}

/// convert_shares must never panic for any non-zero total_shares.
#[test]
fn convert_shares_no_panic_nonzero_total(
member_shares in 0_i128..=1_000_000_i128,
total_shares in 1_i128..=1_000_000_i128,
pool_amount in 0_i128..=(i128::MAX / 1_000_000_i128),
) {
let result = convert_shares(member_shares, total_shares, pool_amount);
// Must never panic — always returns Ok or a typed error.
match result {
Ok(v) => prop_assert!(v >= 0),
Err(e) => prop_assert_eq!(e, MathError::Overflow),
}
}
}

// ── Deterministic unit tests for convert_shares ──────────────────────────────

#[test]
fn convert_shares_zero_total_returns_division_by_zero() {
// GUARD: total_shares == 0 must return DivisionByZero, not panic.
let result = convert_shares(100, 0, 1_000);
assert_eq!(result, Err(MathError::DivisionByZero));
}

#[test]
fn convert_shares_zero_pool_returns_zero() {
// Pool is empty — every member gets 0 regardless of their share count.
assert_eq!(convert_shares(50, 100, 0), Ok(0));
}

#[test]
fn convert_shares_equal_shares_splits_evenly() {
// 5 members each holding 20 out of 100 shares, pool = 1000.
// Each member should receive 200.
assert_eq!(convert_shares(20, 100, 1_000), Ok(200));
}

#[test]
fn convert_shares_all_shares_to_one_member() {
// Single member holds all shares — should receive the entire pool.
assert_eq!(convert_shares(100, 100, 5_000), Ok(5_000));
}

#[test]
fn convert_shares_zero_member_shares_returns_zero() {
// Member with no shares gets nothing.
assert_eq!(convert_shares(0, 100, 1_000), Ok(0));
}

#[test]
fn convert_shares_single_share_of_many() {
// 1 share out of 1_000_000, pool = 1_000_000 → member gets 1.
assert_eq!(convert_shares(1, 1_000_000, 1_000_000), Ok(1));
}

#[test]
fn convert_shares_overflow_on_huge_inputs() {
// member_shares * pool_amount overflows i128 → must return Overflow, not panic.
let result = convert_shares(i128::MAX, 1, i128::MAX);
assert_eq!(result, Err(MathError::Overflow));
}
Loading