Skip to content
Open
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
2 changes: 2 additions & 0 deletions contract/contract/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ pub enum CrowdfundingError {
PoolAlreadyClosed = 45,
PoolNotDisbursedOrRefunded = 46,
InsufficientFees = 47,
/// Basis points must be in the range [0, 10_000] (i.e. 0–100 %)
InvalidFeeBasisPoints = 48,
}
150 changes: 150 additions & 0 deletions contract/contract/src/base/fees.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use crate::base::errors::CrowdfundingError;

/// One hundred percent expressed in basis points.
pub const MAX_BASIS_POINTS: u32 = 10_000;

/// Calculate a platform fee using basis points.
///
/// # Arguments
/// * `amount` – The donation / contribution amount (in the token's smallest unit).
/// * `basis_points` – Fee rate in basis points, e.g. `250` = 2.50 %.
/// Must be in the range `[0, 10_000]`.
///
/// # Returns
/// The fee portion that should be retained by the platform, rounded down.
///
/// # Errors
/// Returns [`CrowdfundingError::InvalidFeeBasisPoints`] when `basis_points > 10_000`.
/// Returns [`CrowdfundingError::InvalidAmount`] when `amount < 0`.
///
/// # Overflow safety
/// The internal multiplication is performed using `i128::checked_mul` so the
/// function never panics even for the maximum `i128` donation value.
pub fn calculate_platform_fee(
amount: i128,
basis_points: u32,
) -> Result<i128, CrowdfundingError> {
if amount < 0 {
return Err(CrowdfundingError::InvalidAmount);
}

if basis_points > MAX_BASIS_POINTS {
return Err(CrowdfundingError::InvalidFeeBasisPoints);
}

// Zero fee short-circuit – avoids any arithmetic entirely.
if basis_points == 0 || amount == 0 {
return Ok(0);
}

// Overflow-safe path:
// fee = amount * basis_points / 10_000
//
// `i128::MAX * 10_000` overflows i128 (i128::MAX ≈ 1.7 × 10^38, and
// 10_000 * 1.7 × 10^38 > i128::MAX), so we use checked_mul and fall
// back to a wide-integer division when the intermediate value would
// overflow.
let fee = match amount.checked_mul(basis_points as i128) {
Some(product) => product / MAX_BASIS_POINTS as i128,
None => {
// amount is too large to multiply directly – divide first to keep
// values in range, then multiply. This loses at most
// (MAX_BASIS_POINTS - 1) units of precision but never overflows.
(amount / MAX_BASIS_POINTS as i128) * basis_points as i128
}
};

Ok(fee)
}

