diff --git a/packages/common/src/math.rs b/packages/common/src/math.rs index 778a3dc..9cbfde6 100644 --- a/packages/common/src/math.rs +++ b/packages/common/src/math.rs @@ -14,3 +14,35 @@ pub fn safe_div(a: i128, b: i128) -> Result { if b == 0 { retur pub fn calculate_percentage(amount: i128, bps: i128) -> Result { 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 { 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 { + // 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) +} diff --git a/packages/common/src/test.rs b/packages/common/src/test.rs index e8eb74b..c686d87 100644 --- a/packages/common/src/test.rs +++ b/packages/common/src/test.rs @@ -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] @@ -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)); }