From 3b9365c4891206c9ab647915fa9ad7b345c387dd Mon Sep 17 00:00:00 2001 From: DavidAkere204 Date: Sat, 27 Jun 2026 00:04:13 +0000 Subject: [PATCH] feat(contract): add multi-sig guardian validation for payout - Add guardians list and guardian_threshold fields to Plan struct - Add GuardianApprovals(Address) variant to DataKey for per-plan storage - Add approve_payout() function: validates caller is a listed guardian, prevents duplicate approvals, persists approvals, emits guardian/approved event - Update trigger_payout() to reject with GuardianThresholdNotMet when approvals count < guardian_threshold (skipped when threshold is 0) - Add NotAGuardian, AlreadyApproved, GuardianThresholdNotMet errors - Update create_plan() to accept guardians and guardian_threshold params - Add 4 new tests: 2-of-3 approval flow, duplicate rejection, non-guardian rejection, and zero-guardian passthrough Closes #840 --- contracts/inheritance-contract/src/lib.rs | 81 ++++++- contracts/inheritance-contract/src/test.rs | 243 ++++++++++++++++++--- 2 files changed, 296 insertions(+), 28 deletions(-) diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index 0760c51c2..1a20bfc12 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec}; const MAX_BENEFICIARIES: u32 = 100; const PLAN_TTL_THRESHOLD: u32 = 500; @@ -18,6 +18,9 @@ pub enum Error { NegativeAmount = 6, InsufficientBalance = 7, TooManyBeneficiaries = 8, + GuardianThresholdNotMet = 9, + AlreadyApproved = 10, + NotAGuardian = 11, } #[contracttype] @@ -40,6 +43,8 @@ pub struct Plan { pub earn_yield: bool, pub yield_rate_bps: u32, pub is_active: bool, + pub guardians: Vec
, + pub guardian_threshold: u32, } pub type InheritancePlan = Plan; @@ -49,6 +54,7 @@ pub type InheritancePlan = Plan; pub enum DataKey { Plan(Address), ClaimStatus(Address), + GuardianApprovals(Address), } #[contracttype] @@ -89,6 +95,8 @@ impl InheritanceContract { grace_period: u64, earn_yield: bool, yield_rate_bps: u32, + guardians: Vec
, + guardian_threshold: u32, ) -> Result<(), Error> { owner.require_auth(); @@ -131,6 +139,8 @@ impl InheritanceContract { earn_yield, yield_rate_bps, is_active: true, + guardians, + guardian_threshold, }; env.storage().persistent().set(&key, &plan); @@ -185,6 +195,57 @@ impl InheritanceContract { Ok(()) } + /// Guardian approves the payout for a plan. Once the threshold is reached, + /// trigger_payout can proceed. Emits a guardian_approved event on each approval. + pub fn approve_payout(env: Env, guardian: Address, owner: Address) -> Result<(), Error> { + guardian.require_auth(); + + let plan_key = DataKey::Plan(owner.clone()); + let plan: Plan = env + .storage() + .persistent() + .get(&plan_key) + .ok_or(Error::PlanNotFound)?; + + // Verify caller is a listed guardian + let mut is_guardian = false; + for g in plan.guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + return Err(Error::NotAGuardian); + } + + let approvals_key = DataKey::GuardianApprovals(owner.clone()); + let mut approvals: Vec
= env + .storage() + .persistent() + .get(&approvals_key) + .unwrap_or_else(|| Vec::new(&env)); + + // Prevent duplicate approvals + for a in approvals.iter() { + if a == guardian { + return Err(Error::AlreadyApproved); + } + } + + approvals.push_back(guardian.clone()); + env.storage().persistent().set(&approvals_key, &approvals); + Self::extend_plan_ttl(&env, &approvals_key); + + // Emit guardian approval event + env.events().publish( + (symbol_short!("guardian"), symbol_short!("approved")), + (owner.clone(), guardian.clone(), approvals.len()), + ); + + Ok(()) + } + /// Retrieve the current inheritance plan data. /// Contributors: Query plan storage, dynamically projects the accumulated yield. pub fn get_plan(env: Env, owner: Address) -> Result { @@ -200,6 +261,7 @@ impl InheritanceContract { } /// Trigger payout to all beneficiaries once the plan is claimable. + /// Requires guardian threshold to be met if guardians are configured. /// Iterates over beneficiaries, computes pro-rata token allocations /// using the stored basis points, and transfers tokens safely. /// Remaining dust from integer division is allocated to the last beneficiary. @@ -221,6 +283,23 @@ impl InheritanceContract { return Err(Error::InactivityPeriodNotMet); } + // Validate guardian threshold if guardians are set + if plan.guardian_threshold > 0 { + let approvals_key = DataKey::GuardianApprovals(owner.clone()); + let approvals: Vec
= env + .storage() + .persistent() + .get(&approvals_key) + .unwrap_or_else(|| Vec::new(&env)); + + if approvals.len() < plan.guardian_threshold { + return Err(Error::GuardianThresholdNotMet); + } + + // Clean up approvals storage + env.storage().persistent().remove(&approvals_key); + } + // Checks-effects-interactions: remove plan before transfers // to prevent double payout and guard against re-entrancy env.storage().persistent().remove(&key); diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index 3765cc18b..7110f4ab0 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -18,18 +18,15 @@ fn test_create_plan_success() { let env = Env::default(); env.mock_all_auths(); - // Register our contract let contract_id = env.register_contract(None, InheritanceContract); let client = InheritanceContractClient::new(&env, &contract_id); - // Register mock token contract 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); - // Mint tokens to owner token_client.mint(&owner, &2000); let beneficiary = Beneficiary { @@ -46,13 +43,13 @@ fn test_create_plan_success() { &3600, &true, &500, + &Vec::new(&env), + &0, ); - // Verify balances assert_eq!(token_client.balance(&owner), 500); assert_eq!(token_client.balance(&contract_id), 1500); - // Verify stored plan let plan = client.get_plan(&owner); assert_eq!(plan.owner, owner); assert_eq!(plan.token, token_id); @@ -89,7 +86,6 @@ fn test_create_plan_insufficient_balance() { fiat_anchor_info: String::from_str(&env, "NGN_BANK"), }; - // Attempting to create plan for 1500 (owner only has 1000) let result = client.try_create_plan( &owner, &token_id, @@ -98,6 +94,8 @@ fn test_create_plan_insufficient_balance() { &3600, &true, &500, + &Vec::new(&env), + &0, ); assert_eq!(result, Err(Ok(Error::InsufficientBalance))); @@ -123,7 +121,6 @@ fn test_create_plan_negative_or_zero_amount() { fiat_anchor_info: String::from_str(&env, "NGN_BANK"), }; - // Amount = 0 let result_zero = client.try_create_plan( &owner, &token_id, @@ -132,10 +129,11 @@ fn test_create_plan_negative_or_zero_amount() { &3600, &true, &500, + &Vec::new(&env), + &0, ); assert_eq!(result_zero, Err(Ok(Error::NegativeAmount))); - // Amount = -10 let result_neg = client.try_create_plan( &owner, &token_id, @@ -144,6 +142,8 @@ fn test_create_plan_negative_or_zero_amount() { &3600, &true, &500, + &Vec::new(&env), + &0, ); assert_eq!(result_neg, Err(Ok(Error::NegativeAmount))); } @@ -170,7 +170,7 @@ fn test_create_plan_invalid_basis_points() { let beneficiary2 = Beneficiary { address: Address::generate(&env), - allocation_bps: 5000, // Total = 9000 BPS (less than 10000) + allocation_bps: 5000, fiat_anchor_info: String::from_str(&env, "NGN_BANK"), }; @@ -182,6 +182,8 @@ fn test_create_plan_invalid_basis_points() { &3600, &true, &500, + &Vec::new(&env), + &0, ); assert_eq!(result, Err(Ok(Error::InvalidBasisPoints))); @@ -207,7 +209,6 @@ fn test_create_plan_already_exists() { fiat_anchor_info: String::from_str(&env, "NGN_BANK"), }; - // First creation client.create_plan( &owner, &token_id, @@ -216,9 +217,10 @@ fn test_create_plan_already_exists() { &3600, &true, &500, + &Vec::new(&env), + &0, ); - // Second creation on same owner let result2 = client.try_create_plan( &owner, &token_id, @@ -227,6 +229,8 @@ fn test_create_plan_already_exists() { &3600, &true, &500, + &Vec::new(&env), + &0, ); assert_eq!(result2, Err(Ok(Error::PlanAlreadyExists))); } @@ -264,22 +268,18 @@ fn test_trigger_payout_single_beneficiary() { &3600, &true, &500, + &Vec::new(&env), + &0, ); - // Deactivate plan client.close_plan(&owner); - - // Jump past grace period env.ledger().set_timestamp(start + 4000); - // Trigger payout client.trigger_payout(&owner); - // Beneficiary receives full amount, contract emptied assert_eq!(token_client.balance(&beneficiary), 1500); assert_eq!(token_client.balance(&contract_id), 0); - // Plan removed from storage let result = client.try_get_plan(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } @@ -328,6 +328,8 @@ fn test_trigger_payout_multiple_beneficiaries() { &3600, &true, &500, + &Vec::new(&env), + &0, ); client.close_plan(&owner); @@ -335,11 +337,8 @@ fn test_trigger_payout_multiple_beneficiaries() { client.trigger_payout(&owner); - // Alice: 1000 * 5000 / 10000 = 500 assert_eq!(token_client.balance(&alice), 500); - // Bob: 1000 * 3000 / 10000 = 300 assert_eq!(token_client.balance(&bob), 300); - // Charlie: remaining = 1000 - 500 - 300 = 200 assert_eq!(token_client.balance(&charlie), 200); assert_eq!(token_client.balance(&contract_id), 0); } @@ -382,6 +381,8 @@ fn test_trigger_payout_dust_goes_to_last_beneficiary() { &3600, &false, &0, + &Vec::new(&env), + &0, ); client.close_plan(&owner); @@ -389,9 +390,7 @@ fn test_trigger_payout_dust_goes_to_last_beneficiary() { client.trigger_payout(&owner); - // A: 100 * 3333 / 10000 = 33 (integer truncation) assert_eq!(token_client.balance(&a), 33); - // B: remaining = 100 - 33 = 67 (not 66, so dust is captured) assert_eq!(token_client.balance(&b), 67); assert_eq!(token_client.balance(&contract_id), 0); } @@ -428,9 +427,10 @@ fn test_trigger_payout_plan_still_active() { &3600, &false, &0, + &Vec::new(&env), + &0, ); - // Plan is still active — close_plan was never called env.ledger().set_timestamp(1_000_000 + 4000); let result = client.try_trigger_payout(&owner); @@ -469,11 +469,11 @@ fn test_trigger_payout_grace_period_not_met() { &3600, &false, &0, + &Vec::new(&env), + &0, ); client.close_plan(&owner); - - // Only 1000 seconds passed — need 3600 env.ledger().set_timestamp(1_000_000 + 1000); let result = client.try_trigger_payout(&owner); @@ -512,16 +512,16 @@ fn test_trigger_payout_double_payout_prevented() { &3600, &false, &0, + &Vec::new(&env), + &0, ); client.close_plan(&owner); env.ledger().set_timestamp(1_000_000 + 4000); - // First payout succeeds client.trigger_payout(&owner); assert_eq!(token_client.balance(&beneficiary), 500); - // Second payout fails — plan already removed let result = client.try_trigger_payout(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } @@ -539,3 +539,192 @@ fn test_trigger_payout_no_plan() { let result = client.try_trigger_payout(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } + +// ── Guardian multi-sig tests ────────────────────────────────────────────────── + +#[test] +fn test_guardian_approve_and_trigger_payout() { + 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); + let guardian1 = Address::generate(&env); + let guardian2 = Address::generate(&env); + let guardian3 = Address::generate(&env); + + token_client.mint(&owner, &1000); + + let b = Beneficiary { + address: beneficiary.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + env.ledger().set_timestamp(1_000_000); + + // 2-of-3 guardian threshold + client.create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + &Vec::from_array(&env, [guardian1.clone(), guardian2.clone(), guardian3.clone()]), + &2, + ); + + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + // Only 1 approval — payout should fail + client.approve_payout(&guardian1, &owner); + let result = client.try_trigger_payout(&owner); + assert_eq!(result, Err(Ok(Error::GuardianThresholdNotMet))); + + // Second approval — threshold met, payout succeeds + client.approve_payout(&guardian2, &owner); + client.trigger_payout(&owner); + + assert_eq!(token_client.balance(&beneficiary), 1000); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_guardian_duplicate_approval_rejected() { + 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); + let guardian = Address::generate(&env); + + token_client.mint(&owner, &500); + + let b = 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, + &500, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + &Vec::from_array(&env, [guardian.clone()]), + &1, + ); + + client.approve_payout(&guardian, &owner); + + // Second approval by same guardian should fail + let result = client.try_approve_payout(&guardian, &owner); + assert_eq!(result, Err(Ok(Error::AlreadyApproved))); +} + +#[test] +fn test_non_guardian_approval_rejected() { + 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); + let guardian = Address::generate(&env); + let stranger = Address::generate(&env); + + token_client.mint(&owner, &500); + + let b = 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, + &500, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + &Vec::from_array(&env, [guardian.clone()]), + &1, + ); + + let result = client.try_approve_payout(&stranger, &owner); + assert_eq!(result, Err(Ok(Error::NotAGuardian))); +} + +#[test] +fn test_no_guardians_payout_skips_threshold_check() { + 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, &500); + + let b = Beneficiary { + address: beneficiary.clone(), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, ""), + }; + + env.ledger().set_timestamp(1_000_000); + + // No guardians, threshold = 0 + client.create_plan( + &owner, + &token_id, + &500, + &Vec::from_array(&env, [b]), + &3600, + &false, + &0, + &Vec::new(&env), + &0, + ); + + client.close_plan(&owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + // Should succeed without any guardian approvals + client.trigger_payout(&owner); + assert_eq!(token_client.balance(&beneficiary), 500); +}