#[cfg(test)]
mod unit_tests {
use super::*;

// ── happy-path ────────────────────────────────────────────────────────

#[test]
fn zero_fee_on_zero_bps() {
assert_eq!(calculate_platform_fee(1_000_000, 0).unwrap(), 0);
}

#[test]
fn zero_fee_on_zero_amount() {
assert_eq!(calculate_platform_fee(0, 250).unwrap(), 0);
}

#[test]
fn two_and_a_half_percent_small_amount() {
// 250 bps on 1_000 → 25
assert_eq!(calculate_platform_fee(1_000, 250).unwrap(), 25);
}

#[test]
fn two_and_a_half_percent_large_amount() {
// 250 bps on 10_000_000_000 → 250_000_000
assert_eq!(
calculate_platform_fee(10_000_000_000, 250).unwrap(),
250_000_000
);
}

#[test]
fn one_percent() {
// 100 bps on 5_000 → 50
assert_eq!(calculate_platform_fee(5_000, 100).unwrap(), 50);
}

#[test]
fn full_hundred_percent() {
// 10_000 bps (100%) on 888 → 888
assert_eq!(calculate_platform_fee(888, 10_000).unwrap(), 888);
}

#[test]
fn flooring_behaviour() {
// 300 bps on 1 → floor(0.03) = 0
assert_eq!(calculate_platform_fee(1, 300).unwrap(), 0);
// 5000 bps on 1 → floor(0.5) = 0
assert_eq!(calculate_platform_fee(1, 5_000).unwrap(), 0);
}

#[test]
fn max_i128_amount_does_not_overflow() {
// i128::MAX with 250 bps – should not panic.
let result = calculate_platform_fee(i128::MAX, 250);
assert!(result.is_ok(), "should not overflow for i128::MAX");
// fee must be positive and strictly less than the amount
let fee = result.unwrap();
assert!(fee > 0);
assert!(fee < i128::MAX);
}

#[test]
fn large_realistic_amount() {
// 1 billion XLM in stroops (1 XLM = 10^7 stroops) at 250 bps
// 1_000_000_000 × 10^7 = 10^16 stroops
let one_billion_xlm_stroops: i128 = 10_000_000_000_000_000;
let fee = calculate_platform_fee(one_billion_xlm_stroops, 250).unwrap();
assert_eq!(fee, 250_000_000_000_000); // 2.5%
}

// ── error cases ───────────────────────────────────────────────────────

#[test]
fn rejects_negative_amount() {
let err = calculate_platform_fee(-1, 250).unwrap_err();
assert_eq!(err, CrowdfundingError::InvalidAmount);
}

#[test]
fn rejects_basis_points_over_ten_thousand() {
let err = calculate_platform_fee(1_000, 10_001).unwrap_err();
assert_eq!(err, CrowdfundingError::InvalidFeeBasisPoints);
}

#[test]
fn rejects_extreme_basis_points() {
let err = calculate_platform_fee(1_000, u32::MAX).unwrap_err();
assert_eq!(err, CrowdfundingError::InvalidFeeBasisPoints);
}
}
1 change: 1 addition & 0 deletions contract/contract/src/base/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod errors;
pub mod events;
pub mod fees;
pub mod types;
19 changes: 19 additions & 0 deletions contract/contract/src/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,25 @@ impl CrowdfundingTrait for CrowdfundingContract {
Ok(())
}


