diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index c99f06d2c..9caa369ba 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -316,11 +316,11 @@ impl InheritanceContract { Ok(()) } - /// Deactivate the plan and withdraw all remaining assets. - /// Contributors: Reclaim assets and transfer principal + yield back to the owner. - pub fn close_plan(env: Env, owner: Address) -> Result<(), Error> { - owner.require_auth(); - + /// Deactivate a plan to start the inactivity grace period. + /// Used internally by claim logic. This does NOT refund tokens. + /// The plan owner can call close_plan() for an early refund. + #[allow(dead_code)] + fn deactivate_plan(env: &Env, owner: &Address) -> Result<(), Error> { let key = DataKey::Plan(owner.clone()); if !env.storage().persistent().has(&key) { return Err(Error::PlanNotFound); @@ -330,7 +330,33 @@ impl InheritanceContract { plan.is_active = false; env.storage().persistent().set(&key, &plan); - Self::extend_plan_ttl(&env, &key); + Self::extend_plan_ttl(env, &key); + + Ok(()) + } + + /// Cancel a plan early and withdraw all remaining assets. + /// Authenticates that the caller is the plan owner. + /// Transfers all locked tokens back to the owner and deletes the plan from storage. + pub fn close_plan(env: Env, owner: Address) -> Result<(), Error> { + owner.require_auth(); + + let key = DataKey::Plan(owner.clone()); + let plan: Plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::PlanNotFound)?; + + let claim_key = DataKey::ClaimStatus(owner.clone()); + if env.storage().persistent().has(&claim_key) { + env.storage().persistent().remove(&claim_key); + } + + 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); Ok(()) } diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index aa2517e9b..75b48c913 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -3,6 +3,20 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::{Events, Ledger}; use soroban_sdk::{symbol_short, vec, Address, Env, IntoVal, String, Vec}; +// Helper function to deactivate a plan for grace period testing +fn deactivate_plan_for_testing(env: &Env, contract_id: &Address, owner: &Address) { + let key = DataKey::Plan(owner.clone()); + let plan_option: Option = + env.as_contract(contract_id, || env.storage().persistent().get(&key)); + + if let Some(mut plan) = plan_option { + plan.is_active = false; + env.as_contract(contract_id, || { + env.storage().persistent().set(&key, &plan); + }); + } +} + #[test] fn test_contract_compilation() { let env = Env::default(); @@ -357,8 +371,8 @@ fn test_trigger_payout_single_beneficiary() { &86400, ); - // Deactivate plan - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); // Jump past grace period env.ledger().set_timestamp(start + 4000); @@ -424,7 +438,8 @@ fn test_trigger_payout_multiple_beneficiaries() { &86400, ); - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); env.ledger().set_timestamp(1_000_000 + 4000); client.claim(&owner); @@ -481,7 +496,8 @@ fn test_trigger_payout_dust_goes_to_last_beneficiary() { &86400, ); - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); env.ledger().set_timestamp(1_000_000 + 4000); client.claim(&owner); @@ -530,7 +546,7 @@ fn test_trigger_payout_plan_still_active() { &86400, ); - // Plan is still active — close_plan was never called + // Plan is still active — deactivate_plan_for_testing was never called env.ledger().set_timestamp(1_000_000 + 4000); let result = client.try_claim(&owner); @@ -572,7 +588,8 @@ fn test_trigger_payout_grace_period_not_met() { &86400, ); - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); // Only 1000 seconds passed — need 3600 env.ledger().set_timestamp(1_000_000 + 1000); @@ -616,7 +633,8 @@ fn test_trigger_payout_double_payout_prevented() { &86400, ); - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); env.ledger().set_timestamp(1_000_000 + 4000); // First payout succeeds @@ -680,7 +698,8 @@ fn test_cancel_claim_success() { &86400, ); - client.close_plan(&owner); + // Deactivate plan to start grace period + deactivate_plan_for_testing(&env, &contract_id, &owner); env.ledger().set_timestamp(start + 4000); // Trigger payout @@ -740,3 +759,565 @@ fn test_reclaim_success() { let result = client.try_get_plan(&owner); assert_eq!(result, Err(Ok(Error::PlanNotFound))); } + +// ============================================================================ +// Issue #843: Unit Tests for Keep-Alive Ping and Close_Plan +// ============================================================================ + +#[test] +fn test_ping_success_from_owner_updates_timestamp() { + 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 = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + token_client.mint(&owner, &5000); + + let start = 1_000_000; + env.ledger().set_timestamp(start); + + client.create_plan( + &owner, + &token_id, + &3000, + &Vec::from_array(&env, [beneficiary]), + &7200, + &true, + &500, + &86400, + ); + + // Verify initial ping timestamp + let plan = client.get_plan(&owner); + assert_eq!(plan.last_ping, start); + + // Owner pings at a later time + let ping_time = start + 5000; + env.ledger().set_timestamp(ping_time); + client.ping(&owner); + + // Verify timestamp is updated + let updated_plan = client.get_plan(&owner); + assert_eq!(updated_plan.last_ping, ping_time); + + // Owner is still within grace period + let timeout_deadline = client.try_get_timeout_deadline(&owner); + assert_eq!(timeout_deadline, Ok(Ok(ping_time + 7200))); +} + +#[test] +fn test_ping_from_third_party_fails() { + 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 third_party = Address::generate(&env); + let beneficiary = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + token_client.mint(&owner, &5000); + + let start = 1_000_000; + env.ledger().set_timestamp(start); + + client.create_plan( + &owner, + &token_id, + &2000, + &Vec::from_array(&env, [beneficiary]), + &3600, + &true, + &500, + &86400, + ); + + // Try to ping as third party without auth + env.mock_auths(&[]); + let result = client.try_ping(&third_party); + + // Should fail due to authorization check + assert!(result.is_err()); +} + +#[test] +fn test_ping_nonexistent_plan_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + + let result = client.try_ping(&owner); + assert_eq!(result, Err(Ok(Error::PlanNotFound))); +} + +#[test] +fn test_close_plan_refunds_all_tokens_and_deletes_storage() { + 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 beneficiary1 = Address::generate(&env); + let beneficiary2 = Address::generate(&env); + + let initial_balance = 10000; + token_client.mint(&owner, &initial_balance); + + let plan_amount = 6000; + let bene1 = Beneficiary { + address: beneficiary1, + allocation_bps: 5000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bene2 = Beneficiary { + address: beneficiary2, + allocation_bps: 5000, + fiat_anchor_info: String::from_str(&env, "EUR_BANK"), + }; + + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &plan_amount, + &Vec::from_array(&env, [bene1, bene2]), + &3600, + &false, + &0, + &86400, + ); + + // Verify tokens are transferred to contract + assert_eq!(token_client.balance(&owner), initial_balance - plan_amount); + assert_eq!(token_client.balance(&contract_id), plan_amount); + + // Close plan early - should refund all tokens and delete plan + client.close_plan(&owner); + + // Verify tokens are refunded to owner + assert_eq!(token_client.balance(&owner), initial_balance); + assert_eq!(token_client.balance(&contract_id), 0); + + // Verify plan is deleted from storage + let result = client.try_get_plan(&owner); + assert_eq!(result, Err(Ok(Error::PlanNotFound))); +} + +#[test] +fn test_close_plan_requires_owner_auth() { + 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 unauthorized_user = Address::generate(&env); + let beneficiary = Beneficiary { + address: Address::generate(&env), + allocation_bps: 10000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + token_client.mint(&owner, &5000); + + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &2000, + &Vec::from_array(&env, [beneficiary]), + &3600, + &false, + &0, + &86400, + ); + + // Try to close plan as unauthorized user + env.mock_auths(&[]); + let result = client.try_close_plan(&unauthorized_user); + + // Should fail due to authorization check + assert!(result.is_err()); +} + +#[test] +fn test_close_plan_nonexistent_plan_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, InheritanceContract); + let client = InheritanceContractClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + + let result = client.try_close_plan(&owner); + assert_eq!(result, Err(Ok(Error::PlanNotFound))); +} + +// ============================================================================ +// Issue #845: Unit Tests for Multi-Beneficiary Payout with Various Edge Cases +// ============================================================================ + +#[test] +fn test_trigger_payout_5_beneficiaries_with_equal_allocations() { + 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 b1 = Address::generate(&env); + let b2 = Address::generate(&env); + let b3 = Address::generate(&env); + let b4 = Address::generate(&env); + let b5 = Address::generate(&env); + + token_client.mint(&owner, &100000); + + // Each beneficiary gets 2000 BPS (20%) + let bene1 = Beneficiary { + address: b1.clone(), + allocation_bps: 2000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bene2 = Beneficiary { + address: b2.clone(), + allocation_bps: 2000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bene3 = Beneficiary { + address: b3.clone(), + allocation_bps: 2000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bene4 = Beneficiary { + address: b4.clone(), + allocation_bps: 2000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bene5 = Beneficiary { + address: b5.clone(), + allocation_bps: 2000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &10000, + &Vec::from_array(&env, [bene1, bene2, bene3, bene4, bene5]), + &3600, + &false, + &0, + &86400, + ); + + // Deactivate, claim, and payout + deactivate_plan_for_testing(&env, &contract_id, &owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + client.claim(&owner); + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + client.trigger_payout(&owner); + + // Each gets exactly 2000 (10000 * 2000 / 10000) + assert_eq!(token_client.balance(&b1), 2000); + assert_eq!(token_client.balance(&b2), 2000); + assert_eq!(token_client.balance(&b3), 2000); + assert_eq!(token_client.balance(&b4), 2000); + assert_eq!(token_client.balance(&b5), 2000); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_trigger_payout_10_beneficiaries_unequal_allocations() { + 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 beneficiaries = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + token_client.mint(&owner, &500000); + + // Create beneficiaries with varying allocations (1000, 1000, ..., 1000 = 10000 BPS) + let mut bene_array = Vec::new(&env); + for beneficiary in beneficiaries.iter() { + let b = Beneficiary { + address: beneficiary.clone(), + allocation_bps: 1000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + bene_array.push_back(b); + } + + let plan_amount = 50000; + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &plan_amount, + &bene_array, + &3600, + &false, + &0, + &86400, + ); + + // Deactivate, claim, and payout + deactivate_plan_for_testing(&env, &contract_id, &owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + client.claim(&owner); + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + client.trigger_payout(&owner); + + // Each gets exactly 5000 (50000 * 1000 / 10000) + for beneficiary in beneficiaries.iter() { + assert_eq!(token_client.balance(beneficiary), 5000); + } + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_trigger_payout_rounding_with_3_beneficiaries() { + 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 bene1 = Address::generate(&env); + let bene2 = Address::generate(&env); + let bene3 = Address::generate(&env); + + token_client.mint(&owner, &100000); + + // Allocations: 3333, 3333, 3334 BPS to test rounding + let b1 = Beneficiary { + address: bene1.clone(), + allocation_bps: 3333, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let b2 = Beneficiary { + address: bene2.clone(), + allocation_bps: 3333, + fiat_anchor_info: String::from_str(&env, "EUR_BANK"), + }; + let b3 = Beneficiary { + address: bene3.clone(), + allocation_bps: 3334, + fiat_anchor_info: String::from_str(&env, "GBP_BANK"), + }; + + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &1000, + &Vec::from_array(&env, [b1, b2, b3]), + &3600, + &false, + &0, + &86400, + ); + + deactivate_plan_for_testing(&env, &contract_id, &owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + client.claim(&owner); + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + client.trigger_payout(&owner); + + // bene1: 1000 * 3333 / 10000 = 333 (truncated) + // bene2: 1000 * 3333 / 10000 = 333 (truncated) + // bene3: 1000 - 333 - 333 = 334 (gets the remainder/dust) + assert_eq!(token_client.balance(&bene1), 333); + assert_eq!(token_client.balance(&bene2), 333); + assert_eq!(token_client.balance(&bene3), 334); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_trigger_payout_after_grace_period_and_timelock_expiry() { + 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 alice = Address::generate(&env); + let bob = Address::generate(&env); + + token_client.mint(&owner, &50000); + + let alice_bene = Beneficiary { + address: alice.clone(), + allocation_bps: 6000, + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + let bob_bene = Beneficiary { + address: bob.clone(), + allocation_bps: 4000, + fiat_anchor_info: String::from_str(&env, "EUR_BANK"), + }; + + let grace_period = 7200; // 2 hours + let timelock_duration = 86400; // 1 day + + let start = 1_000_000; + env.ledger().set_timestamp(start); + + client.create_plan( + &owner, + &token_id, + &20000, + &Vec::from_array(&env, [alice_bene, bob_bene]), + &grace_period, + &false, + &0, + &timelock_duration, + ); + + // Deactivate plan + deactivate_plan_for_testing(&env, &contract_id, &owner); + + // Jump to just before grace period ends - claim should fail + env.ledger().set_timestamp(start + grace_period - 100); + let too_early = client.try_claim(&owner); + assert_eq!(too_early, Err(Ok(Error::InactivityPeriodNotMet))); + + // Jump past grace period - now claim should succeed + env.ledger().set_timestamp(start + grace_period + 100); + client.claim(&owner); + + // Jump to before timelock ends - trigger should fail + env.ledger() + .set_timestamp(start + grace_period + timelock_duration - 100); + let trigger_too_early = client.try_trigger_payout(&owner); + assert_eq!(trigger_too_early, Err(Ok(Error::TimelockNotExpired))); + + // Jump past timelock - now trigger should succeed + env.ledger() + .set_timestamp(start + grace_period + timelock_duration + 100); + client.trigger_payout(&owner); + + // Verify payouts + assert_eq!(token_client.balance(&alice), 12000); // 20000 * 6000 / 10000 + assert_eq!(token_client.balance(&bob), 8000); // 20000 * 4000 / 10000 + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +fn test_trigger_payout_with_single_beneficiary_receives_all() { + 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 sole_beneficiary = Address::generate(&env); + + token_client.mint(&owner, &100000); + + let sole_bene = Beneficiary { + address: sole_beneficiary.clone(), + allocation_bps: 10000, // 100% + fiat_anchor_info: String::from_str(&env, "USD_BANK"), + }; + + let plan_amount = 55555; + env.ledger().set_timestamp(1_000_000); + + client.create_plan( + &owner, + &token_id, + &plan_amount, + &Vec::from_array(&env, [sole_bene]), + &3600, + &false, + &0, + &86400, + ); + + deactivate_plan_for_testing(&env, &contract_id, &owner); + env.ledger().set_timestamp(1_000_000 + 4000); + + client.claim(&owner); + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + client.trigger_payout(&owner); + + // Sole beneficiary gets all + assert_eq!(token_client.balance(&sole_beneficiary), plan_amount); + assert_eq!(token_client.balance(&contract_id), 0); +}