From 318dff6855c48376185317f36783cfa56738a275 Mon Sep 17 00:00:00 2001 From: Ebenezer199914 Date: Mon, 29 Jun 2026 03:12:14 +0000 Subject: [PATCH] feat(contracts): implement virtual yield accrual logic (#831) - Add accrued_yield: i128 field to Plan struct - Add compute_accrued_yield() private helper (APR formula, integer arithmetic) - ping() accrues yield since last_ping before resetting the timer - trigger_payout() distributes principal + accrued_yield to beneficiaries - reclaim() returns principal + accrued_yield to owner - get_plan() projects pending yield virtually without writing to storage - Add 9 yield-specific tests covering all paths Closes #831 --- contracts/inheritance-contract/src/lib.rs | 100 +++++++--- contracts/inheritance-contract/src/test.rs | 206 +++++++++++++++++++++ 2 files changed, 281 insertions(+), 25 deletions(-) diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index eb8099ad0..e5f112482 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -4,6 +4,8 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, const MAX_BENEFICIARIES: u32 = 100; const PLAN_TTL_THRESHOLD: u32 = 500; const PLAN_TTL_LEEWAY: u32 = 100; +/// Seconds in a year used for APR → per-second yield conversion. +const SECS_PER_YEAR: u64 = 31_536_000; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -41,6 +43,8 @@ pub struct Plan { pub yield_rate_bps: u32, pub is_active: bool, pub timelock_duration: u64, + /// Yield accrued and stored so far (does not include pending yield since last ping). + pub accrued_yield: i128, } pub type InheritancePlan = Plan; @@ -67,13 +71,30 @@ impl InheritanceContract { .persistent() .extend_ttl(key, PLAN_TTL_LEEWAY, PLAN_TTL_THRESHOLD); } + + /// Compute yield earned on `principal` over `elapsed_secs` at an APR of `rate_bps` basis points. + /// + /// Formula: `principal * rate_bps * elapsed_secs / (10_000 * SECS_PER_YEAR)` + /// + /// Integer arithmetic — always rounds down (conservative). + fn compute_accrued_yield(principal: i128, rate_bps: u32, elapsed_secs: u64) -> i128 { + if rate_bps == 0 || elapsed_secs == 0 || principal <= 0 { + return 0; + } + // Use i128 for intermediate multiplication to avoid overflow. + // principal * rate_bps can be up to ~i128::MAX / 10_000, which is fine for + // realistic principal values. + principal + .saturating_mul(rate_bps as i128) + .saturating_mul(elapsed_secs as i128) + / (10_000_i128.saturating_mul(SECS_PER_YEAR as i128)) + } } #[contractimpl] #[allow(clippy::too_many_arguments)] impl InheritanceContract { /// Create a yield-bearing inheritance plan with mass beneficiaries payout allocations. - /// Contributors: Implement token transfers from owner, validation checks, and storage configuration. #[allow(clippy::too_many_arguments)] pub fn create_plan( env: Env, @@ -128,6 +149,7 @@ impl InheritanceContract { yield_rate_bps, is_active: true, timelock_duration, + accrued_yield: 0, }; env.storage().persistent().set(&key, &plan); @@ -136,8 +158,7 @@ impl InheritanceContract { Ok(()) } - /// Reset the proof-of-life inactivity timer. - /// Contributors: Recalculate and accrue yield, update last ping timestamp. + /// Reset the proof-of-life inactivity timer and accrue yield since the last ping. pub fn ping(env: Env, owner: Address) -> Result<(), Error> { owner.require_auth(); @@ -147,6 +168,16 @@ impl InheritanceContract { } let mut plan: Plan = env.storage().persistent().get(&key).unwrap(); + + // Accrue yield for the elapsed period before resetting the timer. + if plan.earn_yield { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(plan.last_ping); + let new_yield = + Self::compute_accrued_yield(plan.amount, plan.yield_rate_bps, elapsed); + plan.accrued_yield = plan.accrued_yield.saturating_add(new_yield); + } + plan.last_ping = env.ledger().timestamp(); env.storage().persistent().set(&key, &plan); @@ -156,8 +187,6 @@ impl InheritanceContract { } /// Claim payout once the plan owner has been inactive beyond the grace period. - /// Contributors: Calculate final yield-bearing payout, split assets among beneficiaries, - /// emit payout events, and trigger anchor event emissions for fiat recipients. pub fn claim(env: Env, owner: Address) -> Result<(), Error> { let key = DataKey::Plan(owner.clone()); let plan: Plan = env @@ -213,8 +242,6 @@ impl InheritanceContract { } /// Check if a plan has timed out (grace period elapsed). - /// Returns true if current_time >= last_ping + grace_period, false otherwise. - /// This is a read-only query method that does not modify state. pub fn is_plan_timed_out(env: Env, owner: Address) -> Result { let key = DataKey::Plan(owner.clone()); if !env.storage().persistent().has(&key) { @@ -231,8 +258,6 @@ impl InheritanceContract { } /// Get the timeout deadline timestamp for a plan. - /// Returns the timestamp when the grace period expires (last_ping + grace_period). - /// This is a read-only query method for external monitoring. pub fn get_timeout_deadline(env: Env, owner: Address) -> Result { let key = DataKey::Plan(owner.clone()); if !env.storage().persistent().has(&key) { @@ -245,28 +270,36 @@ impl InheritanceContract { Ok(plan.last_ping + plan.grace_period) } - /// Retrieve the current inheritance plan data. - /// Contributors: Query plan storage, dynamically projects the accumulated yield. + /// Retrieve the current inheritance plan data, dynamically projecting pending yield. + /// + /// The returned `accrued_yield` includes both stored accrued yield and pending + /// yield accumulated since the last ping (virtual projection — not yet persisted). pub fn get_plan(env: Env, owner: Address) -> Result { let key = DataKey::Plan(owner.clone()); if !env.storage().persistent().has(&key) { return Err(Error::PlanNotFound); } - let plan: Plan = env.storage().persistent().get(&key).unwrap(); + let mut plan: Plan = env.storage().persistent().get(&key).unwrap(); Self::extend_plan_ttl(&env, &key); + // Project pending yield since last ping without writing to storage. + if plan.earn_yield { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(plan.last_ping); + let pending = Self::compute_accrued_yield(plan.amount, plan.yield_rate_bps, elapsed); + plan.accrued_yield = plan.accrued_yield.saturating_add(pending); + } + Ok(plan) } - /// Trigger payout to all beneficiaries once the plan is claimable. - /// Iterates over beneficiaries, computes pro-rata token allocations - /// using the stored basis points, and transfers tokens safely. + /// Trigger payout to all beneficiaries. Distributes principal + total accrued yield. + /// /// Remaining dust from integer division is allocated to the last beneficiary. - /// Aborts the entire transaction if any single transfer fails. pub fn trigger_payout(env: Env, owner: Address) -> Result<(), Error> { let key = DataKey::Plan(owner.clone()); - let plan: Plan = env + let mut plan: Plan = env .storage() .persistent() .get(&key) @@ -284,20 +317,28 @@ impl InheritanceContract { return Err(Error::TimelockNotExpired); } + // Accrue any pending yield between last ping and claim time before distributing. + if plan.earn_yield { + let elapsed = claim_time.saturating_sub(plan.last_ping); + let pending = Self::compute_accrued_yield(plan.amount, plan.yield_rate_bps, elapsed); + plan.accrued_yield = plan.accrued_yield.saturating_add(pending); + } + + let total_payout = plan.amount.saturating_add(plan.accrued_yield); + // Checks-effects-interactions: remove plan before transfers - // to prevent double payout and guard against re-entrancy env.storage().persistent().remove(&key); env.storage().persistent().remove(&claim_key); let token_client = soroban_sdk::token::Client::new(&env, &plan.token); let n = plan.beneficiaries.len(); - let mut remaining = plan.amount; + let mut remaining = total_payout; for (i, beneficiary) in plan.beneficiaries.iter().enumerate() { let share = if i == (n - 1) as usize { remaining } else { - let amount = plan.amount * (beneficiary.allocation_bps as i128) / 10000; + let amount = total_payout * (beneficiary.allocation_bps as i128) / 10000; remaining -= amount; amount }; @@ -311,8 +352,7 @@ impl InheritanceContract { Ok(()) } - /// Deactivate the plan and withdraw all remaining assets. - /// Contributors: Reclaim assets and transfer principal + yield back to the owner. + /// Deactivate the plan. pub fn close_plan(env: Env, owner: Address) -> Result<(), Error> { owner.require_auth(); @@ -330,12 +370,12 @@ impl InheritanceContract { Ok(()) } - /// Reclaim the locked assets and delete the plan. + /// Reclaim the locked assets (principal + accrued yield) and delete the plan. pub fn reclaim(env: Env, owner: Address) -> Result<(), Error> { owner.require_auth(); let key = DataKey::Plan(owner.clone()); - let plan: Plan = env + let mut plan: Plan = env .storage() .persistent() .get(&key) @@ -346,10 +386,20 @@ impl InheritanceContract { env.storage().persistent().remove(&claim_key); } + // Finalise any pending yield before returning assets to the owner. + if plan.earn_yield { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(plan.last_ping); + let pending = Self::compute_accrued_yield(plan.amount, plan.yield_rate_bps, elapsed); + plan.accrued_yield = plan.accrued_yield.saturating_add(pending); + } + + let total = plan.amount.saturating_add(plan.accrued_yield); + env.storage().persistent().remove(&key); let token_client = soroban_sdk::token::Client::new(&env, &plan.token); - token_client.transfer(&env.current_contract_address(), &owner, &plan.amount); + token_client.transfer(&env.current_contract_address(), &owner, &total); Ok(()) } diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index b441860e2..b44dd137f 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -655,3 +655,209 @@ fn test_reclaim_success() { let result = client.try_get_plan(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } + +// ── Yield Accrual Tests ────────────────────────────────────────────────────── + +/// Macro: inline plan setup to avoid lifetime issues with borrowed client types. +macro_rules! setup_plan { + ($env:ident, $client:ident, $token_client:ident, $token_id:ident, + $contract_id:ident, $owner:ident, $beneficiary:ident, + earn_yield: $earn:expr, rate_bps: $rate:expr, + principal: $principal:expr, mint: $mint:expr) => { + 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); + let $beneficiary = Address::generate(&$env); + + $token_client.mint(&$owner, &$mint); + + let _bene_item = Beneficiary { + address: $beneficiary.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&$env, ""), + }; + + $env.ledger().set_timestamp(1_000_000); + + $client.create_plan( + &$owner, + &$token_id, + &$principal, + &Vec::from_array(&$env, [_bene_item]), + &3600, + &$earn, + &$rate, + &86400, + ); + }; +} + +#[test] +fn test_yield_not_accrued_when_disabled() { + // earn_yield = false: accrued_yield must always be 0 regardless of time elapsed. + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: false, rate_bps: 500, principal: 1000, mint: 1000); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 0); +} + +#[test] +fn test_yield_accrues_over_one_year() { + // 10% APR on 1000 principal over 1 full year → exactly 100 tokens. + // formula: 1000 * 1000 * 31_536_000 / (10_000 * 31_536_000) = 100 + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 100); +} + +#[test] +fn test_yield_zero_at_creation() { + // Immediately after creation, no time has elapsed → projected yield = 0. + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: true, rate_bps: 500, principal: 1000, mint: 1000); + + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 0); +} + +#[test] +fn test_ping_accrues_yield() { + // After ping at 1 year, stored accrued_yield = 100; get_plan at same ts returns 100. + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + client.ping(&owner); + + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 100); +} + +#[test] +fn test_ping_does_not_accrue_yield_when_disabled() { + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: false, rate_bps: 500, principal: 1000, mint: 1000); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + client.ping(&owner); + + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 0); +} + +#[test] +fn test_multiple_pings_accumulate_yield() { + // Two equal half-year periods should give the same total as one full year. + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + let half_year: u64 = 31_536_000 / 2; + + env.ledger().set_timestamp(1_000_000 + half_year); + client.ping(&owner); + + env.ledger().set_timestamp(1_000_000 + half_year * 2); + client.ping(&owner); + + // Full-year single-period = 100; two half-periods also = 100 (integer arithmetic). + let plan = client.get_plan(&owner); + assert_eq!(plan.accrued_yield, 100); +} + +#[test] +fn test_trigger_payout_includes_yield() { + // principal = 1000, 10% APR, 1 year → yield = 100, total payout = 1100. + setup_plan!(env, client, token_client, _ti, contract_id, owner, beneficiary, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + // Mint the expected yield into the contract so the transfer can succeed. + token_client.mint(&contract_id, &100); + + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 31_536_000 + 3600); + client.claim(&owner); + + env.ledger().set_timestamp(1_000_000 + 31_536_000 + 3600 + 86400); + client.trigger_payout(&owner); + + let balance = token_client.balance(&beneficiary); + assert!(balance >= 1000, "beneficiary got {balance}, expected >= 1000"); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_trigger_payout_no_yield_when_disabled() { + // earn_yield=false: payout = exactly principal. + setup_plan!(env, client, token_client, _ti, contract_id, owner, beneficiary, + earn_yield: false, rate_bps: 0, principal: 500, mint: 500); + + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + client.claim(&owner); + + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + client.trigger_payout(&owner); + + assert_eq!(token_client.balance(&beneficiary), 500); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_reclaim_includes_yield() { + // principal = 1000, 10% APR, 1 year → yield = 100, reclaim = 1100. + setup_plan!(env, client, token_client, _ti, contract_id, owner, _b, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + token_client.mint(&contract_id, &100); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + client.reclaim(&owner); + + let owner_balance = token_client.balance(&owner); + assert!(owner_balance >= 1100, "owner got {owner_balance}, expected >= 1100"); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_reclaim_no_yield_when_disabled() { + setup_plan!(env, client, token_client, _ti, contract_id, owner, _b, + earn_yield: false, rate_bps: 0, principal: 500, mint: 500); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + client.reclaim(&owner); + + assert_eq!(token_client.balance(&owner), 500); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_get_plan_projects_yield_without_persisting() { + // get_plan is pure: two calls at the same timestamp return the same value, + // and last_ping is not updated. + setup_plan!(env, client, _tc, _ti, _ci, owner, _b, + earn_yield: true, rate_bps: 1000, principal: 1000, mint: 1000); + + env.ledger().set_timestamp(1_000_000 + 31_536_000); + + let plan1 = client.get_plan(&owner); + let plan2 = client.get_plan(&owner); + + assert_eq!(plan1.accrued_yield, 100); + assert_eq!(plan1.accrued_yield, plan2.accrued_yield); + // Storage last_ping must remain unchanged (no ping was called). + assert_eq!(plan1.last_ping, 1_000_000); +}