From 3a5799479e160fc54d3bf6eb116ac6e378d83425 Mon Sep 17 00:00:00 2001 From: Pri_ss_ca <136065253+prissca@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:07:24 +0100 Subject: [PATCH] Use fair cumulative pool unit rounding --- contracts/chainmove-pool/src/lib.rs | 68 +++++++++++++++++++++++----- contracts/chainmove-pool/src/test.rs | 57 ++++++++++++++++++++++- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/contracts/chainmove-pool/src/lib.rs b/contracts/chainmove-pool/src/lib.rs index fa9e705d..d3b5fb71 100644 --- a/contracts/chainmove-pool/src/lib.rs +++ b/contracts/chainmove-pool/src/lib.rs @@ -24,6 +24,7 @@ pub enum ContractError { Overpayment = 11, RepayerMismatch = 12, NothingToRefund = 13, + InvestmentTooSmall = 14, } #[contracttype] @@ -68,6 +69,18 @@ pub struct TransitionEvent { pub post_total_repaid: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitAllocationEvent { + pub version: u32, + pub pool_id: u64, + pub investor: Address, + pub invested_amount: i128, + pub allocated_units: u64, + pub post_funded_units: u64, + pub cumulative_remainder_numerator: i128, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] enum OperationKind { @@ -203,7 +216,7 @@ impl ChainMovePoolContract { return Err(ContractError::Oversubscribed); } - let units = allocate_units(&pool, amount, new_total)?; + let units = allocate_units(&pool, new_total)?; let new_units = checked_add_u64(pool.funded_units, units)?; if new_units > pool.total_units { return Err(ContractError::Oversubscribed); @@ -250,12 +263,13 @@ impl ChainMovePoolContract { amount, pool_id, investor.clone(), - investor, + investor.clone(), reference, pool.funded_units, pool.total_invested, pool.total_repaid, ); + publish_unit_allocation(&env, &pool, investor, amount, units)?; Ok(position) } @@ -624,20 +638,52 @@ fn write_reference( env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); } -fn allocate_units(pool: &Pool, amount: i128, new_total: i128) -> Result { - if new_total == pool.target_amount { - return checked_sub_u64(pool.total_units, pool.funded_units); - } - - let unit_amount = amount +fn allocate_units(pool: &Pool, new_total: i128) -> Result { + // Cumulative largest-remainder accounting: each transition advances the + // pool to floor(total_invested * total_units / target). This carries the + // fractional numerator forward instead of gifting all dust to the final + // funder. Any two equal contributions can differ by at most one unit. + let cumulative_units = new_total .checked_mul(pool.total_units as i128) .ok_or(ContractError::ArithmeticOverflow)? .checked_div(pool.target_amount) .ok_or(ContractError::ArithmeticOverflow)?; - if unit_amount <= 0 { - return Err(ContractError::InvalidInput); + let cumulative_units = u64::try_from(cumulative_units) + .map_err(|_| ContractError::ArithmeticOverflow)?; + let unit_amount = checked_sub_u64(cumulative_units, pool.funded_units)?; + if unit_amount == 0 { + // The minimum depends on the carried remainder; callers can retry with + // enough principal to advance the cumulative entitlement by one unit. + return Err(ContractError::InvestmentTooSmall); } - u64::try_from(unit_amount).map_err(|_| ContractError::ArithmeticOverflow) + Ok(unit_amount) +} + +#[allow(deprecated)] +fn publish_unit_allocation( + env: &Env, + pool: &Pool, + investor: Address, + invested_amount: i128, + allocated_units: u64, +) -> Result<(), ContractError> { + let numerator = pool + .total_invested + .checked_mul(pool.total_units as i128) + .ok_or(ContractError::ArithmeticOverflow)?; + env.events().publish( + (Symbol::new(env, "chainmove_pool_v1"), Symbol::new(env, "units_allocated_v1")), + UnitAllocationEvent { + version: 1, + pool_id: pool.id, + investor, + invested_amount, + allocated_units, + post_funded_units: pool.funded_units, + cumulative_remainder_numerator: numerator % pool.target_amount, + }, + ); + Ok(()) } fn release_units(position: &InvestorPosition, amount: i128) -> Result { diff --git a/contracts/chainmove-pool/src/test.rs b/contracts/chainmove-pool/src/test.rs index 2da910aa..1292915c 100644 --- a/contracts/chainmove-pool/src/test.rs +++ b/contracts/chainmove-pool/src/test.rs @@ -1,6 +1,6 @@ extern crate std; -use super::{ChainMovePoolContract, ChainMovePoolContractClient, ContractError}; +use super::{allocate_units, ChainMovePoolContract, ChainMovePoolContractClient, ContractError, Pool}; use soroban_sdk::{testutils::Address as _, token, Address, Env, String}; const POOL_ID: u64 = 1; @@ -132,6 +132,61 @@ fn funding_transfers_tokens_into_contract_custody() { assert_eq!(token.balance(&fixture.investor), 7_500); } +#[test] +fn cumulative_rounding_is_bounded_and_conserves_all_units() { + let env = Env::default(); + let owner = Address::generate(&env); + let repayer = Address::generate(&env); + let asset = Address::generate(&env); + let base = Pool { + id: 7, + owner, + repayer, + asset, + asset_label: String::from_str(&env, "awkward-ratio"), + total_units: 3, + funded_units: 0, + target_amount: 10, + total_invested: 0, + total_repaid: 0, + active: true, + }; + + let allocate_order = |amounts: [i128; 2]| { + let mut pool = base.clone(); + let mut result = [0_u64; 2]; + for (index, amount) in amounts.iter().enumerate() { + let new_total = pool.total_invested + amount; + let units = allocate_units(&pool, new_total).unwrap(); + pool.total_invested = new_total; + pool.funded_units += units; + result[index] = units; + } + (result, pool.funded_units) + }; + + let (forward, forward_total) = allocate_order([4, 6]); + let (reverse, reverse_total) = allocate_order([6, 4]); + assert!(forward[0].abs_diff(reverse[1]) <= 1); + assert!(forward[1].abs_diff(reverse[0]) <= 1); + assert_eq!(forward_total, 3); + assert_eq!(reverse_total, 3); +} + +#[test] +fn funding_below_the_current_unit_granularity_is_explicitly_rejected() { + let fixture = create_fixture(); + approve(&fixture, &fixture.investor, 1); + let result = pool_client(&fixture).try_fund_pool( + &fixture.investor, + &POOL_ID, + &fixture.asset, + &1, + &String::from_str(&fixture.env, "fund-dust"), + ); + assert_eq!(result.unwrap_err().unwrap(), ContractError::InvestmentTooSmall); +} + #[test] fn full_lifecycle_funds_closes_and_records_repayment() { let fixture = create_fixture();