From 06f34ebf5905860f926301d74bed71c8047b32f1 Mon Sep 17 00:00:00 2001 From: BABAT-CODE Date: Mon, 3 Aug 2026 15:32:26 +0000 Subject: [PATCH] fix(security): guard all share-conversion divisions against zero denominators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a zero-division vulnerability reported in common/src/math.rs where dividing by total_shares/total_assets without a zero check would cause an on-chain panic (contract abort) if the pool hits an empty-share edge case. Changes: - common/src/math.rs: add shares_to_assets() and assets_to_shares() helpers with explicit zero-checks on total_shares / total_assets denominators respectively; add 20 unit tests covering zero denominator, zero numerator, normal, proportional, and overflow cases for both helpers plus safe_div, calculate_percentage, and apply_fee - governance/src/contract.rs: replace raw `/ decisive` in finalize_proposal with math::safe_div(); add `use common::math` import; extract intermediate votes_for_scaled binding for readability; the decisive > 0 short-circuit was already present but relying on evaluation order alone is fragile — safe_div makes the invariant explicit and returns a typed MathError - circle/src/contract.rs: replace raw `/ total_weighted` (u128) in trigger_payout weighted-share loop with checked_div().unwrap_or(0); the outer `total_weighted > 0` guard prevents a zero denominator in practice but the raw division was an unguarded foot-gun — checked_div makes intent explicit and eliminates the panic path entirely --- packages/circle/src/contract.rs | 2 +- packages/common/src/math.rs | 240 +++++++++++++++++++++++++++- packages/governance/src/contract.rs | 10 +- 3 files changed, 242 insertions(+), 10 deletions(-) diff --git a/packages/circle/src/contract.rs b/packages/circle/src/contract.rs index ea37869..7caf42d 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{(net_u.saturating_mul(w).checked_div(total_weighted).unwrap_or(0))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..05c0c4a 100644 --- a/packages/common/src/math.rs +++ b/packages/common/src/math.rs @@ -1,8 +1,234 @@ 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)) } + +#[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)) +} + +/// Convert a number of shares into the equivalent asset amount. +/// +/// Formula: `assets = (shares * total_assets) / total_shares` +/// +/// Returns `Err(MathError::DivisionByZero)` if `total_shares == 0`, which +/// would otherwise cause an on-chain panic (contract abort). Callers must +/// treat this as "the pool is empty — no conversion possible" and surface +/// it via a typed contract error rather than propagating a panic. +pub fn shares_to_assets( + shares: i128, + total_shares: i128, + total_assets: i128, +) -> Result { + if total_shares == 0 { + return Err(MathError::DivisionByZero); + } + safe_div(safe_mul(shares, total_assets)?, total_shares) +} + +/// Convert an asset amount into the equivalent number of shares. +/// +/// Formula: `shares = (assets * total_shares) / total_assets` +/// +/// Returns `Err(MathError::DivisionByZero)` if `total_assets == 0`. On first +/// deposit (empty pool), the caller should mint shares 1:1 with assets instead +/// of calling this function — dividing by zero total assets is a protocol +/// invariant violation, not just a math edge case. +pub fn assets_to_shares( + assets: i128, + total_shares: i128, + total_assets: i128, +) -> Result { + if total_assets == 0 { + return Err(MathError::DivisionByZero); + } + safe_div(safe_mul(assets, total_shares)?, total_assets) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── shares_to_assets ──────────────────────────────────────────────────── + + #[test] + fn shares_to_assets_normal() { + // 50 shares out of 100 total, 200 total assets → 100 assets + assert_eq!(shares_to_assets(50, 100, 200), Ok(100)); + } + + #[test] + fn shares_to_assets_all_shares() { + // Holder of all shares gets all assets + assert_eq!(shares_to_assets(1000, 1000, 5000), Ok(5000)); + } + + #[test] + fn shares_to_assets_zero_shares_arg() { + // 0 shares → 0 assets (pool is non-empty) + assert_eq!(shares_to_assets(0, 100, 200), Ok(0)); + } + + #[test] + fn shares_to_assets_zero_total_shares() { + // Empty pool — denominator is zero, must NOT panic + assert_eq!( + shares_to_assets(10, 0, 200), + Err(MathError::DivisionByZero) + ); + } + + #[test] + fn shares_to_assets_zero_total_assets() { + // Pool exists (shares minted) but holds no assets — valid state, yields 0 + assert_eq!(shares_to_assets(50, 100, 0), Ok(0)); + } + + #[test] + fn shares_to_assets_overflow() { + // shares * total_assets overflows i128 + assert_eq!( + shares_to_assets(i128::MAX, i128::MAX, i128::MAX), + Err(MathError::Overflow) + ); + } + + // ── assets_to_shares ──────────────────────────────────────────────────── + + #[test] + fn assets_to_shares_normal() { + // Depositing 100 assets into a pool with 1000 shares and 200 assets + // → 100 * 1000 / 200 = 500 shares + assert_eq!(assets_to_shares(100, 1000, 200), Ok(500)); + } + + #[test] + fn assets_to_shares_proportional() { + // 1:1 pool — shares always equal assets + assert_eq!(assets_to_shares(250, 1000, 1000), Ok(250)); + } + + #[test] + fn assets_to_shares_zero_assets_arg() { + assert_eq!(assets_to_shares(0, 1000, 500), Ok(0)); + } + + #[test] + fn assets_to_shares_zero_total_assets() { + // Empty-asset pool — must NOT panic, caller should mint 1:1 instead + assert_eq!( + assets_to_shares(100, 1000, 0), + Err(MathError::DivisionByZero) + ); + } + + #[test] + fn assets_to_shares_zero_total_shares() { + // No shares minted yet → result is 0 (0 * anything / denom = 0) + assert_eq!(assets_to_shares(100, 0, 500), Ok(0)); + } + + #[test] + fn assets_to_shares_overflow() { + assert_eq!( + assets_to_shares(i128::MAX, i128::MAX, 1), + Err(MathError::Overflow) + ); + } + + // ── safe_div existing behaviour ───────────────────────────────────────── + + #[test] + fn safe_div_zero_denominator() { + assert_eq!(safe_div(10, 0), Err(MathError::DivisionByZero)); + } + + #[test] + fn safe_div_normal() { + assert_eq!(safe_div(100, 4), Ok(25)); + } + + // ── calculate_percentage ──────────────────────────────────────────────── + + #[test] + fn calculate_percentage_normal() { + // 5% of 1000 = 50 + assert_eq!(calculate_percentage(1000, 500), Ok(50)); + } + + #[test] + fn calculate_percentage_zero_bps() { + assert_eq!(calculate_percentage(1000, 0), Ok(0)); + } + + #[test] + fn calculate_percentage_full_bps() { + // 100% of 1000 = 1000 + assert_eq!(calculate_percentage(1000, 10_000), Ok(1000)); + } + + #[test] + fn calculate_percentage_negative_bps() { + assert_eq!( + calculate_percentage(1000, -1), + Err(MathError::Overflow) + ); + } + + #[test] + fn calculate_percentage_exceeds_bps() { + assert_eq!( + calculate_percentage(1000, 10_001), + Err(MathError::Overflow) + ); + } + + // ── apply_fee ──────────────────────────────────────────────────────────── + + #[test] + fn apply_fee_normal() { + // 0.5% fee on 1000 → fee=5, net=995 + let (net, fee) = apply_fee(1000, 50).unwrap(); + assert_eq!(fee, 5); + assert_eq!(net, 995); + } + + #[test] + fn apply_fee_zero_fee() { + let (net, fee) = apply_fee(1000, 0).unwrap(); + assert_eq!(fee, 0); + assert_eq!(net, 1000); + } +} diff --git a/packages/governance/src/contract.rs b/packages/governance/src/contract.rs index 416f090..328d9cf 100644 --- a/packages/governance/src/contract.rs +++ b/packages/governance/src/contract.rs @@ -1,6 +1,6 @@ use soroban_sdk::{Address,BytesN,Env,Val,Vec}; use crate::types::*; -use common::pause; +use common::{math,pause}; const BPS_DENOM:i128=10_000; @@ -89,7 +89,13 @@ pub fn finalize_proposal(env:&Env,proposal_id:u64)->Result<(),GovernanceError>{ let total_votes=proposal.votes_for+proposal.votes_against+proposal.votes_abstain; let quorum_met=total_votes>=config.quorum_votes as i128; let decisive=proposal.votes_for+proposal.votes_against; - let passed=quorum_met&&decisive>0&&proposal.votes_for.checked_mul(BPS_DENOM).ok_or(GovernanceError::InvalidConfig)?/decisive>=config.pass_threshold_bps as i128; + // Use safe_div to guard against a zero decisive denominator — a raw `/` + // would panic on-chain if both votes_for and votes_against are zero. + // The `decisive > 0` short-circuit prevents the division in practice, but + // relying on evaluation order for safety is fragile; safe_div makes the + // invariant explicit and returns a typed MathError if ever reached. + let votes_for_scaled=proposal.votes_for.checked_mul(BPS_DENOM).ok_or(GovernanceError::InvalidConfig)?; + let passed=quorum_met&&decisive>0&&math::safe_div(votes_for_scaled,decisive).map_err(|_|GovernanceError::InvalidConfig)?>=config.pass_threshold_bps as i128; if passed{ proposal.status=ProposalStatus::Succeeded; ProposalStatusChanged{id:proposal_id,status:ProposalStatus::Succeeded}.publish(env);