diff --git a/contracts/InheritX.sol b/contracts/InheritX.sol new file mode 100644 index 000000000..b879b987b --- /dev/null +++ b/contracts/InheritX.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title InheritX + * @dev Contract for managing inheritance plans with emergency exit functionality + */ +contract InheritX is Ownable { + using SafeERC20 for IERC20; + + struct Plan { + address owner; + IERC20 token; + uint256 principal; + uint256 accumulatedYield; + uint256 livenessExpiration; + bool isActive; + uint256 createdAt; + } + + mapping(uint256 => Plan) public plans; + uint256 public planCounter; + uint256 public constant DEFAULT_LIVENESS_PERIOD = 30 days; + + event PlanCreated( + uint256 indexed planId, + address indexed owner, + address token, + uint256 principal, + uint256 livenessExpiration + ); + + event YieldAccumulated(uint256 indexed planId, uint256 amount); + + event PlanWithdrawn( + uint256 indexed planId, + address indexed owner, + uint256 principal, + uint256 yield, + uint256 totalWithdrawn + ); + + event PlanClosed(uint256 indexed planId); + + /** + * @dev Constructor to initialize the contract with deployer as owner + */ + constructor() Ownable(msg.sender) {} + + /** + * @dev Create a new inheritance plan + * @param token The ERC20 token address to lock + * @param principal The amount of tokens to lock as principal + * @param livenessPeriod The time period before liveness expires (in seconds) + */ + function createPlan( + address token, + uint256 principal, + uint256 livenessPeriod + ) external returns (uint256) { + require(token != address(0), "Invalid token address"); + require(principal > 0, "Principal must be greater than 0"); + require(livenessPeriod > 0, "Liveness period must be greater than 0"); + + uint256 planId = ++planCounter; + uint256 livenessExpiration = block.timestamp + livenessPeriod; + + // Transfer tokens from caller to contract + IERC20(token).safeTransferFrom(msg.sender, address(this), principal); + + plans[planId] = Plan({ + owner: msg.sender, + token: IERC20(token), + principal: principal, + accumulatedYield: 0, + livenessExpiration: livenessExpiration, + isActive: true, + createdAt: block.timestamp + }); + + emit PlanCreated(planId, msg.sender, token, principal, livenessExpiration); + + return planId; + } + + /** + * @dev Accumulate yield to a plan (can be called by anyone) + * @param planId The plan identifier + * @param amount The amount of yield to add + */ + function accumulateYield(uint256 planId, uint256 amount) external { + Plan storage plan = plans[planId]; + require(plan.isActive, "Plan is not active"); + require(amount > 0, "Yield amount must be greater than 0"); + + // Transfer yield tokens to contract + plan.token.safeTransferFrom(msg.sender, address(this), amount); + + plan.accumulatedYield += amount; + + emit YieldAccumulated(planId, amount); + } + + /** + * @dev Emergency exit: withdraw plan and return all locked principal plus accumulated yield + * @param planId The plan identifier + * + * Requirements: + * - Caller must be the plan owner (owner signature) + * - Plan must be active + * - Current time must be before liveness expiration + * + * Effects: + * - Transfers principal + accumulated yield back to owner + * - Deactivates the plan + */ + function withdrawPlan(uint256 planId) external { + Plan storage plan = plans[planId]; + + // Owner signature verification (msg.sender must be plan owner) + require(msg.sender == plan.owner, "Only plan owner can withdraw"); + + // Plan must be active + require(plan.isActive, "Plan is not active"); + + // Liveness check: can only withdraw before expiration + require(block.timestamp < plan.livenessExpiration, "Liveness has expired"); + + uint256 totalAmount = plan.principal + plan.accumulatedYield; + require(totalAmount > 0, "No funds to withdraw"); + + // Deactivate plan before transfer to prevent reentrancy + plan.isActive = false; + + // Transfer all tokens back to owner + plan.token.safeTransfer(msg.sender, totalAmount); + + emit PlanWithdrawn( + planId, + msg.sender, + plan.principal, + plan.accumulatedYield, + totalAmount + ); + + emit PlanClosed(planId); + + // Clear plan data (optional, for gas optimization) + delete plans[planId]; + } + + /** + * @dev Get plan details + * @param planId The plan identifier + */ + function getPlan(uint256 planId) external view returns ( + address owner, + address token, + uint256 principal, + uint256 accumulatedYield, + uint256 livenessExpiration, + bool isActive, + uint256 createdAt + ) { + Plan memory plan = plans[planId]; + return ( + plan.owner, + address(plan.token), + plan.principal, + plan.accumulatedYield, + plan.livenessExpiration, + plan.isActive, + plan.createdAt + ); + } + + /** + * @dev Check if a plan can be withdrawn (before liveness expiration) + * @param planId The plan identifier + */ + function canWithdraw(uint256 planId) external view returns (bool) { + Plan memory plan = plans[planId]; + return plan.isActive && block.timestamp < plan.livenessExpiration; + } + + /** + * @dev Get total value of a plan (principal + yield) + * @param planId The plan identifier + */ + function getPlanTotalValue(uint256 planId) external view returns (uint256) { + Plan memory plan = plans[planId]; + return plan.principal + plan.accumulatedYield; + } +} diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs index deab89e31..50fe3fe1a 100644 --- a/contracts/inheritance-contract/src/lib.rs +++ b/contracts/inheritance-contract/src/lib.rs @@ -70,6 +70,7 @@ pub struct InheritancePlan { pub waterfall_enabled: bool, pub grace_period: u64, pub earn_yield: bool, + pub liveness_expiration: u64, // Timestamp when emergency exit expires } #[contracterror] @@ -125,6 +126,7 @@ pub enum InheritanceError { WillAlreadyLinked = 48, WillAlreadyFinalized = 49, WillVersionNotFound = 50, + LivenessExpired = 51, } #[contracttype] @@ -1855,6 +1857,8 @@ impl InheritanceContract { } // Create the inheritance plan with net amount (user input minus 2% fee) + let created_at = env.ledger().timestamp(); + let liveness_expiration = created_at + 2_592_000; // 30 days in seconds let plan = InheritancePlan { plan_name, description, @@ -1864,13 +1868,14 @@ impl InheritanceContract { beneficiaries, total_allocation_bp, owner: owner.clone(), - created_at: env.ledger().timestamp(), + created_at, is_active: true, is_lendable, total_loaned: 0, waterfall_enabled: false, grace_period: 0, earn_yield: false, + liveness_expiration, }; // Store the plan @@ -2152,6 +2157,112 @@ impl InheritanceContract { Ok(()) } + /// Emergency exit: withdraw entire plan and return all locked principal plus accumulated yield + /// + /// # Arguments + /// * `env` - The contract environment + /// * `caller` - The address calling the function (must be plan owner) + /// * `token` - The token address to transfer + /// * `plan_id` - The plan identifier + /// + /// # Requirements + /// - Caller must be the plan owner (owner signature) + /// - Plan must be active + /// - Current time must be before liveness expiration + /// + /// # Effects + /// - Transfers total_amount + any yield back to owner + /// - Deactivates the plan + pub fn withdraw_plan( + env: Env, + caller: Address, + token: Address, + plan_id: u64, + ) -> Result<(), InheritanceError> { + caller.require_auth(); + Self::check_not_paused(&env); + Self::enter_guard(&env); + + let mut plan = Self::get_plan(&env, plan_id).ok_or(InheritanceError::PlanNotFound)?; + + // Owner signature verification + if plan.owner != caller { + return Err(InheritanceError::Unauthorized); + } + + // Plan must be active + if !plan.is_active { + return Err(InheritanceError::PlanNotActive); + } + + // Liveness check: can only withdraw before expiration + let now = env.ledger().timestamp(); + if now >= plan.liveness_expiration { + return Err(InheritanceError::LivenessExpired); + } + + // Freeze/legal hold check + if env + .storage() + .persistent() + .has(&DataKey::FreezePlan(plan_id)) + { + return Err(InheritanceError::PlanNotActive); + } + if env.storage().persistent().has(&DataKey::LegalHold(plan_id)) { + return Err(InheritanceError::PlanNotActive); + } + + // Calculate total amount to withdraw (principal + yield) + let total_amount = plan.total_amount; + + // Deactivate plan before transfer to prevent reentrancy + plan.is_active = false; + Self::store_plan(&env, plan_id, &plan); + + // Transfer all tokens back to owner + let contract_id = env.current_contract_address(); + let required = total_amount as i128; + let args: Vec = vec![ + &env, + contract_id.clone().into_val(&env), + caller.clone().into_val(&env), + required.into_val(&env), + ]; + let res = + env.try_invoke_contract::<(), InvokeError>(&token, &symbol_short!("transfer"), args); + if res.is_err() { + // Revert plan activation if transfer fails + plan.is_active = true; + Self::store_plan(&env, plan_id, &plan); + return Err(InheritanceError::FeeTransferFailed); + } + + // Add to deactivated plans list + let mut deactivated: Vec = env + .storage() + .persistent() + .get(&DataKey::DeactivatedPlans) + .unwrap_or(Vec::new(&env)); + deactivated.push_back(plan_id); + env.storage() + .persistent() + .set(&DataKey::DeactivatedPlans, &deactivated); + + env.events().publish( + (symbol_short!("PLAN"), symbol_short!("WITHDRAWN")), + PlanDeactivatedEvent { + plan_id, + owner: caller.clone(), + total_amount, + deactivated_at: now, + }, + ); + log!(&env, "Plan {} withdrawn by owner, total amount: {}", plan_id, total_amount); + Self::exit_guard(&env); + Ok(()) + } + pub fn set_beneficiary_priority( env: Env, owner: Address, diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index 0bdcc05c2..244f4bec1 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -6367,5 +6367,147 @@ fn test_set_conditions_blocked_after_trigger() { client.auto_trigger_check(&plan_id); let result = client.try_add_time_trigger(&owner, &plan_id, &9999u64); +} + +// --- withdraw_plan emergency exit tests --- + +#[test] +fn test_withdraw_plan_success_before_liveness_expiration() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (client, token, _admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&CreateInheritancePlanParams { + owner: owner.clone(), + token: token.clone(), + plan_name: String::from_str(&env, "Test Plan"), + description: String::from_str(&env, "Test Description"), + total_amount: 100_000u64, + distribution_method: DistributionMethod::LumpSum, + beneficiaries_data: Vec::from_array(&env, [ + (String::from_str(&env, "John"), String::from_str(&env, "john@example.com"), 123456u32, Bytes::from_array(&env, &[1u8; 10]), 10000u32, 1u32), + ]), + is_lendable: false, + }).unwrap(); + + let initial_balance = TestTokenHelper::new(&env, &token).balance(&owner); + + // Withdraw plan before liveness expiration + let result = client.withdraw_plan(&owner, &token, &plan_id); + assert!(result.is_ok()); + + let final_balance = TestTokenHelper::new(&env, &token).balance(&owner); + assert!(final_balance > initial_balance); + + // Plan should be deactivated + let plan = client.get_plan_details(&plan_id).unwrap(); + assert!(!plan.is_active); +} + +#[test] +fn test_withdraw_plan_fails_after_liveness_expiration() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (client, token, _admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&CreateInheritancePlanParams { + owner: owner.clone(), + token: token.clone(), + plan_name: String::from_str(&env, "Test Plan"), + description: String::from_str(&env, "Test Description"), + total_amount: 100_000u64, + distribution_method: DistributionMethod::LumpSum, + beneficiaries_data: Vec::from_array(&env, [ + (String::from_str(&env, "John"), String::from_str(&env, "john@example.com"), 123456u32, Bytes::from_array(&env, &[1u8; 10]), 10000u32, 1u32), + ]), + is_lendable: false, + }).unwrap(); + + // Advance time past liveness expiration (30 days = 2,592,000 seconds) + env.ledger().set_timestamp(3_000_000); + + let result = client.try_withdraw_plan(&owner, &token, &plan_id); assert!(result.is_err()); + assert_eq!(result.err().unwrap(), InheritanceError::LivenessExpired); +} + +#[test] +fn test_withdraw_plan_fails_unauthorized() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (client, token, _admin, owner) = setup_with_token_and_admin(&env); + let unauthorized = create_test_address(&env, 999); + + let plan_id = client.create_inheritance_plan(&CreateInheritancePlanParams { + owner: owner.clone(), + token: token.clone(), + plan_name: String::from_str(&env, "Test Plan"), + description: String::from_str(&env, "Test Description"), + total_amount: 100_000u64, + distribution_method: DistributionMethod::LumpSum, + beneficiaries_data: Vec::from_array(&env, [ + (String::from_str(&env, "John"), String::from_str(&env, "john@example.com"), 123456u32, Bytes::from_array(&env, &[1u8; 10]), 10000u32, 1u32), + ]), + is_lendable: false, + }).unwrap(); + + let result = client.try_withdraw_plan(&unauthorized, &token, &plan_id); + assert!(result.is_err()); + assert_eq!(result.err().unwrap(), InheritanceError::Unauthorized); +} + +#[test] +fn test_withdraw_plan_fails_plan_not_active() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (client, token, _admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&CreateInheritancePlanParams { + owner: owner.clone(), + token: token.clone(), + plan_name: String::from_str(&env, "Test Plan"), + description: String::from_str(&env, "Test Description"), + total_amount: 100_000u64, + distribution_method: DistributionMethod::LumpSum, + beneficiaries_data: Vec::from_array(&env, [ + (String::from_str(&env, "John"), String::from_str(&env, "john@example.com"), 123456u32, Bytes::from_array(&env, &[1u8; 10]), 10000u32, 1u32), + ]), + is_lendable: false, + }).unwrap(); + + // Deactivate plan first + client.deactivate_plan(&owner, &plan_id); + + let result = client.try_withdraw_plan(&owner, &token, &plan_id); + assert!(result.is_err()); + assert_eq!(result.err().unwrap(), InheritanceError::PlanNotActive); +} + +#[test] +fn test_withdraw_plan_emits_event() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (client, token, _admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&CreateInheritancePlanParams { + owner: owner.clone(), + token: token.clone(), + plan_name: String::from_str(&env, "Test Plan"), + description: String::from_str(&env, "Test Description"), + total_amount: 100_000u64, + distribution_method: DistributionMethod::LumpSum, + beneficiaries_data: Vec::from_array(&env, [ + (String::from_str(&env, "John"), String::from_str(&env, "john@example.com"), 123456u32, Bytes::from_array(&env, &[1u8; 10]), 10000u32, 1u32), + ]), + is_lendable: false, + }).unwrap(); + + client.withdraw_plan(&owner, &token, &plan_id); + + let events = env.events().all(); + let withdraw_event = events.iter().find(|e| { + e.topics[0] == soroban_sdk::Symbol::new(&env, "PLAN") + && e.topics[1] == soroban_sdk::Symbol::new(&env, "WITHDRAWN") + }); + assert!(withdraw_event.is_some()); }