/// Compute the platform fee for `amount` at the given `basis_points` rate.
///
/// This is a **pure, read-only** function — it does not write any state.
/// Clients (frontend, other contracts) can call it to preview the fee that
/// will be deducted before crediting a pool.
///
/// # Examples
/// ```text
/// 250 bps on 10_000 stroops → 25 stroops (2.5 %)
/// 100 bps on 5_000 stroops → 50 stroops (1.0 %)
/// ```
fn calculate_platform_fee(
_env: Env,
amount: i128,
basis_points: u32,
) -> Result<i128, CrowdfundingError> {
crate::base::fees::calculate_platform_fee(amount, basis_points)

fn set_emergency_contact(env: Env, contact: Address) -> Result<(), CrowdfundingError> {
let admin: Address = env
.storage()
Expand Down
13 changes: 13 additions & 0 deletions contract/contract/src/interfaces/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ pub trait CrowdfundingTrait {
amount: i128,
) -> Result<(), CrowdfundingError>;


/// Calculate the platform fee for a given `amount` using basis points.
///
/// * `amount` – raw token amount (must be ≥ 0)
/// * `basis_points` – fee rate in bps; 250 = 2.50 % (must be ≤ 10 000)
///
/// Returns the fee to be retained, or an error if inputs are invalid.
fn calculate_platform_fee(
env: Env,
amount: i128,
basis_points: u32,
) -> Result<i128, CrowdfundingError>;

fn set_emergency_contact(env: Env, contact: Address) -> Result<(), CrowdfundingError>;

fn get_emergency_contact(env: Env) -> Result<Address, CrowdfundingError>;
Expand Down
2 changes: 2 additions & 0 deletions contract/contract/test/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod close_pool_test;
mod create_pool;
mod crowdfunding_test;

mod platform_fee_test;
mod renounce_admin_test;
mod verify_cause;
128 changes: 128 additions & 0 deletions contract/contract/test/platform_fee_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/// Integration tests for `calculate_platform_fee` via the Soroban contract client.
///
/// Unlike the unit tests in `base/fees.rs`, these tests exercise the full
/// contract dispatch path — i.e. they call through `CrowdfundingContractClient`
/// the same way a real off-chain caller would.
#[cfg(test)]
mod tests {
use soroban_sdk::Env;
use crate::{CrowdfundingContract, CrowdfundingContractClient};
use crate::base::errors::CrowdfundingError;

/// Spin up a minimal test environment and return a client.
/// `calculate_platform_fee` is pure and does not touch storage, so we
/// only need to register the contract — no `initialize` call is required.
fn setup(env: &Env) -> CrowdfundingContractClient<'_> {
env.mock_all_auths();
let contract_id = env.register(CrowdfundingContract, ());
CrowdfundingContractClient::new(env, &contract_id)
}

// ── happy-path ────────────────────────────────────────────────────────

#[test]
fn two_and_half_percent_small() {
let env = Env::default();
let client = setup(&env);
// 250 bps on 1_000 → 25
assert_eq!(client.calculate_platform_fee(&1_000, &250), 25);
}

#[test]
fn two_and_half_percent_large() {
let env = Env::default();
let client = setup(&env);
// 250 bps on 10_000_000_000 → 250_000_000
assert_eq!(
client.calculate_platform_fee(&10_000_000_000, &250),
250_000_000
);
}

#[test]
fn one_percent() {
let env = Env::default();
let client = setup(&env);
// 100 bps on 5_000 → 50
assert_eq!(client.calculate_platform_fee(&5_000, &100), 50);
}

#[test]
fn zero_bps_yields_zero_fee() {
let env = Env::default();
let client = setup(&env);
assert_eq!(client.calculate_platform_fee(&999_999, &0), 0);
}

#[test]
fn zero_amount_yields_zero_fee() {
let env = Env::default();
let client = setup(&env);
assert_eq!(client.calculate_platform_fee(&0, &250), 0);
}

#[test]
fn full_hundred_percent() {
let env = Env::default();
let client = setup(&env);
// 10_000 bps = 100% → fee == amount
assert_eq!(client.calculate_platform_fee(&888, &10_000), 888);
}

#[test]
fn no_overflow_on_large_amount() {
let env = Env::default();
let client = setup(&env);
// i128::MAX with 250 bps – must not panic and must be > 0
let fee = client.calculate_platform_fee(&i128::MAX, &250);
assert!(fee > 0);
assert!(fee < i128::MAX);
}

#[test]
fn realistic_xlm_stroop_amount() {
let env = Env::default();
let client = setup(&env);
// 1 billion XLM in stroops at 250 bps
let billion_xlm: i128 = 10_000_000_000_000_000;
assert_eq!(
client.calculate_platform_fee(&billion_xlm, &250),
250_000_000_000_000 // 2.5%
);
}

// ── error cases ───────────────────────────────────────────────────────

#[test]
fn rejects_negative_amount() {
let env = Env::default();
let client = setup(&env);
let result = client.try_calculate_platform_fee(&-1, &250);
assert_eq!(
result.unwrap_err().unwrap(),
CrowdfundingError::InvalidAmount
);
}

#[test]
fn rejects_bps_over_ten_thousand() {
let env = Env::default();
let client = setup(&env);
let result = client.try_calculate_platform_fee(&1_000, &10_001);
assert_eq!(
result.unwrap_err().unwrap(),
CrowdfundingError::InvalidFeeBasisPoints
);
}

#[test]
fn rejects_extreme_bps() {
let env = Env::default();
let client = setup(&env);
let result = client.try_calculate_platform_fee(&1_000, &u32::MAX);
assert_eq!(
result.unwrap_err().unwrap(),
CrowdfundingError::InvalidFeeBasisPoints
);
}
}
Loading