From 4810617e40bb789404d4fe0b87e2a57b259f609d Mon Sep 17 00:00:00 2001 From: BABAT-CODE Date: Fri, 31 Jul 2026 15:05:19 +0000 Subject: [PATCH] fix: add zero-division guards in math.rs and share conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add safe_div_u128() with explicit zero-denominator guard - Add safe_mul_u128() for overflow-safe u128 multiplication - Add shares_to_amount(pool, member_shares, total_shares) helper that returns DivisionByZero when total_shares == 0 instead of panicking — guards the rounding edge case in vault/pool payouts - Replace unsafe (net_u.saturating_mul(w)/total_weighted) in trigger_payout with math::shares_to_amount to propagate the error cleanly instead of panicking the contract - Add comprehensive unit tests for all new helpers (happy path, zero denominator, overflow, single-member full-pool, etc.) Fixes: HIGH security issue — division by zero in share conversion could permanently lock funds if total_shares hits zero --- packages/circle/src/contract.rs | 2 +- packages/common/src/math.rs | 248 +++++++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 8 deletions(-) diff --git a/packages/circle/src/contract.rs b/packages/circle/src/contract.rs index ea37869..4731df4 100644 --- a/packages/circle/src/contract.rs +++ b/packages/circle/src/contract.rs @@ -2,7 +2,7 @@ use soroban_sdk::{Address,BytesN,Env,Vec,Map,symbol_short};use crate::types::*;u pub fn init(env:&Env,admin:&Address,factory:&Address,config:&CircleConfig)->Result<(),CircleError>{if config.max_members<2||config.contribution_amount<=0||config.total_rounds==0||config.payout_type>3{return Err(CircleError::InvalidAmount);}if config.max_members>scoring::max_circle_size(env,&config.organizer){return Err(CircleError::CircleSizeExceedsTier);}if config.contribution_amount>scoring::max_contribution(env,&config.organizer){return Err(CircleError::ContributionExceedsTier);}let circle=Circle{id:env.current_contract_address(),token:config.token.clone(),name:config.name.clone(),organizer:config.organizer.clone(),factory:factory.clone(),contribution_amount:config.contribution_amount,max_members:config.max_members,member_count:0,payout_type:config.payout_type,total_rounds:config.total_rounds,current_round:0,status:STATUS_PENDING,started_at:0,created_at:env.ledger().timestamp(),contribution_deadline_seconds:config.contribution_deadline_seconds,min_moi_score:config.min_moi_score,collateral_amount:config.collateral_amount,penalty_bps:config.penalty_bps,grace_period_seconds:config.grace_period_seconds,max_strikes:config.max_strikes,payout_bitmap:0,total_payouts:0,total_fees:0,slug:config.slug.clone()};env.storage().instance().set(&DataKey::Circle,&circle);env.storage().instance().set(&DataKey::Admin,admin);env.storage().instance().set(&DataKey::Factory,factory);env.storage().persistent().set(&DataKey::Members,&Vec::::new(env));env.storage().persistent().set(&DataKey::Contributions,&Vec::::new(env));env.storage().persistent().set(&DataKey::Payouts,&Vec::::new(env));env.storage().persistent().set(&DataKey::Bids,&Vec::::new(env));env.storage().persistent().set(&DataKey::Votes,&Vec::::new(env));Ok(())} pub fn join(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_DISPUTED||circle.status==STATUS_COMPLETED{return Err(CircleError::NotActive);}let score = scoring::get_score(env, member);if score < circle.min_moi_score {return Err(CircleError::InsufficientMoiScore);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).unwrap_or_else(||Vec::new(env));for i in 0..members.len(){if members.get(i).ok_or(CircleError::VecAccessError)?.address==*member{return Err(CircleError::AlreadyMember);}}if members.len()as u32>=circle.max_members{return Err(CircleError::CircleFull);}if circle.collateral_amount>0{let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(member,&circle.id,&circle.collateral_amount);}let now=env.ledger().timestamp();let pos=members.len()as u32;members.push_back(Member{address:member.clone(),position:pos,joined_at:now,strikes:0,status:MEMBER_ACTIVE,exited_at:0,total_contributions:0,total_received:0});circle.member_count=circle.member_count.wrapping_add(1);if circle.member_count>=circle.max_members&&circle.status==STATUS_PENDING{circle.status=STATUS_ACTIVE;circle.started_at=now;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("joined")),MemberJoined{member:member.clone(),position:pos});Ok(())} pub fn contribute(env:&Env,member:&Address,amount:i128,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}if amount!=circle.contribution_amount{return Err(CircleError::ContributionMismatch);}let members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut found=false;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*member{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}found=true;}}if!found{return Err(CircleError::NotMember);}let mut contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));for i in 0..contributions.len(){let c=contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.member==*member&&c.round==round{return Err(CircleError::AlreadyContributed);}}let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(member,&circle.id,&amount);let now=env.ledger().timestamp();let on_time=now<=circle.started_at.wrapping_add(circle.contribution_deadline_seconds);contributions.push_back(Contribution{member:member.clone(),round,amount,timestamp:now,on_time,time_weight:now});env.storage().persistent().set(&DataKey::Contributions,&contributions);env.events().publish((env.current_contract_address(),symbol_short!("contrib")),ContributionRecorded{member:member.clone(),round,amount,on_time});scoring::record_on_time_payment(env,member,&circle.id,amount);Ok(())} -pub fn trigger_payout(env:&Env,caller:&Address,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;let stored_admin:Address=env.storage().instance().get(&DataKey::Admin).ok_or(CircleError::NotInitialized)?;if caller!=&circle.organizer&&caller!=&stored_admin{return Err(CircleError::Unauthorized);}if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let(recipient,payout_type)=match circle.payout_type{PAYOUT_RANDOM=>(payout::resolve_random(env,&circle,round)?,PAYOUT_RANDOM),PAYOUT_FIXED=>(payout::resolve_fixed(env,&circle,round)?,PAYOUT_FIXED),PAYOUT_AUCTION=>{let(w,_)=payout::resolve_auction(env,&circle,round)?;(w,PAYOUT_AUCTION)}PAYOUT_VOTE=>(payout::resolve_vote(env,&circle,round)?,PAYOUT_VOTE),_=>return Err(CircleError::InvalidPayoutType)};let pool=math::safe_mul(circle.contribution_amount,circle.member_count as i128).map_err(|_|CircleError::InvalidAmount)?;let fee_bps:u32=env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0u32);let(net,fee)=math::apply_fee(pool,fee_bps as i128).map_err(|_|CircleError::InvalidAmount)?;let token_client=soroban_sdk::token::Client::new(env,&circle.token);let now=env.ledger().timestamp();let all_contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut total_weighted:u128=0;let mut member_weighted:Map=Map::new(env);for i in 0..all_contributions.len(){let c=all_contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.round==round{let time_held=(now as u128).saturating_sub(c.time_weight as u128);let w=(c.amount as u128).saturating_mul(time_held);total_weighted=total_weighted.saturating_add(w);let prev=member_weighted.get(c.member.clone()).unwrap_or(0);member_weighted.set(c.member.clone(),prev.saturating_add(w));}}let mut payouts:Vec=env.storage().persistent().get(&DataKey::Payouts).unwrap_or_else(||Vec::new(env));let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut distributed:i128=0;let net_u=net as u128;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if let Some(w)=member_weighted.get(m.address.clone()){if total_weighted>0{let share=if distributed==0&&w==total_weighted{net}else{(net_u.saturating_mul(w)/total_weighted)as i128};if share>0{token_client.transfer(&circle.id,&m.address,&share);distributed=math::safe_add(distributed,share).map_err(|_|CircleError::InvalidAmount)?;payouts.push_back(PayoutRecipient{recipient:m.address.clone(),round,amount:share,fee:0,payout_type,timestamp:now});let mut found=false;for j in 0..members.len(){let mut m2=members.get(j).ok_or(CircleError::VecAccessError)?;if m2.address==m.address{m2.total_received=math::safe_add(m2.total_received,share).map_err(|_|CircleError::InvalidAmount)?;members.set(j,m2);found=true;}}}}}}if fee>0&&distributed>0{if let Some(treasury)=env.storage().instance().get::(&DataKey::Treasury){token_client.transfer(&circle.id,&treasury,&fee);}}if distributed=circle.total_rounds{circle.status=STATUS_COMPLETED;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Payouts,&payouts);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("payout")),PayoutExecuted{recipient,round,amount:distributed,fee,payout_type});if circle.status==STATUS_COMPLETED{env.events().publish((env.current_contract_address(),symbol_short!("complete")),CircleCompleted{total_payouts:circle.total_payouts});if circle.collateral_amount>0{for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{token_client.transfer(&circle.id,&m.address,&circle.collateral_amount);}}}let final_members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;for i in 0..final_members.len(){let m=final_members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{scoring::record_circle_completion(env,&m.address);}}}Ok(())} +pub fn trigger_payout(env:&Env,caller:&Address,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;let mut circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;let stored_admin:Address=env.storage().instance().get(&DataKey::Admin).ok_or(CircleError::NotInitialized)?;if caller!=&circle.organizer&&caller!=&stored_admin{return Err(CircleError::Unauthorized);}if circle.status!=STATUS_ACTIVE{return Err(CircleError::NotActive);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let(recipient,payout_type)=match circle.payout_type{PAYOUT_RANDOM=>(payout::resolve_random(env,&circle,round)?,PAYOUT_RANDOM),PAYOUT_FIXED=>(payout::resolve_fixed(env,&circle,round)?,PAYOUT_FIXED),PAYOUT_AUCTION=>{let(w,_)=payout::resolve_auction(env,&circle,round)?;(w,PAYOUT_AUCTION)}PAYOUT_VOTE=>(payout::resolve_vote(env,&circle,round)?,PAYOUT_VOTE),_=>return Err(CircleError::InvalidPayoutType)};let pool=math::safe_mul(circle.contribution_amount,circle.member_count as i128).map_err(|_|CircleError::InvalidAmount)?;let fee_bps:u32=env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0u32);let(net,fee)=math::apply_fee(pool,fee_bps as i128).map_err(|_|CircleError::InvalidAmount)?;let token_client=soroban_sdk::token::Client::new(env,&circle.token);let now=env.ledger().timestamp();let all_contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut total_weighted:u128=0;let mut member_weighted:Map=Map::new(env);for i in 0..all_contributions.len(){let c=all_contributions.get(i).ok_or(CircleError::VecAccessError)?;if c.round==round{let time_held=(now as u128).saturating_sub(c.time_weight as u128);let w=(c.amount as u128).saturating_mul(time_held);total_weighted=total_weighted.saturating_add(w);let prev=member_weighted.get(c.member.clone()).unwrap_or(0);member_weighted.set(c.member.clone(),prev.saturating_add(w));}}let mut payouts:Vec=env.storage().persistent().get(&DataKey::Payouts).unwrap_or_else(||Vec::new(env));let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut distributed:i128=0;let net_u=net as u128;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if let Some(w)=member_weighted.get(m.address.clone()){if total_weighted>0{let share=if distributed==0&&w==total_weighted{net}else{math::shares_to_amount(net_u,w,total_weighted).map_err(|_|CircleError::InvalidAmount)? as i128};if share>0{token_client.transfer(&circle.id,&m.address,&share);distributed=math::safe_add(distributed,share).map_err(|_|CircleError::InvalidAmount)?;payouts.push_back(PayoutRecipient{recipient:m.address.clone(),round,amount:share,fee:0,payout_type,timestamp:now});let mut found=false;for j in 0..members.len(){let mut m2=members.get(j).ok_or(CircleError::VecAccessError)?;if m2.address==m.address{m2.total_received=math::safe_add(m2.total_received,share).map_err(|_|CircleError::InvalidAmount)?;members.set(j,m2);found=true;}}}}}}if fee>0&&distributed>0{if let Some(treasury)=env.storage().instance().get::(&DataKey::Treasury){token_client.transfer(&circle.id,&treasury,&fee);}}if distributed=circle.total_rounds{circle.status=STATUS_COMPLETED;}env.storage().instance().set(&DataKey::Circle,&circle);env.storage().persistent().set(&DataKey::Payouts,&payouts);env.storage().persistent().set(&DataKey::Members,&members);env.events().publish((env.current_contract_address(),symbol_short!("payout")),PayoutExecuted{recipient,round,amount:distributed,fee,payout_type});if circle.status==STATUS_COMPLETED{env.events().publish((env.current_contract_address(),symbol_short!("complete")),CircleCompleted{total_payouts:circle.total_payouts});if circle.collateral_amount>0{for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{token_client.transfer(&circle.id,&m.address,&circle.collateral_amount);}}}let final_members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;for i in 0..final_members.len(){let m=final_members.get(i).ok_or(CircleError::NotInitialized)?;if m.status==MEMBER_ACTIVE{scoring::record_circle_completion(env,&m.address);}}}Ok(())} pub fn auction_bid(env:&Env,bidder:&Address,discount_bips:u32,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;bidder.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.payout_type!=PAYOUT_AUCTION{return Err(CircleError::InvalidPayoutType);}if discount_bips>10000{return Err(CircleError::InvalidBid);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let mut bids:Vec=env.storage().persistent().get(&DataKey::Bids).unwrap_or_else(||Vec::new(env));for i in 0..bids.len(){let b=bids.get(i).ok_or(CircleError::VecAccessError)?;if b.bidder==*bidder&&b.round==round{return Err(CircleError::AlreadyBidded);}}bids.push_back(AuctionBid{bidder:bidder.clone(),discount_bips,round,timestamp:env.ledger().timestamp()});env.storage().persistent().set(&DataKey::Bids,&bids);env.events().publish((env.current_contract_address(),symbol_short!("bid")),AuctionBidPlaced{bidder:bidder.clone(),discount_bips,round});Ok(())} pub fn vote_payout(env:&Env,voter:&Address,vote_for:&Address,round:u32)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;voter.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.payout_type!=PAYOUT_VOTE{return Err(CircleError::InvalidPayoutType);}if round!=circle.current_round{return Err(CircleError::RoundNotCurrent);}let members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut is_member=false;for i in 0..members.len(){let m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*voter{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}is_member=true;}}if!is_member{return Err(CircleError::NotMember);}let mut votes:Vec=env.storage().persistent().get(&DataKey::Votes).unwrap_or_else(||Vec::new(env));for i in 0..votes.len(){let v=votes.get(i).ok_or(CircleError::VecAccessError)?;if v.voter==*voter&&v.round==round{return Err(CircleError::AlreadyVoted);}}votes.push_back(VoteEntry{voter:voter.clone(),vote_for:vote_for.clone(),round,timestamp:env.ledger().timestamp()});env.storage().persistent().set(&DataKey::Votes,&votes);env.events().publish((env.current_contract_address(),symbol_short!("vote")),VoteCast{voter:voter.clone(),vote_for:vote_for.clone(),round});Ok(())} pub fn exit(env:&Env,member:&Address)->Result<(),CircleError>{pause::when_not_paused(env).map_err(|_|CircleError::ContractPaused)?;let _guard = ReentrancyGuard::new(env).map_err(|_| CircleError::NotActive)?;member.require_auth();let circle:Circle=env.storage().instance().get(&DataKey::Circle).ok_or(CircleError::NotInitialized)?;if circle.status==STATUS_COMPLETED{return Err(CircleError::NotActive);}let mut members:Vec=env.storage().persistent().get(&DataKey::Members).ok_or(CircleError::NotInitialized)?;let mut penalty:i128=0;for i in 0..members.len(){let mut m=members.get(i).ok_or(CircleError::VecAccessError)?;if m.address==*member{if m.status!=MEMBER_ACTIVE{return Err(CircleError::InvalidMemberStatus);}let contributions:Vec=env.storage().persistent().get(&DataKey::Contributions).unwrap_or_else(||Vec::new(env));let mut ctotal:i128=0;for j in 0..contributions.len(){let c=contributions.get(j).ok_or(CircleError::VecAccessError)?;if c.member==*member{ctotal=math::safe_add(ctotal,c.amount).map_err(|_|CircleError::InvalidAmount)?;}}penalty=math::calculate_percentage(ctotal,500).map_err(|_|CircleError::InvalidAmount)?;m.status=MEMBER_EXITED;m.exited_at=env.ledger().timestamp();members.set(i,m);}}env.storage().persistent().set(&DataKey::Members,&members);if circle.collateral_amount>0{let token_client=soroban_sdk::token::Client::new(env,&circle.token);token_client.transfer(&circle.id,member,&circle.collateral_amount);}env.events().publish((env.current_contract_address(),symbol_short!("exited")),MemberExited{member:member.clone(),penalty});Ok(())} diff --git a/packages/common/src/math.rs b/packages/common/src/math.rs index 62b3e45..e1a0b43 100644 --- a/packages/common/src/math.rs +++ b/packages/common/src/math.rs @@ -1,8 +1,242 @@ +#![cfg_attr(not(test), no_std)] + use soroban_sdk::contracterror; -#[contracterror] #[derive(Debug)] pub enum MathError { Overflow=1, Underflow=2, DivisionByZero=3 } -pub fn safe_add(a: i128, b: i128) -> Result { a.checked_add(b).ok_or(MathError::Overflow) } -pub fn safe_sub(a: i128, b: i128) -> Result { a.checked_sub(b).ok_or(MathError::Underflow) } -pub fn safe_mul(a: i128, b: i128) -> Result { a.checked_mul(b).ok_or(MathError::Overflow) } -pub fn safe_div(a: i128, b: i128) -> Result { if b == 0 { return Err(MathError::DivisionByZero); } a.checked_div(b).ok_or(MathError::Overflow) } -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)) } + +/// Typed arithmetic errors — never panic, always propagate. +#[contracterror] +#[derive(Debug)] +pub enum MathError { + Overflow = 1, + Underflow = 2, + DivisionByZero = 3, +} + +// --------------------------------------------------------------------------- +// i128 safe arithmetic +// --------------------------------------------------------------------------- + +pub fn safe_add(a: i128, b: i128) -> Result { + a.checked_add(b).ok_or(MathError::Overflow) +} + +pub fn safe_sub(a: i128, b: i128) -> Result { + a.checked_sub(b).ok_or(MathError::Underflow) +} + +pub fn safe_mul(a: i128, b: i128) -> Result { + a.checked_mul(b).ok_or(MathError::Overflow) +} + +/// Safe signed division. Explicitly guards against zero denominator so the +/// contract never panics regardless of caller-supplied inputs. +pub fn safe_div(a: i128, b: i128) -> Result { + if b == 0 { + return Err(MathError::DivisionByZero); + } + a.checked_div(b).ok_or(MathError::Overflow) +} + +// --------------------------------------------------------------------------- +// u128 safe arithmetic (used for weighted-share calculations in payout) +// --------------------------------------------------------------------------- + +pub fn safe_add_u128(a: u128, b: u128) -> Result { + a.checked_add(b).ok_or(MathError::Overflow) +} + +pub fn safe_mul_u128(a: u128, b: u128) -> Result { + a.checked_mul(b).ok_or(MathError::Overflow) +} + +/// Safe unsigned division. Returns `DivisionByZero` when `b == 0` rather +/// than panicking — critical for share-conversion paths where `total_shares` +/// could theoretically be zero due to rounding edge cases. +pub fn safe_div_u128(a: u128, b: u128) -> Result { + if b == 0 { + return Err(MathError::DivisionByZero); + } + Ok(a / b) +} + +// --------------------------------------------------------------------------- +// Share / weight conversion helpers +// --------------------------------------------------------------------------- + +/// Compute the pro-rata `amount` for a member given their weight out of the +/// total pool. +/// +/// Guards: +/// - `total_shares == 0` → `DivisionByZero` +/// - intermediate overflow in `net_pool * member_shares` → `Overflow` +/// +/// This is the canonical path for all weighted-payout arithmetic so that no +/// caller ever divides by zero in ad-hoc code. +pub fn shares_to_amount(net_pool: u128, member_shares: u128, total_shares: u128) -> Result { + if total_shares == 0 { + return Err(MathError::DivisionByZero); + } + let numerator = safe_mul_u128(net_pool, member_shares)?; + Ok(numerator / total_shares) +} + +// --------------------------------------------------------------------------- +// Percentage / fee helpers +// --------------------------------------------------------------------------- + +pub fn calculate_percentage(amount: i128, bps: i128) -> Result { + if bps < 0 || bps > 10_000 { + return Err(MathError::Overflow); + } + // safe_div already guards against bps == 0 (10_000 denominator is constant + // and non-zero, but we use the safe path for correctness). + 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)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // --- safe_div ----------------------------------------------------------- + + #[test] + fn test_safe_div_happy_path() { + assert_eq!(safe_div(100, 4), Ok(25)); + assert_eq!(safe_div(-100, 4), Ok(-25)); + assert_eq!(safe_div(0, 5), Ok(0)); + } + + #[test] + fn test_safe_div_by_zero_returns_error() { + assert_eq!(safe_div(100, 0), Err(MathError::DivisionByZero)); + assert_eq!(safe_div(0, 0), Err(MathError::DivisionByZero)); + assert_eq!(safe_div(-1, 0), Err(MathError::DivisionByZero)); + } + + #[test] + fn test_safe_div_overflow_boundary() { + // i128::MIN / -1 overflows in two's complement + assert_eq!(safe_div(i128::MIN, -1), Err(MathError::Overflow)); + } + + // --- safe_div_u128 ------------------------------------------------------ + + #[test] + fn test_safe_div_u128_happy_path() { + assert_eq!(safe_div_u128(100, 4), Ok(25)); + assert_eq!(safe_div_u128(0, 5), Ok(0)); + assert_eq!(safe_div_u128(u128::MAX, 1), Ok(u128::MAX)); + } + + #[test] + fn test_safe_div_u128_by_zero_returns_error() { + assert_eq!(safe_div_u128(1, 0), Err(MathError::DivisionByZero)); + assert_eq!(safe_div_u128(0, 0), Err(MathError::DivisionByZero)); + assert_eq!(safe_div_u128(u128::MAX, 0), Err(MathError::DivisionByZero)); + } + + // --- shares_to_amount --------------------------------------------------- + + #[test] + fn test_shares_to_amount_happy_path() { + // 3 members, equal weight → each gets 1/3 + assert_eq!(shares_to_amount(300, 100, 300), Ok(100)); + // 2 members, 3:1 weighting + assert_eq!(shares_to_amount(400, 300, 400), Ok(300)); + assert_eq!(shares_to_amount(400, 100, 400), Ok(100)); + } + + #[test] + fn test_shares_to_amount_zero_total_shares_is_error() { + // This is the guard for the rounding-edge-case described in the issue. + assert_eq!( + shares_to_amount(1_000_000, 50, 0), + Err(MathError::DivisionByZero) + ); + } + + #[test] + fn test_shares_to_amount_zero_member_shares() { + // Member with zero weight receives nothing (not an error). + assert_eq!(shares_to_amount(1_000_000, 0, 1_000_000), Ok(0)); + } + + #[test] + fn test_shares_to_amount_overflow_on_numerator() { + // net_pool × member_shares overflows u128 + assert_eq!( + shares_to_amount(u128::MAX, u128::MAX, u128::MAX), + Err(MathError::Overflow) + ); + } + + #[test] + fn test_shares_to_amount_single_member_full_pool() { + // One member with all shares → receives full pool + assert_eq!(shares_to_amount(500_000_000, 1, 1), Ok(500_000_000)); + } + + // --- safe_add / safe_sub / safe_mul ------------------------------------- + + #[test] + fn test_safe_add_overflow() { + assert_eq!(safe_add(i128::MAX, 1), Err(MathError::Overflow)); + } + + #[test] + fn test_safe_sub_underflow() { + assert_eq!(safe_sub(i128::MIN, 1), Err(MathError::Underflow)); + } + + #[test] + fn test_safe_mul_overflow() { + assert_eq!(safe_mul(i128::MAX, 2), Err(MathError::Overflow)); + } + + // --- safe_div_u128 edge cases ------------------------------------------- + + #[test] + fn test_safe_div_u128_truncates_remainder() { + // u128 div is truncating (floor for unsigned) + assert_eq!(safe_div_u128(7, 2), Ok(3)); + } + + // --- calculate_percentage ----------------------------------------------- + + #[test] + fn test_calculate_percentage_happy_path() { + assert_eq!(calculate_percentage(1_000, 500), Ok(50)); // 5% + assert_eq!(calculate_percentage(1_000, 0), Ok(0)); // 0% + assert_eq!(calculate_percentage(1_000, 10_000), Ok(1_000)); // 100% + } + + #[test] + fn test_calculate_percentage_invalid_bps() { + assert_eq!(calculate_percentage(1_000, -1), Err(MathError::Overflow)); + assert_eq!(calculate_percentage(1_000, 10_001), Err(MathError::Overflow)); + } + + // --- apply_fee ---------------------------------------------------------- + + #[test] + fn test_apply_fee_happy_path() { + let (net, fee) = apply_fee(1_000, 500).unwrap(); // 5% fee + assert_eq!(fee, 50); + assert_eq!(net, 950); + } + + #[test] + fn test_apply_fee_zero_fee() { + let (net, fee) = apply_fee(1_000, 0).unwrap(); + assert_eq!(fee, 0); + assert_eq!(net, 1_000); + } +}