diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index a9fdef648..544598110 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -34,6 +34,11 @@ const EMERGENCY_COOLDOWN_PERIOD: u64 = 86400; const MIN_GRACE_PERIOD_SECONDS: u64 = 604_800; const MAX_GRACE_PERIOD_SECONDS: u64 = 5 * 365 * 24 * 60 * 60; +/// How long a primary beneficiary has to claim after inheritance is +/// triggered ("post-expiration") before their configured contingency +/// address becomes eligible to claim in their place. 180 days. +const CONTINGENCY_CLAIM_DELAY: u64 = 180 * 24 * 60 * 60; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DistributionMethod { @@ -53,6 +58,14 @@ pub struct Beneficiary { pub allocation_bp: u32, // Allocation in basis points (0-10000, where 10000 = 100%) pub priority: u32, // Priority level (1=highest) pub is_claimed: bool, // Whether the beneficiary has already claimed their portion + // Optional secondary/contingency payout address. If the primary + // beneficiary never claims (invalid, deactivated, or simply + // unreachable) this address can claim the allocation once the + // contingency window has elapsed after inheritance is triggered. + pub contingency_address: Option
, + // Set to true once the allocation was paid out to `contingency_address` + // instead of the primary beneficiary, for auditability. + pub claimed_via_contingency: bool, } #[contracttype] @@ -64,6 +77,8 @@ pub struct BeneficiaryInput { pub bank_account: Bytes, pub allocation_bp: u32, pub priority: u32, + // Optional secondary contingency address for this beneficiary. + pub contingency_address: Option
, } #[contracttype] @@ -842,7 +857,8 @@ pub struct CreateInheritancePlanParams { pub description: String, pub total_amount: u64, pub distribution_method: DistributionMethod, - pub beneficiaries_data: Vec<(String, String, u32, Bytes, u32, u32)>, + // Tuple: (full_name, email, claim_code, bank_account, allocation_bp, priority, contingency_address) + pub beneficiaries_data: Vec<(String, String, u32, Bytes, u32, u32, Option
)>, pub is_lendable: bool, } @@ -1563,6 +1579,7 @@ impl InheritanceContract { bank_account: Bytes, allocation_bp: u32, priority: u32, + contingency_address: Option
, ) -> Result { // Validate inputs if full_name.is_empty() || email.is_empty() || bank_account.is_empty() { @@ -1591,6 +1608,8 @@ impl InheritanceContract { allocation_bp, priority, is_claimed: false, + contingency_address, + claimed_via_contingency: false, }) } @@ -1627,7 +1646,7 @@ impl InheritanceContract { pub fn validate_beneficiaries( env: &Env, - beneficiaries_data: Vec<(String, String, u32, Bytes, u32, u32)>, + beneficiaries_data: Vec<(String, String, u32, Bytes, u32, u32, Option
)>, ) -> Result<(), InheritanceError> { // Validate beneficiary count (max 10) if beneficiaries_data.len() > 10 { @@ -1643,7 +1662,7 @@ impl InheritanceContract { let mut priorities = Vec::new(env); let mut emails = Vec::new(env); - for (name, email, _, _, bp, priority) in beneficiaries_data.iter() { + for (name, email, _, _, bp, priority, _contingency) in beneficiaries_data.iter() { // Issue #961: Require non-empty beneficiary names if name.is_empty() { return Err(InheritanceError::InvalidBeneficiaryData); @@ -2114,6 +2133,7 @@ impl InheritanceContract { beneficiary_input.bank_account, beneficiary_input.allocation_bp, beneficiary_input.priority, + beneficiary_input.contingency_address, )?; // Add beneficiary to plan @@ -2352,6 +2372,7 @@ impl InheritanceContract { beneficiary_data.3.clone(), beneficiary_data.4, beneficiary_data.5, + beneficiary_data.6.clone(), )?; total_allocation_bp += beneficiary_data.4; beneficiaries.push_back(beneficiary); @@ -2466,11 +2487,11 @@ impl InheritanceContract { /// - PlanNotFound: If plan_id doesn't exist /// - InheritanceAlreadyTriggered: If a claim has already been triggered /// - Other validation errors from validate_beneficiaries - pub fn update_plan( + pub fn update_plan( env: Env, owner: Address, plan_id: u64, - beneficiaries: Vec<(String, String, u32, Bytes, u32, u32)>, + beneficiaries: Vec<(String, String, u32, Bytes, u32, u32, Option
)>, grace_period: u64, earn_yield: bool, ) -> Result<(), InheritanceError> { @@ -2499,6 +2520,15 @@ impl InheritanceContract { let mut total_allocation_bp = 0u32; let mut idx: u32 = 0; for beneficiary_data in beneficiaries.iter() { + // If the caller didn't supply a contingency address for this + // index, preserve any existing contingency address so updates + // don't accidentally drop it. + let fallback_contingency = plan + .beneficiaries + .get(idx) + .and_then(|b| b.contingency_address); + let contingency = beneficiary_data.6.clone().or(fallback_contingency); + let beneficiary = Self::create_beneficiary( &env, plan_id, @@ -2509,6 +2539,7 @@ impl InheritanceContract { beneficiary_data.3.clone(), beneficiary_data.4, beneficiary_data.5, + contingency, )?; total_allocation_bp += beneficiary_data.4; new_beneficiaries.push_back(beneficiary); @@ -3046,6 +3077,178 @@ impl InheritanceContract { Ok(()) } + /// Whether the contingency fallback for a beneficiary is currently + /// claimable: inheritance must be triggered, the primary beneficiary + /// must not have claimed yet, a contingency address must be configured, + /// and at least `CONTINGENCY_CLAIM_DELAY` must have elapsed since the + /// trigger ("post-expiration"). + pub fn is_contingency_claim_available( + env: Env, + plan_id: u64, + beneficiary_index: u32, + ) -> Result { + let plan = Self::get_plan(&env, plan_id).ok_or(InheritanceError::PlanNotFound)?; + if beneficiary_index >= plan.beneficiaries.len() { + return Err(InheritanceError::InvalidBeneficiaryIndex); + } + let beneficiary = plan.beneficiaries.get(beneficiary_index).unwrap(); + if beneficiary.is_claimed || beneficiary.contingency_address.is_none() { + return Ok(false); + } + let trigger_info = match Self::get_trigger_info(&env, plan_id) { + Some(t) => t, + None => return Ok(false), + }; + let now = env.ledger().timestamp(); + Ok(now >= trigger_info.triggered_at.saturating_add(CONTINGENCY_CLAIM_DELAY)) + } + + /// Claim a beneficiary's allocation via their configured contingency + /// (secondary) address after the primary beneficiary has failed to claim + /// within `CONTINGENCY_CLAIM_DELAY` of inheritance being triggered. + pub fn claim_via_contingency( + env: Env, + plan_id: u64, + beneficiary_index: u32, + contingency_claimer: Address, + ) -> Result<(), InheritanceError> { + contingency_claimer.require_auth(); + Self::check_not_paused(&env); + Self::enter_guard(&env); + + let plan = Self::get_plan(&env, plan_id).ok_or(InheritanceError::PlanNotFound)?; + + if !plan.is_active { + return Err(InheritanceError::PlanNotActive); + } + if env.storage().persistent().has(&DataKey::Fz(plan_id)) { + return Err(InheritanceError::PlanNotActive); + } + if env.storage().persistent().has(&DataKey::Lh(plan_id)) { + return Err(InheritanceError::PlanNotActive); + } + + if beneficiary_index >= plan.beneficiaries.len() { + return Err(InheritanceError::InvalidBeneficiaryIndex); + } + + if env + .storage() + .persistent() + .get::(&DataKey::Fb(plan_id, beneficiary_index)) + .unwrap_or(false) + { + return Err(InheritanceError::Unauthorized); + } + + let beneficiary = plan.beneficiaries.get(beneficiary_index).unwrap(); + + let expected_contingency = beneficiary + .contingency_address + .clone() + .ok_or(InheritanceError::BeneficiaryNotFound)?; + if expected_contingency != contingency_claimer { + return Err(InheritanceError::Unauthorized); + } + + if beneficiary.is_claimed { + return Err(InheritanceError::AlreadyClaimed); + } + + // Bring trigger state up to date before checking post-expiration window + let _ = Self::auto_trigger_check(env.clone(), plan_id); + + let trigger_info = Self::get_trigger_info(&env, plan_id) + .ok_or(InheritanceError::InheritanceNotTriggered)?; + + let now = env.ledger().timestamp(); + let unlock_at = trigger_info + .triggered_at + .saturating_add(CONTINGENCY_CLAIM_DELAY); + if now < unlock_at { + return Err(InheritanceError::ContingencyClaimNotAllowedYet); + } + + // Build claim key using plan id and beneficiary hashed email + let claim_key = { + let mut data = Bytes::new(&env); + data.extend_from_slice(&plan_id.to_be_bytes()); + data.extend_from_slice(&beneficiary.hashed_email.to_array()); + DataKey::C(env.crypto().sha256(&data).into()) + }; + if env.storage().persistent().has(&claim_key) { + return Err(InheritanceError::AlreadyClaimed); + } + + let mut payout = Self::calculate_waterfall_payout(&env, &plan, beneficiary_index); + let exit_settlement = Self::get_vesting_exit_settlement(&env, plan_id, beneficiary_index); + if exit_settlement > 0 { + payout = payout.min(exit_settlement); + } + + if payout == 0 { + return Err(InheritanceError::NothingToClaim); + } + + let mut updated_plan = plan.clone(); + let exit_remaining_after = exit_settlement.saturating_sub(payout); + let exit_finalized = exit_settlement == 0 || exit_remaining_after == 0; + + let mut b = updated_plan.beneficiaries.get(beneficiary_index).unwrap(); + if exit_finalized { + b.is_claimed = true; + b.claimed_via_contingency = true; + } + updated_plan.beneficiaries.set(beneficiary_index, b); + updated_plan.total_amount = updated_plan.total_amount.saturating_sub(payout); + Self::store_plan(&env, plan_id, &updated_plan); + + if exit_settlement > 0 { + let settle_key = DataKey::Ves(plan_id, beneficiary_index); + if exit_remaining_after == 0 { + env.storage().persistent().remove(&settle_key); + } else { + env.storage() + .persistent() + .set(&settle_key, &exit_remaining_after); + } + } + + if exit_finalized { + let claim = ClaimRecord { + plan_id, + beneficiary_index, + claimed_at: now, + }; + env.storage().persistent().set(&claim_key, &claim); + Self::add_plan_to_claimed(&env, plan.owner.clone(), plan_id); + } + + access_control::assign_role(&env, &contingency_claimer, Role::Beneficiary); + + env.events().publish( + (symbol_short!("CLAIM"), symbol_short!("SUCCESS")), + (plan_id, beneficiary.hashed_email, payout), + ); + + if !beneficiary.bank_account.is_empty() { + env.events().publish( + (symbol_short!("F_PAYOUT"),), + (plan_id, beneficiary_index, payout, symbol_short!("BANK")), + ); + } + + log!( + &env, + "Inheritance plan {} beneficiary {} claimed via contingency address", + plan_id, + beneficiary_index + ); + + Self::exit_guard(&env); + Ok(()) + } + /// Record KYC submission on-chain (called after off-chain submission). pub fn submit_kyc(env: Env, user: Address) -> Result<(), InheritanceError> { user.require_auth(); @@ -5638,6 +5841,7 @@ impl InheritanceContract { input.bank_account.clone(), input.allocation_bp, input.priority, + input.contingency_address.clone(), ) { Ok(beneficiary) => { plan.total_allocation_bp = new_total;