Skip to content
Closed
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
122 changes: 78 additions & 44 deletions remittance_split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,33 +653,11 @@ impl RemittanceSplit {
env: Env,
total_amount: i128,
) -> Result<Vec<i128>, RemittanceSplitError> {
if total_amount <= 0 {
return Err(RemittanceSplitError::InvalidAmount);
}

let split = Self::get_split(&env);
let s0 = split.get(0).unwrap() as i128;
let s1 = split.get(1).unwrap() as i128;
let s2 = split.get(2).unwrap() as i128;

let spending = total_amount
.checked_mul(s0)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
let savings = total_amount
.checked_mul(s1)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
let bills = total_amount
.checked_mul(s2)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
// Insurance gets the remainder to handle rounding
let insurance = total_amount
.checked_sub(spending)
.and_then(|n| n.checked_sub(savings))
.and_then(|n| n.checked_sub(bills))
.ok_or(RemittanceSplitError::Overflow)?;
let amounts = Self::compute_split_amounts(&env, total_amount)?;
let spending = amounts[0];
let savings = amounts[1];
let bills = amounts[2];
let insurance = amounts[3];

// Emit SplitCalculated event

Expand Down Expand Up @@ -1348,6 +1326,38 @@ impl RemittanceSplit {
env: &Env,
total_amount: i128,
emit_events: bool,
) -> Result<[i128; 4], RemittanceSplitError> {
let amounts = Self::compute_split_amounts(env, total_amount)?;
let spending = amounts[0];
let savings = amounts[1];
let bills = amounts[2];
let insurance = amounts[3];

if emit_events {
let event = SplitCalculatedEvent {
total_amount,
spending_amount: spending,
savings_amount: savings,
bills_amount: bills,
insurance_amount: insurance,
timestamp: env.ledger().timestamp(),
};
env.events().publish((SPLIT_CALCULATED,), event);
env.events().publish(
(symbol_short!("split"), SplitEvent::Calculated),
total_amount,
);
}

Ok([spending, savings, bills, insurance])
}

/// @notice Compute deterministic split amounts with fair remainder distribution.
/// @dev Uses the largest-remainder method with a fixed tie-break order:
/// spending, savings, bills, then insurance. Ensures sum == total_amount.
fn compute_split_amounts(
env: &Env,
total_amount: i128,
) -> Result<[i128; 4], RemittanceSplitError> {
if total_amount <= 0 {
return Err(RemittanceSplitError::InvalidAmount);
Expand All @@ -1366,24 +1376,39 @@ impl RemittanceSplit {
Some(v) => v as i128,
None => return Err(RemittanceSplitError::Overflow),
};
let s3 = match split.get(3) {
Some(v) => v as i128,
None => return Err(RemittanceSplitError::Overflow),
};

let spending = total_amount
.checked_mul(s0)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
let savings = total_amount
.checked_mul(s1)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
let bills = total_amount
.checked_mul(s2)
.and_then(|n| n.checked_div(100))
.ok_or(RemittanceSplitError::Overflow)?;
let insurance = total_amount
.checked_sub(spending)
.and_then(|n| n.checked_sub(savings))
.and_then(|n| n.checked_sub(bills))
let percents = [s0, s1, s2, s3];
let mut base = [0i128; 4];
let mut remainders = [0i128; 4];
let mut base_sum: i128 = 0;

for i in 0..4 {
let raw = total_amount
.checked_mul(percents[i])
.ok_or(RemittanceSplitError::Overflow)?;
let portion = raw
.checked_div(100)
.ok_or(RemittanceSplitError::Overflow)?;
let remainder = raw
.checked_rem(100)
.ok_or(RemittanceSplitError::Overflow)?;
base[i] = portion;
remainders[i] = remainder;
base_sum = base_sum
.checked_add(portion)
.ok_or(RemittanceSplitError::Overflow)?;
}

let mut remainder_units = total_amount
.checked_sub(base_sum)
.ok_or(RemittanceSplitError::Overflow)?;
if remainder_units < 0 || remainder_units > 4 {
return Err(RemittanceSplitError::Overflow);
}

if emit_events {
let event = SplitCalculatedEvent {
Expand All @@ -1410,7 +1435,16 @@ impl RemittanceSplit {
);
}

Ok([spending, savings, bills, insurance])
let total = out[0]
.checked_add(out[1])
.and_then(|n| n.checked_add(out[2]))
.and_then(|n| n.checked_add(out[3]))
.ok_or(RemittanceSplitError::Overflow)?;
if total != total_amount {
return Err(RemittanceSplitError::Overflow);
}

Ok(out)
}

/// Extend the TTL of instance storage
Expand Down
27 changes: 16 additions & 11 deletions remittance_split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,12 @@ fn test_calculate_split_rounding() {
let token_admin = Address::generate(&env);
let token_id = setup_token(&env, &token_admin, &owner, 0);

client.initialize_split(&owner, &0, &token_id, &33, &33, &33, &1);
let amounts = client.calculate_split(&100);
let sum: i128 = amounts.iter().sum();
assert_eq!(sum, 100);
client.initialize_split(&owner, &0, &token_id, &50, &30, &15, &5);
let amounts = client.calculate_split(&101);
assert_eq!(amounts.get(0).unwrap(), 51);
assert_eq!(amounts.get(1).unwrap(), 30);
assert_eq!(amounts.get(2).unwrap(), 15);
assert_eq!(amounts.get(3).unwrap(), 5);
}

#[test]
Expand Down Expand Up @@ -808,27 +810,30 @@ fn test_distribute_usdc_split_math_100_0_0_0() {
}

#[test]
fn test_distribute_usdc_rounding_remainder_goes_to_insurance() {
fn test_distribute_usdc_rounding_remainder_deterministic() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, RemittanceSplit);
let client = RemittanceSplitClient::new(&env, &contract_id);
let owner = Address::generate(&env);
let token_admin = Address::generate(&env);
// 33/33/33/1 with amount=100: 33+33+33=99, insurance gets remainder=1
let token_id = setup_token(&env, &token_admin, &owner, 100);
// 50/30/15/5 with amount=101: spending gets remainder by highest fractional part
let token_id = setup_token(&env, &token_admin, &owner, 101);

client.initialize_split(&owner, &0, &token_id, &33, &33, &33, &1);
client.initialize_split(&owner, &0, &token_id, &50, &30, &15, &5);
let accounts = make_accounts(&env);
client.distribute_usdc(&token_id, &owner, &1, &accounts, &100);
client.distribute_usdc(&token_id, &owner, &1, &accounts, &101);

let token = TokenClient::new(&env, &token_id);
let total = token.balance(&accounts.spending)
+ token.balance(&accounts.savings)
+ token.balance(&accounts.bills)
+ token.balance(&accounts.insurance);
assert_eq!(total, 100, "all funds must be distributed");
assert_eq!(token.balance(&accounts.insurance), 1);
assert_eq!(total, 101, "all funds must be distributed");
assert_eq!(token.balance(&accounts.spending), 51);
assert_eq!(token.balance(&accounts.savings), 30);
assert_eq!(token.balance(&accounts.bills), 15);
assert_eq!(token.balance(&accounts.insurance), 5);
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading