From 7e81cc5ebfc4badad95081c8115fd11dc8baa72d Mon Sep 17 00:00:00 2001 From: martinvibes Date: Sat, 18 Jul 2026 13:42:03 +0100 Subject: [PATCH 1/2] feat: implement overflow-safe arithmetic and daily yield compounding logic --- contracts/inheritance-contract/src/lib.rs | 184 +++++- .../inheritance-contract/src/safe_math.rs | 348 +++++++++++ contracts/inheritance-contract/src/test.rs | 539 ++++++++++++++++++ 3 files changed, 1044 insertions(+), 27 deletions(-) create mode 100644 contracts/inheritance-contract/src/safe_math.rs diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index f31d6101a..99344802a 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -3,6 +3,8 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, }; +pub mod safe_math; + const MAX_BENEFICIARIES: u32 = 100; const PLAN_TTL_THRESHOLD: u32 = 500; const PLAN_TTL_LEEWAY: u32 = 100; @@ -26,6 +28,8 @@ pub enum Error { InvalidBridgeMetadata = 12, MathOverflow = 13, AlreadyInitialized = 14, + DivisionByZero = 15, + InvalidYieldRate = 16, } #[contracttype] @@ -78,6 +82,17 @@ pub enum DataKey { Plan(Address), ClaimStatus(Address), SupportedWrappedToken(Address), + YieldState(Address), +} + +/// Checkpointed virtual-yield bookkeeping for a plan. `accrued` holds +/// interest locked in by past checkpoints; interest since `last_accrual` +/// is projected on demand with `safe_math::compound_yield`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct YieldState { + pub accrued: i128, + pub last_accrual: u64, } #[contracttype] @@ -111,6 +126,70 @@ impl InheritanceContract { .persistent() .has(&DataKey::SupportedWrappedToken(token.clone())) } + + /// Loads the yield checkpoint for a plan, falling back to a zero + /// checkpoint anchored at `last_ping` for plans created before the + /// yield engine existed. + fn load_yield_state(env: &Env, plan: &Plan, owner: &Address) -> YieldState { + env.storage() + .persistent() + .get(&DataKey::YieldState(owner.clone())) + .unwrap_or(YieldState { + accrued: 0, + last_accrual: plan.last_ping, + }) + } + + /// Interest earned since the last checkpoint, compounded daily on + /// principal + already-accrued interest. Zero while yield is disabled. + fn live_yield(env: &Env, plan: &Plan, state: &YieldState) -> Result { + if !plan.earn_yield || plan.yield_rate_bps == 0 { + return Ok(0); + } + let elapsed = env.ledger().timestamp().saturating_sub(state.last_accrual); + let base = safe_math::safe_add(plan.amount, state.accrued)?; + let compounded = safe_math::compound_yield(base, plan.yield_rate_bps, elapsed)?; + safe_math::safe_sub(compounded, base) + } + + /// Locks in interest accrued so far and re-anchors the accrual clock at + /// the current ledger time. Returns (gain, new total accrued). + fn checkpoint_yield(env: &Env, plan: &Plan, owner: &Address) -> Result<(i128, i128), Error> { + let mut state = Self::load_yield_state(env, plan, owner); + let gain = Self::live_yield(env, plan, &state)?; + state.accrued = safe_math::safe_add(state.accrued, gain)?; + state.last_accrual = env.ledger().timestamp(); + let key = DataKey::YieldState(owner.clone()); + env.storage().persistent().set(&key, &state); + Self::extend_plan_ttl(env, &key); + Ok((gain, state.accrued)) + } + + fn clear_yield_state(env: &Env, owner: &Address) { + env.storage() + .persistent() + .remove(&DataKey::YieldState(owner.clone())); + } + + /// Sums beneficiary allocations with checked math and validates bridge + /// metadata; returns InvalidBasisPoints when the sum overflows or + /// deviates from 100%. + fn validate_beneficiaries(env: &Env, beneficiaries: &Vec) -> Result<(), Error> { + let mut total_bps: u32 = 0; + let empty = String::from_str(env, ""); + for beneficiary in beneficiaries.iter() { + total_bps = total_bps + .checked_add(beneficiary.allocation_bps) + .ok_or(Error::InvalidBasisPoints)?; + if beneficiary.destination_chain == empty || beneficiary.destination_address == empty { + return Err(Error::InvalidBridgeMetadata); + } + } + if total_bps != 10000 { + return Err(Error::InvalidBasisPoints); + } + Ok(()) + } } #[contractimpl] @@ -147,17 +226,8 @@ impl InheritanceContract { return Err(Error::NegativeAmount); } - let mut total_bps: u32 = 0; - for beneficiary in beneficiaries.iter() { - total_bps += beneficiary.allocation_bps; - let empty = String::from_str(&env, ""); - if beneficiary.destination_chain == empty || beneficiary.destination_address == empty { - return Err(Error::InvalidBridgeMetadata); - } - } - if total_bps != 10000 { - return Err(Error::InvalidBasisPoints); - } + safe_math::validate_yield_rate(yield_rate_bps)?; + Self::validate_beneficiaries(&env, &beneficiaries)?; let empty = String::from_str(&env, ""); if source_chain == empty || source_tx_hash == empty { @@ -195,6 +265,16 @@ impl InheritanceContract { env.storage().persistent().set(&key, &plan); Self::extend_plan_ttl(&env, &key); + let yield_key = DataKey::YieldState(owner.clone()); + env.storage().persistent().set( + &yield_key, + &YieldState { + accrued: 0, + last_accrual: plan.last_ping, + }, + ); + Self::extend_plan_ttl(&env, &yield_key); + Ok(()) } @@ -210,6 +290,15 @@ impl InheritanceContract { let mut plan: Plan = env.storage().persistent().get(&key).unwrap(); let current_timestamp = env.ledger().timestamp(); + + let (gain, total_accrued) = Self::checkpoint_yield(&env, &plan, &owner)?; + if gain > 0 { + env.events().publish( + (symbol_short!("yield"), owner.clone()), + (gain, total_accrued), + ); + } + plan.last_ping = current_timestamp; env.storage().persistent().set(&key, &plan); @@ -282,7 +371,8 @@ impl InheritanceContract { } let current_time = env.ledger().timestamp(); - if current_time < plan.last_ping + plan.grace_period { + let inactivity_deadline = safe_math::safe_add_u64(plan.last_ping, plan.grace_period)?; + if current_time < inactivity_deadline { return Err(Error::InactivityPeriodNotMet); } @@ -336,7 +426,7 @@ impl InheritanceContract { Self::extend_plan_ttl(&env, &key); let current_time = env.ledger().timestamp(); - let timeout_deadline = plan.last_ping + plan.grace_period; + let timeout_deadline = safe_math::safe_add_u64(plan.last_ping, plan.grace_period)?; Ok(current_time >= timeout_deadline) } @@ -353,7 +443,7 @@ impl InheritanceContract { let plan: Plan = env.storage().persistent().get(&key).unwrap(); Self::extend_plan_ttl(&env, &key); - Ok(plan.last_ping + plan.grace_period) + safe_math::safe_add_u64(plan.last_ping, plan.grace_period) } /// Retrieve the current inheritance plan data. @@ -391,7 +481,8 @@ impl InheritanceContract { .ok_or(Error::PayoutNotTriggered)?; let current_time = env.ledger().timestamp(); - if current_time < claim_time + plan.timelock_duration { + let payout_time = safe_math::safe_add_u64(claim_time, plan.timelock_duration)?; + if current_time < payout_time { return Err(Error::TimelockNotExpired); } @@ -399,6 +490,7 @@ impl InheritanceContract { // to prevent double payout and guard against re-entrancy env.storage().persistent().remove(&key); env.storage().persistent().remove(&claim_key); + Self::clear_yield_state(&env, &owner); let token_client = soroban_sdk::token::Client::new(&env, &plan.token); let n = plan.beneficiaries.len(); @@ -408,8 +500,8 @@ impl InheritanceContract { let share = if i == (n - 1) as usize { remaining } else { - let amount = plan.amount * (beneficiary.allocation_bps as i128) / 10000; - remaining -= amount; + let amount = safe_math::apply_bps(plan.amount, beneficiary.allocation_bps)?; + remaining = safe_math::safe_sub(remaining, amount)?; amount }; @@ -490,6 +582,7 @@ impl InheritanceContract { } env.storage().persistent().remove(&key); + Self::clear_yield_state(&env, &owner); let token_client = soroban_sdk::token::Client::new(&env, &plan.token); token_client.transfer(&env.current_contract_address(), &owner, &plan.amount); @@ -514,6 +607,7 @@ impl InheritanceContract { } env.storage().persistent().remove(&key); + Self::clear_yield_state(&env, &owner); let token_client = soroban_sdk::token::Client::new(&env, &plan.token); token_client.transfer(&env.current_contract_address(), &owner, &plan.amount); @@ -545,18 +639,15 @@ impl InheritanceContract { } // Validate beneficiary allocation_bps sum to exactly 10000 - let mut total_bps: u32 = 0; - for beneficiary in beneficiaries.iter() { - total_bps += beneficiary.allocation_bps; - let empty = String::from_str(&env, ""); - if beneficiary.destination_chain == empty || beneficiary.destination_address == empty { - return Err(Error::InvalidBridgeMetadata); - } - } - if total_bps != 10000 { - return Err(Error::InvalidBasisPoints); + Self::validate_beneficiaries(&env, &beneficiaries)?; + if let Some(yrb) = yield_rate_bps { + safe_math::validate_yield_rate(yrb)?; } + // Lock in interest earned under the old rate/earn_yield settings + // before they change, so updates never apply retroactively. + Self::checkpoint_yield(&env, &plan, &owner)?; + // Update plan fields plan.beneficiaries = beneficiaries; if let Some(gp) = grace_period { @@ -574,6 +665,45 @@ impl InheritanceContract { Ok(()) } + + /// Total virtual interest accrued by a plan: checkpointed interest plus + /// interest compounded daily since the last checkpoint. Read-only. + pub fn get_accrued_yield(env: Env, owner: Address) -> Result { + let key = DataKey::Plan(owner.clone()); + let plan: Plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PlanNotFound)?; + Self::extend_plan_ttl(&env, &key); + + let state = Self::load_yield_state(&env, &plan, &owner); + let live = Self::live_yield(&env, &plan, &state)?; + safe_math::safe_add(state.accrued, live) + } + + /// Principal plus all accrued virtual yield. Read-only projection. + pub fn get_projected_balance(env: Env, owner: Address) -> Result { + let accrued = Self::get_accrued_yield(env.clone(), owner.clone())?; + let plan: Plan = env + .storage() + .persistent() + .get(&DataKey::Plan(owner)) + .ok_or(Error::PlanNotFound)?; + safe_math::safe_add(plan.amount, accrued) + } + + /// Pure preview of daily compounding: the value of `principal` after + /// `days` days at `annual_rate_bps` APY, using only checked math. + pub fn simulate_compound( + principal: i128, + annual_rate_bps: u32, + days: u64, + ) -> Result { + safe_math::validate_yield_rate(annual_rate_bps)?; + let elapsed = safe_math::safe_mul_u64(days, safe_math::SECONDS_PER_DAY)?; + safe_math::compound_yield(principal, annual_rate_bps, elapsed) + } } #[cfg(test)] diff --git a/contracts/inheritance-contract/src/safe_math.rs b/contracts/inheritance-contract/src/safe_math.rs new file mode 100644 index 000000000..6a262a1fe --- /dev/null +++ b/contracts/inheritance-contract/src/safe_math.rs @@ -0,0 +1,348 @@ +//! Overflow-safe arithmetic and fixed-point compounding for yield accrual. +//! +//! All operations use checked i128 math (`checked_mul`, `checked_div`, ...) +//! and surface failures as contract `Error` values instead of panicking, so +//! a hostile or extreme input can never abort the VM with an unhandled trap. +//! +//! Compounding model: `yield_rate_bps` is an annual rate in basis points +//! (500 = 5% APY). Interest compounds once per whole elapsed day using a +//! fixed-point growth factor at `YIELD_SCALE` precision, raised to the +//! number of elapsed days with exponentiation by squaring (O(log n) muls). + +use crate::Error; + +/// Basis-point denominator: 10_000 bps == 100%. +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Fixed-point scale (1e12) used for growth factors during compounding. +pub const YIELD_SCALE: i128 = 1_000_000_000_000; + +/// Length of one compounding period in seconds (daily compounding). +pub const SECONDS_PER_DAY: u64 = 86_400; + +/// Days per year used to derive the per-day rate from the annual rate. +pub const DAYS_PER_YEAR: i128 = 365; + +/// Maximum accepted annual yield rate: 10_000 bps == 100% APY. +pub const MAX_YIELD_RATE_BPS: u32 = 10_000; + +/// Checked i128 addition. +pub fn safe_add(a: i128, b: i128) -> Result { + a.checked_add(b).ok_or(Error::MathOverflow) +} + +/// Checked i128 subtraction. +pub fn safe_sub(a: i128, b: i128) -> Result { + a.checked_sub(b).ok_or(Error::MathOverflow) +} + +/// Checked i128 multiplication. +pub fn safe_mul(a: i128, b: i128) -> Result { + a.checked_mul(b).ok_or(Error::MathOverflow) +} + +/// Checked i128 division. Rejects a zero divisor explicitly and maps the +/// single overflowing case (i128::MIN / -1) to `MathOverflow`. +pub fn safe_div(a: i128, b: i128) -> Result { + if b == 0 { + return Err(Error::DivisionByZero); + } + a.checked_div(b).ok_or(Error::MathOverflow) +} + +/// Checked u64 addition, used for ledger-timestamp deadline arithmetic. +pub fn safe_add_u64(a: u64, b: u64) -> Result { + a.checked_add(b).ok_or(Error::MathOverflow) +} + +/// Checked u64 multiplication, used for seconds/period conversions. +pub fn safe_mul_u64(a: u64, b: u64) -> Result { + a.checked_mul(b).ok_or(Error::MathOverflow) +} + +/// Computes (a * b) / denominator with overflow-checked intermediate steps. +pub fn mul_div(a: i128, b: i128, denominator: i128) -> Result { + safe_div(safe_mul(a, b)?, denominator) +} + +/// Applies a basis-point fraction to an amount: amount * bps / 10_000. +pub fn apply_bps(amount: i128, bps: u32) -> Result { + mul_div(amount, bps as i128, BPS_DENOMINATOR) +} + +/// Validates that an annual yield rate does not exceed the protocol cap. +pub fn validate_yield_rate(rate_bps: u32) -> Result<(), Error> { + if rate_bps > MAX_YIELD_RATE_BPS { + return Err(Error::InvalidYieldRate); + } + Ok(()) +} + +/// Raises a fixed-point factor (scaled by `YIELD_SCALE`) to an integer power +/// using exponentiation by squaring. Every multiply is checked, so extreme +/// exponents surface `MathOverflow` instead of trapping. +pub fn pow_factor(base: i128, mut exp: u64) -> Result { + if base < 0 { + return Err(Error::NegativeAmount); + } + let mut result = YIELD_SCALE; + let mut acc = base; + loop { + if exp & 1 == 1 { + result = mul_div(result, acc, YIELD_SCALE)?; + } + exp >>= 1; + if exp == 0 { + break; + } + acc = mul_div(acc, acc, YIELD_SCALE)?; + } + Ok(result) +} + +/// Compounds `principal` over `periods` periods at `rate_bps_per_period` +/// per period: principal * (1 + rate)^periods, entirely in checked math. +pub fn compound_amount( + principal: i128, + rate_bps_per_period: u32, + periods: u64, +) -> Result { + if principal < 0 { + return Err(Error::NegativeAmount); + } + if principal == 0 || rate_bps_per_period == 0 || periods == 0 { + return Ok(principal); + } + let per_period_factor = safe_add(YIELD_SCALE, apply_bps(YIELD_SCALE, rate_bps_per_period)?)?; + let growth = pow_factor(per_period_factor, periods)?; + mul_div(principal, growth, YIELD_SCALE) +} + +/// Compounds `principal` at an annual rate of `annual_rate_bps` over +/// `elapsed_seconds`, compounding once per whole elapsed day. Partial days +/// accrue nothing until the day completes. Returns the new total value. +pub fn compound_yield( + principal: i128, + annual_rate_bps: u32, + elapsed_seconds: u64, +) -> Result { + if principal < 0 { + return Err(Error::NegativeAmount); + } + let periods = elapsed_seconds / SECONDS_PER_DAY; + if principal == 0 || annual_rate_bps == 0 || periods == 0 { + return Ok(principal); + } + let daily_increment = safe_div( + safe_mul(annual_rate_bps as i128, YIELD_SCALE)?, + safe_mul(BPS_DENOMINATOR, DAYS_PER_YEAR)?, + )?; + let daily_factor = safe_add(YIELD_SCALE, daily_increment)?; + let growth = pow_factor(daily_factor, periods)?; + mul_div(principal, growth, YIELD_SCALE) +} + +/// Interest earned on top of `principal` after compounding: always >= 0. +pub fn accrued_interest( + principal: i128, + annual_rate_bps: u32, + elapsed_seconds: u64, +) -> Result { + safe_sub( + compound_yield(principal, annual_rate_bps, elapsed_seconds)?, + principal, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- checked primitives ---- + + #[test] + fn safe_add_sums_and_detects_overflow() { + assert_eq!(safe_add(2, 3), Ok(5)); + assert_eq!(safe_add(-2, -3), Ok(-5)); + assert_eq!(safe_add(i128::MAX, 1), Err(Error::MathOverflow)); + assert_eq!(safe_add(i128::MIN, -1), Err(Error::MathOverflow)); + } + + #[test] + fn safe_sub_subtracts_and_detects_overflow() { + assert_eq!(safe_sub(10, 4), Ok(6)); + assert_eq!(safe_sub(4, 10), Ok(-6)); + assert_eq!(safe_sub(i128::MIN, 1), Err(Error::MathOverflow)); + assert_eq!(safe_sub(i128::MAX, -1), Err(Error::MathOverflow)); + } + + #[test] + fn safe_mul_multiplies_and_detects_overflow() { + assert_eq!(safe_mul(6, 7), Ok(42)); + assert_eq!(safe_mul(-6, 7), Ok(-42)); + assert_eq!(safe_mul(0, i128::MAX), Ok(0)); + assert_eq!(safe_mul(i128::MAX, 2), Err(Error::MathOverflow)); + } + + #[test] + fn safe_div_divides_and_rejects_zero_divisor() { + assert_eq!(safe_div(42, 7), Ok(6)); + assert_eq!(safe_div(7, 2), Ok(3)); + assert_eq!(safe_div(42, 0), Err(Error::DivisionByZero)); + assert_eq!(safe_div(i128::MIN, -1), Err(Error::MathOverflow)); + } + + #[test] + fn safe_u64_helpers_detect_overflow() { + assert_eq!(safe_add_u64(1, 2), Ok(3)); + assert_eq!(safe_add_u64(u64::MAX, 1), Err(Error::MathOverflow)); + assert_eq!(safe_mul_u64(3, 4), Ok(12)); + assert_eq!(safe_mul_u64(u64::MAX, 2), Err(Error::MathOverflow)); + } + + #[test] + fn mul_div_computes_scaled_products() { + assert_eq!(mul_div(10, 20, 4), Ok(50)); + assert_eq!(mul_div(7, 3, 2), Ok(10)); + assert_eq!(mul_div(5, 5, 0), Err(Error::DivisionByZero)); + assert_eq!(mul_div(i128::MAX, 2, 2), Err(Error::MathOverflow)); + } + + #[test] + fn apply_bps_takes_basis_point_fractions() { + assert_eq!(apply_bps(1_000, 5_000), Ok(500)); + assert_eq!(apply_bps(1_000_000, 250), Ok(25_000)); + assert_eq!(apply_bps(1_000, 10_000), Ok(1_000)); + assert_eq!(apply_bps(1_000, 0), Ok(0)); + assert_eq!(apply_bps(999, 3_333), Ok(332)); + assert_eq!(apply_bps(i128::MAX, 2), Err(Error::MathOverflow)); + } + + #[test] + fn validate_yield_rate_enforces_cap() { + assert_eq!(validate_yield_rate(0), Ok(())); + assert_eq!(validate_yield_rate(500), Ok(())); + assert_eq!(validate_yield_rate(MAX_YIELD_RATE_BPS), Ok(())); + assert_eq!( + validate_yield_rate(MAX_YIELD_RATE_BPS + 1), + Err(Error::InvalidYieldRate) + ); + } + + // ---- fixed-point exponentiation ---- + + #[test] + fn pow_factor_zero_exponent_is_identity() { + assert_eq!(pow_factor(3 * YIELD_SCALE, 0), Ok(YIELD_SCALE)); + assert_eq!(pow_factor(0, 0), Ok(YIELD_SCALE)); + } + + #[test] + fn pow_factor_first_power_returns_base() { + assert_eq!(pow_factor(YIELD_SCALE, 1), Ok(YIELD_SCALE)); + assert_eq!(pow_factor(2 * YIELD_SCALE, 1), Ok(2 * YIELD_SCALE)); + } + + #[test] + fn pow_factor_computes_integer_powers() { + // 2^10 = 1024 at fixed-point scale + assert_eq!(pow_factor(2 * YIELD_SCALE, 10), Ok(1024 * YIELD_SCALE)); + // 1.1^2 = 1.21 exactly representable at 1e12 scale + assert_eq!(pow_factor(1_100_000_000_000, 2), Ok(1_210_000_000_000)); + } + + #[test] + fn pow_factor_rejects_negative_base_and_overflow() { + assert_eq!(pow_factor(-YIELD_SCALE, 2), Err(Error::NegativeAmount)); + assert_eq!(pow_factor(2 * YIELD_SCALE, 200), Err(Error::MathOverflow)); + } + + // ---- per-period compounding ---- + + #[test] + fn compound_amount_identity_cases() { + assert_eq!(compound_amount(10_000, 1_000, 0), Ok(10_000)); + assert_eq!(compound_amount(10_000, 0, 5), Ok(10_000)); + assert_eq!(compound_amount(0, 1_000, 5), Ok(0)); + } + + #[test] + fn compound_amount_compounds_per_period() { + // 10% per period on 10_000: 11_000, 12_100, 13_310 + assert_eq!(compound_amount(10_000, 1_000, 1), Ok(11_000)); + assert_eq!(compound_amount(10_000, 1_000, 2), Ok(12_100)); + assert_eq!(compound_amount(10_000, 1_000, 3), Ok(13_310)); + } + + #[test] + fn compound_amount_rejects_negative_principal() { + assert_eq!(compound_amount(-1, 1_000, 1), Err(Error::NegativeAmount)); + } + + #[test] + fn compound_amount_overflows_cleanly() { + assert_eq!( + compound_amount(i128::MAX / 2, 10_000, 2), + Err(Error::MathOverflow) + ); + } + + // ---- time-based compounding ---- + + #[test] + fn compound_yield_identity_cases() { + assert_eq!(compound_yield(1_000, 500, 0), Ok(1_000)); + // Partial day accrues nothing + assert_eq!(compound_yield(1_000, 500, SECONDS_PER_DAY - 1), Ok(1_000)); + assert_eq!(compound_yield(1_000, 0, 10 * SECONDS_PER_DAY), Ok(1_000)); + assert_eq!(compound_yield(0, 500, 10 * SECONDS_PER_DAY), Ok(0)); + } + + #[test] + fn compound_yield_rejects_negative_principal() { + assert_eq!( + compound_yield(-5, 500, SECONDS_PER_DAY), + Err(Error::NegativeAmount) + ); + } + + #[test] + fn compound_yield_one_year_beats_simple_interest() { + let principal: i128 = 1_000_000_000; + let one_year = 365 * SECONDS_PER_DAY; + let value = compound_yield(principal, 500, one_year).unwrap(); + // Daily compounding at 5% APY lands between simple 5% and 5.2% + assert!(value > 1_050_000_000, "compounding must beat simple interest"); + assert!(value < 1_052_000_000, "5% APY cannot exceed 5.2% in a year"); + } + + #[test] + fn compound_yield_is_monotonic_in_time() { + let principal: i128 = 1_000_000_000; + let y1 = compound_yield(principal, 500, 365 * SECONDS_PER_DAY).unwrap(); + let y2 = compound_yield(principal, 500, 730 * SECONDS_PER_DAY).unwrap(); + assert!(y2 > y1); + assert!(y1 > principal); + } + + #[test] + fn compound_yield_overflow_surfaces_error() { + // 100% APY over 100 years: growth ~e^100, far beyond i128 range + let hundred_years = 36_500 * SECONDS_PER_DAY; + assert_eq!( + compound_yield(1_000_000, 10_000, hundred_years), + Err(Error::MathOverflow) + ); + } + + #[test] + fn accrued_interest_is_value_minus_principal() { + let principal: i128 = 1_000_000_000; + let one_year = 365 * SECONDS_PER_DAY; + let total = compound_yield(principal, 500, one_year).unwrap(); + let interest = accrued_interest(principal, 500, one_year).unwrap(); + assert_eq!(interest, total - principal); + assert!(interest > 0); + assert_eq!(accrued_interest(principal, 500, 0), Ok(0)); + } +} diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index 68cf68fa1..a0329c532 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -1539,3 +1539,542 @@ fn test_get_plan_returns_not_found_for_unknown_owner() { let result = client.try_get_plan(&unknown); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } + +// ============================================================================ +// Safe-math yield engine tests (Issue #8: checked compounding) +// ============================================================================ + +const DAY: u64 = safe_math::SECONDS_PER_DAY; + +/// Registers the contract plus a mock token, mints `amount` to a fresh owner +/// and creates a single-beneficiary plan with the given yield settings. +fn setup_yield_plan<'a>( + env: &'a Env, + amount: i128, + earn_yield: bool, + yield_rate_bps: u32, +) -> ( + InheritanceContractClient<'a>, + mock_token::MockTokenClient<'a>, + Address, + Address, + Address, +) { + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(env, &contract_id); + + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(env, &token_id); + + let owner = Address::generate(env); + let beneficiary_address = Address::generate(env); + token_client.mint(&owner, &amount); + + let beneficiary = Beneficiary { + address: beneficiary_address.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(env, "NGN_BANK"), + destination_chain: String::from_str(env, "Stellar"), + destination_address: String::from_str(env, "GDESTADDR"), + }; + + client.create_plan( + &owner, + &token_id, + &amount, + &Vec::from_array(env, [beneficiary]), + &3600, + &earn_yield, + &yield_rate_bps, + &86400, + &String::from_str(env, "Stellar"), + &String::from_str(env, "SRC_TX_HASH"), + ); + + (client, token_client, owner, beneficiary_address, contract_id) +} + +#[test] +fn test_get_accrued_yield_zero_immediately_after_creation() { + let env = Env::default(); + env.ledger().set_timestamp(1_000_000); + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, 1_000_000_000, true, 500); + + assert_eq!(client.get_accrued_yield(&owner), 0); + assert_eq!(client.get_projected_balance(&owner), 1_000_000_000); +} + +#[test] +fn test_get_accrued_yield_compounds_daily_after_one_year() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 365 * DAY); + let accrued = client.get_accrued_yield(&owner); + + let expected = safe_math::accrued_interest(principal, 500, 365 * DAY).unwrap(); + assert_eq!(accrued, expected); + // Daily compounding at 5% APY must beat simple interest but stay < 5.2% + assert!(accrued > 50_000_000); + assert!(accrued < 52_000_000); +} + +#[test] +fn test_get_projected_balance_is_principal_plus_yield() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 365 * DAY); + let accrued = client.get_accrued_yield(&owner); + assert!(accrued > 0); + assert_eq!(client.get_projected_balance(&owner), principal + accrued); +} + +#[test] +fn test_get_accrued_yield_zero_when_earn_yield_disabled() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, 1_000_000_000, false, 500); + + env.ledger().set_timestamp(start + 365 * DAY); + assert_eq!(client.get_accrued_yield(&owner), 0); + assert_eq!(client.get_projected_balance(&owner), 1_000_000_000); +} + +#[test] +fn test_yield_queries_return_plan_not_found_for_unknown_owner() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let unknown = Address::generate(&env); + assert_eq!( + client.try_get_accrued_yield(&unknown), + Err(Ok(Error::PlanNotFound)) + ); + assert_eq!( + client.try_get_projected_balance(&unknown), + Err(Ok(Error::PlanNotFound)) + ); +} + +#[test] +fn test_ping_checkpoints_and_keeps_compounding_on_new_base() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 100 * DAY); + client.ping(&owner); + + env.ledger().set_timestamp(start + 200 * DAY); + let accrued = client.get_accrued_yield(&owner); + + // Checkpoint at day 100, then compounding continues on principal + accrued + let first_leg = safe_math::accrued_interest(principal, 500, 100 * DAY).unwrap(); + let second_leg = + safe_math::accrued_interest(principal + first_leg, 500, 100 * DAY).unwrap(); + assert_eq!(accrued, first_leg + second_leg); + + // Within integer-rounding distance of one uninterrupted 200-day stretch + let single_stretch = safe_math::accrued_interest(principal, 500, 200 * DAY).unwrap(); + assert!((accrued - single_stretch).abs() <= 5); +} + +#[test] +fn test_multiple_pings_match_single_stretch_within_rounding() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + for k in 1..=4u64 { + env.ledger().set_timestamp(start + k * 50 * DAY); + client.ping(&owner); + } + + let accrued = client.get_accrued_yield(&owner); + let single_stretch = safe_math::accrued_interest(principal, 500, 200 * DAY).unwrap(); + assert!((accrued - single_stretch).abs() <= 10); +} + +#[test] +fn test_ping_emits_yield_event_when_interest_accrued() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, contract_id) = + setup_yield_plan(&env, principal, true, 500); + + let ping_ts = start + 10 * DAY; + env.ledger().set_timestamp(ping_ts); + client.ping(&owner); + + let gain = safe_math::accrued_interest(principal, 500, 10 * DAY).unwrap(); + assert!(gain > 0); + assert_eq!( + env.events().all(), + vec![ + &env, + ( + contract_id.clone(), + (symbol_short!("yield"), owner.clone()).into_val(&env), + (gain, gain).into_val(&env), + ), + ( + contract_id, + (symbol_short!("ping"), owner).into_val(&env), + ping_ts.into_val(&env), + ), + ] + ); +} + +#[test] +fn test_create_plan_rejects_excessive_yield_rate() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(&env, &token_id); + + let owner = Address::generate(&env); + token_client.mint(&owner, &2000); + + let beneficiary = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + + let result = client.try_create_plan( + &owner, + &token_id, + &1500, + &Vec::from_array(&env, [beneficiary]), + &3600, + &true, + &(safe_math::MAX_YIELD_RATE_BPS + 1), + &86400, + &String::from_str(&env, "Stellar"), + &String::from_str(&env, "SRC_TX_HASH"), + ); + assert_eq!(result, Err(Ok(Error::InvalidYieldRate))); +} + +#[test] +fn test_update_plan_rejects_excessive_yield_rate() { + let env = Env::default(); + env.ledger().set_timestamp(1_000_000); + let (client, _token, owner, bene, _cid) = setup_yield_plan(&env, 1_000_000_000, true, 500); + + let beneficiary = Beneficiary { + address: bene, + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + let result = client.try_update_plan( + &owner, + &Vec::from_array(&env, [beneficiary]), + &None, + &None, + &Some(safe_math::MAX_YIELD_RATE_BPS + 1), + ); + assert_eq!(result, Err(Ok(Error::InvalidYieldRate))); +} + +#[test] +fn test_create_plan_allocation_bps_overflow_returns_invalid_basis_points() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + let token_id = env.register_contract(None, mock_token::MockToken); + + let owner = Address::generate(&env); + let make_beneficiary = |bps: u32| Beneficiary { + address: Address::generate(&env), + allocation_bps: bps, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + + // 3_000_000_000 + 3_000_000_000 overflows u32: must be a clean error + let result = client.try_create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array( + &env, + [make_beneficiary(3_000_000_000), make_beneficiary(3_000_000_000)], + ), + &3600, + &false, + &0, + &86400, + &String::from_str(&env, "Stellar"), + &String::from_str(&env, "SRC_TX_HASH"), + ); + assert_eq!(result, Err(Ok(Error::InvalidBasisPoints))); +} + +#[test] +fn test_update_plan_freezes_accrual_when_yield_disabled() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 100 * DAY); + let beneficiary = Beneficiary { + address: bene, + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + client.update_plan( + &owner, + &Vec::from_array(&env, [beneficiary]), + &None, + &Some(false), + &None, + ); + + // Interest up to the update is locked in; nothing accrues afterwards + let frozen = safe_math::accrued_interest(principal, 500, 100 * DAY).unwrap(); + env.ledger().set_timestamp(start + 200 * DAY); + assert_eq!(client.get_accrued_yield(&owner), frozen); +} + +#[test] +fn test_update_plan_applies_new_rate_only_forward() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 100 * DAY); + let beneficiary = Beneficiary { + address: bene, + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + client.update_plan( + &owner, + &Vec::from_array(&env, [beneficiary]), + &None, + &None, + &Some(1000), + ); + + env.ledger().set_timestamp(start + 200 * DAY); + let accrued = client.get_accrued_yield(&owner); + + // First 100 days at the old 5% rate, next 100 days at 10% on the new base + let first_leg = safe_math::accrued_interest(principal, 500, 100 * DAY).unwrap(); + let second_leg = + safe_math::accrued_interest(principal + first_leg, 1000, 100 * DAY).unwrap(); + assert_eq!(accrued, first_leg + second_leg); +} + +#[test] +fn test_close_plan_clears_yield_state() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, token_client, owner, _bene, contract_id) = + setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 365 * DAY); + assert!(client.get_accrued_yield(&owner) > 0); + + client.close_plan(&owner); + env.as_contract(&contract_id, || { + assert!(!env + .storage() + .persistent() + .has(&DataKey::YieldState(owner.clone()))); + }); + + // Recreating a plan starts the yield clock from scratch + assert_eq!(token_client.balance(&owner), principal); + let beneficiary = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + client.create_plan( + &owner, + &token_client.address, + &principal, + &Vec::from_array(&env, [beneficiary]), + &3600, + &true, + &500, + &86400, + &String::from_str(&env, "Stellar"), + &String::from_str(&env, "SRC_TX_HASH"), + ); + assert_eq!(client.get_accrued_yield(&owner), 0); +} + +#[test] +fn test_reclaim_clears_yield_state() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, contract_id) = + setup_yield_plan(&env, 1_000_000_000, true, 500); + + env.ledger().set_timestamp(start + 365 * DAY); + client.reclaim(&owner); + + env.as_contract(&contract_id, || { + assert!(!env + .storage() + .persistent() + .has(&DataKey::YieldState(owner.clone()))); + }); +} + +#[test] +fn test_trigger_payout_pays_principal_only_and_clears_yield_state() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 10_000; + let (client, token_client, owner, beneficiary, contract_id) = + setup_yield_plan(&env, principal, true, 500); + + // A year of virtual yield accrues, then the owner goes silent + env.ledger().set_timestamp(start + 365 * DAY); + assert!(client.get_accrued_yield(&owner) > 0); + deactivate_plan_for_testing(&env, &contract_id, &owner); + client.claim(&owner); + + env.ledger().set_timestamp(start + 365 * DAY + 86400); + client.trigger_payout(&owner); + + // Payout distributes exactly the held principal; yield stays virtual + assert_eq!(token_client.balance(&beneficiary), principal); + assert_eq!(token_client.balance(&contract_id), 0); + env.as_contract(&contract_id, || { + assert!(!env + .storage() + .persistent() + .has(&DataKey::YieldState(owner.clone()))); + }); +} + +#[test] +fn test_get_accrued_yield_overflow_surfaces_math_error() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, _cid) = + setup_yield_plan(&env, 1_000_000, true, safe_math::MAX_YIELD_RATE_BPS); + + // 100% APY over 100 years: growth ~e^100 overflows i128 fixed-point math + env.ledger().set_timestamp(start + 36_500 * DAY); + assert_eq!( + client.try_get_accrued_yield(&owner), + Err(Ok(Error::MathOverflow)) + ); +} + +#[test] +fn test_timeout_deadline_overflow_surfaces_math_error() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000_000); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + let token_id = env.register_contract(None, mock_token::MockToken); + let token_client = mock_token::MockTokenClient::new(&env, &token_id); + + let owner = Address::generate(&env); + token_client.mint(&owner, &2000); + + let beneficiary = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "NGN_BANK"), + destination_chain: String::from_str(&env, "Stellar"), + destination_address: String::from_str(&env, "GDESTADDR"), + }; + + // A grace period of u64::MAX makes last_ping + grace_period overflow + client.create_plan( + &owner, + &token_id, + &1500, + &Vec::from_array(&env, [beneficiary]), + &u64::MAX, + &false, + &0, + &86400, + &String::from_str(&env, "Stellar"), + &String::from_str(&env, "SRC_TX_HASH"), + ); + + assert_eq!( + client.try_get_timeout_deadline(&owner), + Err(Ok(Error::MathOverflow)) + ); + assert_eq!( + client.try_is_plan_timed_out(&owner), + Err(Ok(Error::MathOverflow)) + ); +} + +#[test] +fn test_simulate_compound_matches_safe_math_and_validates() { + let env = Env::default(); + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let expected = safe_math::compound_yield(1_000_000_000, 500, 365 * DAY).unwrap(); + assert_eq!(client.simulate_compound(&1_000_000_000, &500, &365), expected); + assert_eq!(client.simulate_compound(&1_000_000_000, &500, &0), 1_000_000_000); + assert_eq!(client.simulate_compound(&1_000_000_000, &0, &365), 1_000_000_000); + + assert_eq!( + client.try_simulate_compound(&1_000, &(safe_math::MAX_YIELD_RATE_BPS + 1), &10), + Err(Ok(Error::InvalidYieldRate)) + ); + assert_eq!( + client.try_simulate_compound(&1_000, &500, &u64::MAX), + Err(Ok(Error::MathOverflow)) + ); +} From 1289616a6c8cb893d47ed759381df88c3c2c6ac8 Mon Sep 17 00:00:00 2001 From: martinvibes Date: Sat, 18 Jul 2026 13:51:23 +0100 Subject: [PATCH 2/2] feat: add read-only yield state and future projection methods with expanded safe math test coverage --- contracts/inheritance-contract/src/lib.rs | 49 ++++++- .../inheritance-contract/src/safe_math.rs | 84 +++++++++++ contracts/inheritance-contract/src/test.rs | 138 ++++++++++++++++++ 3 files changed, 267 insertions(+), 4 deletions(-) diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index 99344802a..68d80806c 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -140,18 +140,25 @@ impl InheritanceContract { }) } - /// Interest earned since the last checkpoint, compounded daily on - /// principal + already-accrued interest. Zero while yield is disabled. - fn live_yield(env: &Env, plan: &Plan, state: &YieldState) -> Result { + /// Interest earned between the last checkpoint and `at`, compounded + /// daily on principal + already-accrued interest. Zero while yield is + /// disabled, and zero for any `at` at or before the last checkpoint. + fn yield_between(plan: &Plan, state: &YieldState, at: u64) -> Result { if !plan.earn_yield || plan.yield_rate_bps == 0 { return Ok(0); } - let elapsed = env.ledger().timestamp().saturating_sub(state.last_accrual); + let elapsed = at.saturating_sub(state.last_accrual); let base = safe_math::safe_add(plan.amount, state.accrued)?; let compounded = safe_math::compound_yield(base, plan.yield_rate_bps, elapsed)?; safe_math::safe_sub(compounded, base) } + /// Interest earned since the last checkpoint, as of the current ledger + /// timestamp. + fn live_yield(env: &Env, plan: &Plan, state: &YieldState) -> Result { + Self::yield_between(plan, state, env.ledger().timestamp()) + } + /// Locks in interest accrued so far and re-anchors the accrual clock at /// the current ledger time. Returns (gain, new total accrued). fn checkpoint_yield(env: &Env, plan: &Plan, owner: &Address) -> Result<(i128, i128), Error> { @@ -693,6 +700,40 @@ impl InheritanceContract { safe_math::safe_add(plan.amount, accrued) } + /// Raw yield checkpoint for a plan: interest locked in as of + /// `last_accrual`, plus the anchor timestamp itself. Useful for + /// off-chain indexing and for auditing when interest was last locked in + /// by a `ping` or `update_plan` call. Read-only. + pub fn get_yield_state(env: Env, owner: Address) -> Result { + let key = DataKey::Plan(owner.clone()); + let plan: Plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PlanNotFound)?; + Self::extend_plan_ttl(&env, &key); + + Ok(Self::load_yield_state(&env, &plan, &owner)) + } + + /// Previews total accrued interest at an arbitrary future ledger + /// timestamp, without mutating any state. `at` before the last + /// checkpoint yields the checkpointed total (no negative accrual). + /// Lets a caller answer "what will this plan be worth on date X". + pub fn get_yield_at(env: Env, owner: Address, at: u64) -> Result { + let key = DataKey::Plan(owner.clone()); + let plan: Plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PlanNotFound)?; + Self::extend_plan_ttl(&env, &key); + + let state = Self::load_yield_state(&env, &plan, &owner); + let projected = Self::yield_between(&plan, &state, at)?; + safe_math::safe_add(state.accrued, projected) + } + /// Pure preview of daily compounding: the value of `principal` after /// `days` days at `annual_rate_bps` APY, using only checked math. pub fn simulate_compound( diff --git a/contracts/inheritance-contract/src/safe_math.rs b/contracts/inheritance-contract/src/safe_math.rs index 6a262a1fe..50d12c91a 100644 --- a/contracts/inheritance-contract/src/safe_math.rs +++ b/contracts/inheritance-contract/src/safe_math.rs @@ -345,4 +345,88 @@ mod tests { assert!(interest > 0); assert_eq!(accrued_interest(principal, 500, 0), Ok(0)); } + + // ---- additional boundary and consistency coverage ---- + + #[test] + fn apply_bps_at_max_rate_is_exact_amount() { + assert_eq!(apply_bps(123_456, MAX_YIELD_RATE_BPS), Ok(123_456)); + } + + #[test] + fn apply_bps_handles_negative_amounts_symmetrically() { + // Bridge fees always operate on non-negative shares, but the helper + // itself must stay sign-correct since it's pure checked math. + assert_eq!(apply_bps(-1_000, 5_000), Ok(-500)); + } + + #[test] + fn mul_div_rounds_toward_zero_like_integer_division() { + assert_eq!(mul_div(1, 1, 3), Ok(0)); + assert_eq!(mul_div(-1, 1, 3), Ok(0)); + assert_eq!(mul_div(2, 1, 3), Ok(0)); + assert_eq!(mul_div(4, 1, 3), Ok(1)); + } + + #[test] + fn pow_factor_handles_odd_and_even_exponents_consistently() { + // x^7 == x^6 * x == (x^3)^2 * x, exercised via the squaring ladder + let base = 1_050_000_000_000; // 1.05 at YIELD_SCALE + let seven = pow_factor(base, 7).unwrap(); + let six = pow_factor(base, 6).unwrap(); + let manual_seven = mul_div(six, base, YIELD_SCALE).unwrap(); + assert_eq!(seven, manual_seven); + } + + #[test] + fn pow_factor_large_exponent_stays_within_bounds_at_low_base() { + // A base just above 1.0 with many periods must not overflow even + // though the loop runs through every bit of a large exponent. + let base = YIELD_SCALE + 1; // smallest possible daily growth tick + let result = pow_factor(base, 100_000).unwrap(); + assert!(result >= YIELD_SCALE); + } + + #[test] + fn compound_amount_zero_periods_ignores_rate() { + assert_eq!(compound_amount(10_000, MAX_YIELD_RATE_BPS, 0), Ok(10_000)); + } + + #[test] + fn compound_amount_single_period_matches_apply_bps_plus_principal() { + let principal: i128 = 50_000; + let rate_bps = 750u32; + let expected = principal + apply_bps(principal, rate_bps).unwrap(); + assert_eq!(compound_amount(principal, rate_bps, 1), Ok(expected)); + } + + #[test] + fn compound_yield_zero_rate_is_identity_across_long_horizons() { + let principal: i128 = 987_654_321; + assert_eq!( + compound_yield(principal, 0, 10_000 * SECONDS_PER_DAY), + Ok(principal) + ); + } + + #[test] + fn compound_yield_small_principal_does_not_underflow_to_negative() { + // A tiny principal at a low rate over a short horizon may round its + // interest down to zero, but must never go negative. + let value = compound_yield(1, 1, SECONDS_PER_DAY).unwrap(); + assert!(value >= 1); + } + + #[test] + fn safe_div_negative_operands_round_toward_zero() { + assert_eq!(safe_div(-7, 2), Ok(-3)); + assert_eq!(safe_div(7, -2), Ok(-3)); + assert_eq!(safe_div(-7, -2), Ok(3)); + } + + #[test] + fn safe_mul_u64_zero_and_one_are_identities() { + assert_eq!(safe_mul_u64(0, 12345), Ok(0)); + assert_eq!(safe_mul_u64(1, 12345), Ok(12345)); + } } diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index a0329c532..490189d61 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -2078,3 +2078,141 @@ fn test_simulate_compound_matches_safe_math_and_validates() { Err(Ok(Error::MathOverflow)) ); } + +// ============================================================================ +// get_yield_state / get_yield_at (future-preview) tests +// ============================================================================ + +#[test] +fn test_get_yield_state_reflects_creation_checkpoint() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, 1_000_000_000, true, 500); + + let state = client.get_yield_state(&owner); + assert_eq!(state.accrued, 0); + assert_eq!(state.last_accrual, start); +} + +#[test] +fn test_get_yield_state_updates_after_ping() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + let ping_ts = start + 10 * DAY; + env.ledger().set_timestamp(ping_ts); + client.ping(&owner); + + let state = client.get_yield_state(&owner); + let expected_gain = safe_math::accrued_interest(principal, 500, 10 * DAY).unwrap(); + assert_eq!(state.accrued, expected_gain); + assert_eq!(state.last_accrual, ping_ts); +} + +#[test] +fn test_get_yield_state_returns_plan_not_found_for_unknown_owner() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let unknown = Address::generate(&env); + assert_eq!( + client.try_get_yield_state(&unknown), + Err(Ok(Error::PlanNotFound)) + ); +} + +#[test] +fn test_get_yield_at_matches_get_accrued_yield_for_current_timestamp() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + let now = start + 90 * DAY; + env.ledger().set_timestamp(now); + + assert_eq!(client.get_yield_at(&owner, &now), client.get_accrued_yield(&owner)); +} + +#[test] +fn test_get_yield_at_previews_future_timestamp_without_mutating_state() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + // Preview one year out while the ledger clock is still at `start`. + let future = start + 365 * DAY; + let preview = client.get_yield_at(&owner, &future); + let expected = safe_math::accrued_interest(principal, 500, 365 * DAY).unwrap(); + assert_eq!(preview, expected); + + // The preview call must not have written a new checkpoint. + let state = client.get_yield_state(&owner); + assert_eq!(state.accrued, 0); + assert_eq!(state.last_accrual, start); + assert_eq!(client.get_accrued_yield(&owner), 0); +} + +#[test] +fn test_get_yield_at_before_last_checkpoint_returns_checkpointed_total_only() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let principal: i128 = 1_000_000_000; + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, principal, true, 500); + + env.ledger().set_timestamp(start + 50 * DAY); + client.ping(&owner); + let checkpointed = client.get_yield_state(&owner).accrued; + assert!(checkpointed > 0); + + // Querying a timestamp before the checkpoint must not go negative. + assert_eq!(client.get_yield_at(&owner, &start), checkpointed); +} + +#[test] +fn test_get_yield_at_zero_when_earn_yield_disabled() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, _cid) = setup_yield_plan(&env, 1_000_000_000, false, 500); + + assert_eq!(client.get_yield_at(&owner, &(start + 365 * DAY)), 0); +} + +#[test] +fn test_get_yield_at_returns_plan_not_found_for_unknown_owner() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let unknown = Address::generate(&env); + assert_eq!( + client.try_get_yield_at(&unknown, &1_000_000), + Err(Ok(Error::PlanNotFound)) + ); +} + +#[test] +fn test_get_yield_at_overflow_surfaces_math_error() { + let env = Env::default(); + let start = 1_000_000u64; + env.ledger().set_timestamp(start); + let (client, _token, owner, _bene, _cid) = + setup_yield_plan(&env, 1_000_000, true, safe_math::MAX_YIELD_RATE_BPS); + + assert_eq!( + client.try_get_yield_at(&owner, &(start + 36_500 * DAY)), + Err(Ok(Error::MathOverflow)) + ); +}