diff --git a/contracts/split/src/calc.rs b/contracts/split/src/calc.rs index de19239..a853c8b 100644 --- a/contracts/split/src/calc.rs +++ b/contracts/split/src/calc.rs @@ -8,6 +8,8 @@ use crate::types::BASIS_POINTS_TOTAL; use soroban_sdk::{Env, Vec}; +use crate::error::ContractError; + /// Distribute `total` among recipients according to their `ratios` out of /// `denom`, using the largest-remainder method to handle rounding. /// @@ -35,9 +37,13 @@ pub fn distribute_with_remainder( total: i128, ratios: &Vec, denom: i128, -) -> Vec { - assert!(!ratios.is_empty(), "ratios must not be empty"); - assert!(denom > 0, "denom must be positive"); +) -> Result, ContractError> { + if ratios.is_empty() { + return Err(ContractError::InvalidAmount); + } + if denom <= 0 { + return Err(ContractError::InvalidAmount); + } let n = ratios.len() as usize; @@ -59,7 +65,9 @@ pub fn distribute_with_remainder( // Contracts with more than 64 recipients would need a larger cap, but // 64 is a reasonable upper bound for on-chain use. const MAX_RECIPIENTS: usize = 64; - assert!(n <= MAX_RECIPIENTS, "too many recipients (max 64)"); + if n > MAX_RECIPIENTS { + return Err(ContractError::InvalidAmount); + } let mut indices = [0usize; MAX_RECIPIENTS]; for i in 0..n { @@ -95,7 +103,7 @@ pub fn distribute_with_remainder( shares_mut.set(idx, current + 1); } - shares_mut + Ok(shares_mut) } // --------------------------------------------------------------------------- @@ -169,7 +177,8 @@ mod tests { /// Assert sum equals total and return shares. fn assert_exact(env: &Env, total: i128, ratios: &[i128], denom: i128) -> Vec { let r_vec = make_ratios(env, ratios); - let result = distribute_with_remainder(env, total, &r_vec, denom); + let result = distribute_with_remainder(env, total, &r_vec, denom) + .expect("distribute_with_remainder should not fail for valid inputs"); let sum: i128 = result.iter().sum(); assert_eq!( sum, total, diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 72aff97..732f72f 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1060,15 +1060,30 @@ pub fn tranche_released(env: &Env, invoice_id: u64, tranche_index: u32, amount: /// Issue #349: Emitted when an address's reputation score is updated. /// Topics: (split, rep_upd, address) -/// Data: score -pub fn rep_updated(env: &Env, address: &Address, score: &RepScore) { +/// Data: (score_struct, computed_score) +/// +/// # Computed score formula +/// +/// The `computed_score` is a single `u32` summary of the raw `RepScore` +/// counters. The formula rewards consistent on-time payment and successful +/// invoice completion while penalising late payments and refunds: +/// +/// ```text +/// base = paid_on_time * 10 + invoices_released * 5 +/// deductions = late_pays * 5 + invoices_refunded * 2 +/// computed = base.saturating_sub(deductions) +/// ``` +/// +/// Indexers are encouraged to use `computed_score` directly rather than +/// re-implementing the formula off-chain. +pub fn rep_updated(env: &Env, address: &Address, score: &RepScore, computed_score: u32) { env.events().publish( ( symbol_short!("split"), symbol_short!("rep_upd"), address.clone(), ), - score.clone(), + (score.clone(), computed_score), ); } @@ -1625,7 +1640,10 @@ pub fn child_invoice_unblocked(env: &Env, child_id: u64, parent_id: u64) { /// Emitted every time a late-payment penalty is charged. /// /// Topics: `("late_pen", invoice_id)` -/// Data: `(payer, penalty_amount)` +/// Data: `(invoice_id, payer, penalty_amount)` +/// +/// `invoice_id` is included in both the topics (for indexer filtering) and +/// the data payload (for data-only decoders that do not inspect topics). #[allow(dead_code)] pub fn late_payment_penalty_charged( env: &Env, @@ -1635,7 +1653,7 @@ pub fn late_payment_penalty_charged( ) { env.events().publish( (symbol_short!("late_pen"), invoice_id), - (payer.clone(), penalty_amount), + (invoice_id, payer.clone(), penalty_amount), ); } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index cbaf26c..98be691 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -457,7 +457,17 @@ where let mut score = get_rep_internal(env, address); update_fn(&mut score); env.storage().persistent().set(&rep_key(address), &score); - events::rep_updated(env, address, &score); + // Compute the derived score integer before emitting the event. + // Formula: (paid_on_time * 10 + invoices_released * 5) + // .saturating_sub(late_pays * 5 + invoices_refunded * 2) + let base = (score.paid_on_time as u32) + .saturating_mul(10) + .saturating_add((score.invoices_released as u32).saturating_mul(5)); + let deductions = (score.late_pays as u32) + .saturating_mul(5) + .saturating_add((score.invoices_refunded as u32).saturating_mul(2)); + let computed_score = base.saturating_sub(deductions); + events::rep_updated(env, address, &score, computed_score); score } diff --git a/contracts/split/src/stats.rs b/contracts/split/src/stats.rs index 2f1a0fe..d239291 100644 --- a/contracts/split/src/stats.rs +++ b/contracts/split/src/stats.rs @@ -13,92 +13,6 @@ const TOTAL_VOLUME: &str = "stats_total_volume"; const TOTAL_RECIPIENTS_PAID: &str = "stats_total_recipients_paid"; const STATS_UPDATED: &str = "StatsUpdated"; -pub type Stats = (u64, i128, u64); - -fn invoices_key(env: &Env) -> Symbol { - Symbol::new(env, TOTAL_INVOICES) -} - -fn volume_key(env: &Env) -> Symbol { - Symbol::new(env, TOTAL_VOLUME) -} - -fn recipients_paid_key(env: &Env) -> Symbol { - Symbol::new(env, TOTAL_RECIPIENTS_PAID) -} - -pub fn get_stats(env: &Env) -> Stats { - let invoices = env - .storage() - .instance() - .get::(&invoices_key(env)) - .unwrap_or(0); - let volume = env - .storage() - .instance() - .get::(&volume_key(env)) - .unwrap_or(0); - let recipients_paid = env - .storage() - .instance() - .get::(&recipients_paid_key(env)) - .unwrap_or(0); - - (invoices, volume, recipients_paid) -} - -fn publish_updated(env: &Env, stats: Stats) { - env.events().publish( - (Symbol::new(env, STATS_UPDATED),), - ( - stats.0, - stats.1, - stats.2, - env.ledger().sequence(), - ), - ); -} - -pub fn record_invoice_created(env: &Env) -> Result<(), ContractError> { - let (invoices, volume, recipients_paid) = get_stats(env); - let updated_invoices = invoices - .checked_add(1) - .ok_or(ContractError::StatsOverflow)?; - - env.storage() - .instance() - .set(&invoices_key(env), &updated_invoices); - - publish_updated(env, (updated_invoices, volume, recipients_paid)); - Ok(()) -} - -pub fn record_volume(env: &Env, amount: i128) -> Result<(), ContractError> { - let (invoices, volume, recipients_paid) = get_stats(env); - let updated_volume = volume - .checked_add(amount) - .ok_or(ContractError::StatsOverflow)?; - - env.storage() - .instance() - .set(&volume_key(env), &updated_volume); - - publish_updated(env, (invoices, updated_volume, recipients_paid)); - Ok(()) -} - -pub fn record_recipients_paid(env: &Env, count: u64) -> Result<(), ContractError> { - let (invoices, volume, recipients_paid) = get_stats(env); - let updated_recipients_paid = recipients_paid - .checked_add(count) - .ok_or(ContractError::StatsOverflow)?; - - env.storage() - .instance() - .set(&recipients_paid_key(env), &updated_recipients_paid); - - publish_updated(env, (invoices, volume, updated_recipients_paid)); - Ok(()) fn total_invoices_key(env: &Env) -> Symbol { Symbol::new(env, TOTAL_INVOICES) }