Skip to content
Merged
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
68 changes: 57 additions & 11 deletions contracts/chainmove-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub enum ContractError {
RepayerMismatch = 12,
NothingToRefund = 13,
RefundTooSmall = 14,
InvestmentTooSmall = 15,
}

#[contracttype]
Expand Down Expand Up @@ -70,6 +71,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 {
Expand Down Expand Up @@ -223,7 +236,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);
Expand Down Expand Up @@ -284,12 +297,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)
}
Expand Down Expand Up @@ -723,20 +737,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<u64, ContractError> {
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<u64, ContractError> {
// 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(
Expand Down
57 changes: 56 additions & 1 deletion contracts/chainmove-pool/src/test.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading