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
19 changes: 7 additions & 12 deletions remitwise-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,16 @@ impl EventPriority {
pub const DEFAULT_PAGE_LIMIT: u32 = 20;
pub const MAX_PAGE_LIMIT: u32 = 50;

/// Standardized TTL Constants (Ledger Counts)
pub const DAY_IN_LEDGERS: u32 = 17280; // ~5 seconds per ledger

/// Storage TTL constants for active data
pub const INSTANCE_LIFETIME_THRESHOLD: u32 = 17280; // ~1 day
pub const INSTANCE_BUMP_AMOUNT: u32 = 518400; // ~30 days
pub const INSTANCE_LIFETIME_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; // 7 days
pub const INSTANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; // 30 days

/// Storage TTL constants for archived data
pub const ARCHIVE_LIFETIME_THRESHOLD: u32 = 17280; // ~1 day
pub const ARCHIVE_BUMP_AMOUNT: u32 = 2592000; // ~180 days (6 months)
pub const ARCHIVE_LIFETIME_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; // 7 days
pub const ARCHIVE_BUMP_AMOUNT: u32 = 180 * DAY_IN_LEDGERS; // 180 days (6 months)

/// Signature expiration time (24 hours in seconds)
pub const SIGNATURE_EXPIRATION: u64 = 86400;
Expand Down Expand Up @@ -161,11 +164,3 @@ impl RemitwiseEvents {
env.events().publish(topics, data);
}
}

// Standardized TTL Constants (Ledger Counts)
pub const DAY_IN_LEDGERS: u32 = 17280; // ~5 seconds per ledger
pub const INSTANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; // 30 days
pub const INSTANCE_LIFETIME_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; // 7 days

pub const PERSISTENT_BUMP_AMOUNT: u32 = 60 * DAY_IN_LEDGERS; // 60 days
pub const PERSISTENT_LIFETIME_THRESHOLD: u32 = 15 * DAY_IN_LEDGERS; // 15 days
42 changes: 42 additions & 0 deletions reporting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@ Financial reporting and insights contract for the RemitWise platform.
Generates on-chain financial health reports by aggregating data from the
remittance\_split, savings\_goals, bill\_payments, and insurance contracts.

## Financial Health Score

The contract calculates a comprehensive financial health score (0-100) based on three components:

### Score Components

- **Savings Score (0-40 points)**: Based on savings goal completion percentage
- **Bills Score (0-40 points)**: Based on bill payment compliance
- **Insurance Score (0-20 points)**: Based on active insurance coverage

### Arithmetic Safety & Normalization

The health score calculation implements hardened arithmetic to ensure security and predictability:

#### Overflow Protection
- Uses saturating arithmetic for amount summations
- Safe division prevents intermediate overflow in percentage calculations
- Individual amounts are clamped to reasonable bounds

#### Bounds Guarantees
- Overall score is always bounded [0, 100]
- Component scores never exceed their maximum values
- Progress percentages are clamped [0, 100]

#### Edge Case Handling
- Zero savings targets result in default score (20 points)
- Negative amounts are clamped to zero
- Extreme input values don't cause calculation failures

#### Security Properties
- Deterministic output for identical inputs
- No external dependencies on ledger state
- Cross-contract calls use configured addresses only

### Example Calculation

For a user with:
- 80% savings goal completion → 32 savings points
- Unpaid bills (none overdue) → 35 bills points
- Active insurance policy → 20 insurance points

**Total Score**: 32 + 35 + 20 = 87

## Trend Analysis

Expand Down
180 changes: 141 additions & 39 deletions reporting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,63 +590,165 @@ impl ReportingContract {
}
}

/// Calculate financial health score
/// Calculate financial health score with hardened arithmetic and normalization
///
/// This function computes a comprehensive financial health score (0-100) based on:
/// - Savings progress (0-40 points): Based on goal completion percentage
/// - Bill payment compliance (0-40 points): Based on unpaid/overdue bills
/// - Insurance coverage (0-20 points): Based on active policies
///
/// # Arithmetic Safety
/// - Uses safe division to prevent overflow
/// - Clamps all intermediate and final scores to valid ranges
/// - Handles edge cases: zero targets, negative amounts, extreme values
///
/// # Normalization Guarantees
/// - Overall score is always bounded [0, 100]
/// - Component scores are bounded to their respective maximums
/// - Progress calculations use saturating arithmetic
///
/// # Arguments
/// * `env` - Soroban environment
/// * `user` - Address of the user to calculate score for
/// * `_total_remittance` - Total remittance amount (currently unused)
///
/// # Returns
/// `HealthScore` struct with overall and component scores
///
/// # Security Notes
/// - All cross-contract calls are made to configured addresses
/// - Arithmetic operations are overflow-safe
/// - No external dependencies on ledger state beyond cross-contract data
pub fn calculate_health_score(env: Env, user: Address, _total_remittance: i128) -> HealthScore {
let addresses: ContractAddresses = env
.storage()
.instance()
.get(&symbol_short!("ADDRS"))
.unwrap_or_else(|| panic!("Contract addresses not configured"));

// Savings score (0-40 points)
let savings_client = SavingsGoalsClient::new(&env, &addresses.savings_goals);
let goals = savings_client.get_all_goals(&user);
// Calculate savings score (0-40 points) with safe arithmetic
let savings_score = Self::calculate_savings_score(&env, &addresses, &user);

// Calculate bills score (0-40 points) with safe arithmetic
let bills_score = Self::calculate_bills_score(&env, &addresses, &user);

// Calculate insurance score (0-20 points)
let insurance_score = Self::calculate_insurance_score(&env, &addresses, &user);

// Calculate total score with bounds checking
let total_score = Self::clamp_score(savings_score + bills_score + insurance_score, 0, 100);

HealthScore {
score: total_score,
savings_score,
bills_score,
insurance_score,
}
}

/// Calculate savings score component (0-40 points)
///
/// Score is based on the percentage of savings goals achieved.
/// Uses safe division to prevent overflow and clamps result to [0, 40].
fn calculate_savings_score(env: &Env, addresses: &ContractAddresses, user: &Address) -> u32 {
let savings_client = SavingsGoalsClient::new(env, &addresses.savings_goals);
let goals = savings_client.get_all_goals(user);

let mut total_target = 0i128;
let mut total_saved = 0i128;

// Sum all goals with overflow protection
for goal in goals.iter() {
total_target += goal.target_amount;
total_saved += goal.current_amount;
// Clamp individual amounts to prevent extreme values from dominating
let target = Self::clamp_amount(goal.target_amount, 0, i128::MAX / 2);
let saved = Self::clamp_amount(goal.current_amount, 0, target);

total_target = total_target.saturating_add(target);
total_saved = total_saved.saturating_add(saved);
}
let savings_score = if total_target > 0 {
let progress = ((total_saved * 100) / total_target) as u32;
if progress > 100 {
40
} else {
(progress * 40) / 100
}

if total_target == 0 {
// No goals set - assign default score
return 20;
}

// Safe percentage calculation: (saved * 100) / target
// To prevent overflow: use (saved * 100) / target, but check for overflow
let progress_percentage = if total_saved >= total_target {
100u32
} else {
20 // Default score if no goals
// Safe division: multiply first, then divide to maintain precision
// (saved * 100) / target, but avoid intermediate overflow
let saved_scaled = total_saved.saturating_mul(100);
let progress = saved_scaled.checked_div(total_target).unwrap_or(0) as u32;
progress.min(100)
};

// Bills score (0-40 points)
let bill_client = BillPaymentsClient::new(&env, &addresses.bill_payments);
let unpaid_bills = bill_client.get_unpaid_bills(&user, &0u32, &50u32).items;
let bills_score = if unpaid_bills.is_empty() {
40
// Convert percentage to score: (progress * 40) / 100
let score = (progress_percentage as u32 * 40) / 100;
score.min(40) // Ensure maximum is 40
}

/// Calculate bills score component (0-40 points)
///
/// Score is based on bill payment compliance:
/// - 40 points: No unpaid bills
/// - 35 points: Has unpaid bills but none overdue
/// - 20 points: Has overdue bills
fn calculate_bills_score(env: &Env, addresses: &ContractAddresses, user: &Address) -> u32 {
let bill_client = BillPaymentsClient::new(env, &addresses.bill_payments);
let unpaid_bills = bill_client.get_unpaid_bills(user, &0u32, &1000u32).items; // Large limit to get all

if unpaid_bills.is_empty() {
return 40; // Perfect compliance
}

let current_time = env.ledger().timestamp();
let overdue_count = unpaid_bills
.iter()
.filter(|bill| bill.due_date < current_time)
.count();

if overdue_count == 0 {
35 // Has unpaid but none overdue
} else {
let overdue_count = unpaid_bills
.iter()
.filter(|b| b.due_date < env.ledger().timestamp())
.count();
if overdue_count == 0 {
35 // Has unpaid but none overdue
} else {
20 // Has overdue bills
}
};
20 // Has overdue bills
}
}

// Insurance score (0-20 points)
let insurance_client = InsuranceClient::new(&env, &addresses.insurance);
let policy_page = insurance_client.get_active_policies(&user, &0, &1);
let insurance_score = if !policy_page.items.is_empty() { 20 } else { 0 };
/// Calculate insurance score component (0-20 points)
///
/// Score is binary: 20 points if at least one active policy, 0 otherwise.
fn calculate_insurance_score(env: &Env, addresses: &ContractAddresses, user: &Address) -> u32 {
let insurance_client = InsuranceClient::new(env, &addresses.insurance);
let policy_page = insurance_client.get_active_policies(user, &0, &1); // Just check if any exist

let total_score = savings_score + bills_score + insurance_score;
if policy_page.items.is_empty() {
0
} else {
20
}
}

HealthScore {
score: total_score,
savings_score,
bills_score,
insurance_score,
/// Clamp a score value to specified min/max bounds
fn clamp_score(value: u32, min: u32, max: u32) -> u32 {
if value < min {
min
} else if value > max {
max
} else {
value
}
}

/// Clamp an amount to specified min/max bounds
fn clamp_amount(value: i128, min: i128, max: i128) -> i128 {
if value < min {
min
} else if value > max {
max
} else {
value
}
}

Expand Down
Loading
Loading