diff --git a/contracts/savings-vault/src/challenge.rs b/contracts/savings-vault/src/challenge.rs new file mode 100644 index 00000000..0431d56c --- /dev/null +++ b/contracts/savings-vault/src/challenge.rs @@ -0,0 +1,637 @@ +//! Savings challenge contracts — issue #442. +//! +//! A savings challenge is a fixed-duration competition between two or more +//! participants who each commit to saving a target amount by a deadline. +//! The first participant to reach their target may claim the prize pool; +//! if nobody wins by the deadline the entrants' stakes are refunded. +//! +//! # Lifecycle +//! +//! 1. **Create** — [`create`] opens a challenge: caller sets the target +//! amount, entry fee (both in token stroops), a deadline, and a +//! minimum number of participants required before any winner can claim. +//! 2. **Join** — Any address calls [`join`] and pays the entry fee. +//! Joining is open until `deadline`. +//! 3. **Save** — Participants call [`save`] to deposit toward their +//! personal target. Progress is tracked per-participant. +//! 4. **Verify & claim** — When a participant's `saved >= target`, they +//! call [`claim_winner`]. The contract verifies the achievement on-chain +//! and pays the entire prize pool (sum of all entry fees) to the winner. +//! 5. **Expired refund** — After the deadline, if no winner has claimed, +//! any participant calls [`refund_expired`] to return all entry fees to +//! their respective payers. The prize pool is distributed in separate +//! transfers, one per participant (pull-based to bound gas). +//! +//! # Storage +//! +//! - Each challenge is stored under [`DataKey::Challenge(id)`] in persistent +//! storage. +//! - A monotonic `NextChallengeId` counter in instance storage allocates ids. + +use soroban_sdk::{symbol_short, Address, Env}; + +use crate::error::Error; +use crate::storage::{ + extend_instance_ttl, extend_persistent_ttl, transfer_in, transfer_out, +}; +use crate::types::{Challenge, ChallengeParticipant, ChallengeStatus, DataKey}; + +// ── Internal helpers ─────────────────────────────────────────────────────── + +fn load_challenge(env: &Env, challenge_id: u64) -> Result { + let key = DataKey::Challenge(challenge_id); + let c = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + extend_persistent_ttl(env, &key); + Ok(c) +} + +fn save_challenge(env: &Env, challenge: &Challenge) { + let key = DataKey::Challenge(challenge.id); + env.storage().persistent().set(&key, challenge); + extend_persistent_ttl(env, &key); +} + +fn find_participant_index(c: &Challenge, addr: &Address) -> Option { + for (i, p) in (0_u32..).zip(c.participants.iter()) { + if p.address == *addr { + return Some(i); + } + } + None +} + +// ── Public entrypoints ───────────────────────────────────────────────────── + +/// Create a new savings challenge. +/// +/// # Arguments +/// * `creator` — Must authorise; becomes the first participant automatically. +/// * `target_amount` — Stroops each participant must save to win. +/// * `entry_fee` — Stroops each participant pays to join (forms the prize pool). +/// * `deadline` — Ledger timestamp after which new joins are blocked and +/// winners can no longer be claimed (expired state). +/// * `min_participants` — Minimum number of joined participants before a winner +/// can be declared. Must be ≥ 2. +/// +/// Returns the new challenge id. +/// +/// # Errors +/// - [`Error::InvalidAmount`] if `target_amount <= 0` or `entry_fee <= 0`. +/// - [`Error::InvalidTimestamp`] if `deadline <= now`. +/// - [`Error::InvalidParameters`] if `min_participants < 2`. +pub fn create( + env: &Env, + creator: Address, + target_amount: i128, + entry_fee: i128, + deadline: u64, + min_participants: u32, +) -> Result { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + creator.require_auth(); + + if target_amount <= 0 { + return Err(Error::InvalidAmount); + } + if entry_fee <= 0 { + return Err(Error::InvalidAmount); + } + let now = env.ledger().timestamp(); + if deadline <= now { + return Err(Error::InvalidTimestamp); + } + if min_participants < 2 { + return Err(Error::InvalidParameters); + } + + // Collect entry fee from creator. + transfer_in(env, &creator, entry_fee)?; + + let id = crate::storage::next_id(env, DataKey::NextChallengeId); + + let participants = { + let mut v = soroban_sdk::Vec::new(env); + v.push_back(ChallengeParticipant { + address: creator.clone(), + saved: 0, + fee_paid: entry_fee, + }); + v + }; + + let challenge = Challenge { + id, + creator: creator.clone(), + target_amount, + entry_fee, + deadline, + min_participants, + prize_pool: entry_fee, + status: ChallengeStatus::Open, + winner: None, + participants, + created_at: now, + }; + + save_challenge(env, &challenge); + + env.events().publish( + (symbol_short!("challenge"), symbol_short!("created")), + (id, creator, target_amount, entry_fee, deadline), + ); + Ok(id) +} + +/// Join an existing savings challenge and pay the entry fee. +/// +/// # Errors +/// - [`Error::NotFound`] if the challenge does not exist. +/// - [`Error::GroupClosed`] if the challenge is not open (won or expired). +/// - [`Error::OperationExpired`] if the deadline has passed. +/// - [`Error::MemberAlreadyExists`] if the caller is already a participant. +pub fn join(env: &Env, participant: Address, challenge_id: u64) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + participant.require_auth(); + + let mut challenge = load_challenge(env, challenge_id)?; + + if challenge.status != ChallengeStatus::Open { + return Err(Error::GroupClosed); + } + let now = env.ledger().timestamp(); + if now >= challenge.deadline { + return Err(Error::OperationExpired); + } + + // Reject duplicates. + if find_participant_index(&challenge, &participant).is_some() { + return Err(Error::MemberAlreadyExists); + } + + transfer_in(env, &participant, challenge.entry_fee)?; + + challenge.prize_pool = challenge + .prize_pool + .checked_add(challenge.entry_fee) + .ok_or(Error::Overflow)?; + challenge.participants.push_back(ChallengeParticipant { + address: participant.clone(), + saved: 0, + fee_paid: challenge.entry_fee, + }); + + save_challenge(env, &challenge); + + env.events().publish( + (symbol_short!("challenge"), symbol_short!("joined")), + (challenge_id, participant), + ); + Ok(()) +} + +/// Deposit savings toward the caller's personal target in a challenge. +/// +/// # Errors +/// - [`Error::NotFound`] if the challenge or the caller's participation does not exist. +/// - [`Error::GroupClosed`] if the challenge is not open. +/// - [`Error::OperationExpired`] if the deadline has passed. +pub fn save( + env: &Env, + participant: Address, + challenge_id: u64, + amount: i128, +) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + participant.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let mut challenge = load_challenge(env, challenge_id)?; + + if challenge.status != ChallengeStatus::Open { + return Err(Error::GroupClosed); + } + let now = env.ledger().timestamp(); + if now >= challenge.deadline { + return Err(Error::OperationExpired); + } + + let idx = find_participant_index(&challenge, &participant) + .ok_or(Error::NotAMember)?; + + transfer_in(env, &participant, amount)?; + + let mut p = challenge.participants.get(idx).unwrap(); + p.saved = p.saved.checked_add(amount).ok_or(Error::Overflow)?; + challenge.participants.set(idx, p); + + save_challenge(env, &challenge); + + env.events().publish( + (symbol_short!("challenge"), symbol_short!("saved")), + (challenge_id, participant, amount), + ); + Ok(()) +} + +/// Verify that the caller has reached the target and pay them the prize pool. +/// +/// # Verification logic +/// The contract verifies on-chain that `participant.saved >= challenge.target_amount` +/// and that the minimum number of participants have joined. If both conditions +/// are met the caller wins the entire prize pool and the challenge is closed. +/// +/// # Errors +/// - [`Error::NotFound`] if the challenge or participation does not exist. +/// - [`Error::GroupClosed`] if the challenge has already been won or expired. +/// - [`Error::OperationExpired`] if the deadline has passed before claiming. +/// - [`Error::GoalNotReached`] if the caller has not yet saved `target_amount`. +/// - [`Error::InvalidParameters`] if not enough participants have joined. +pub fn claim_winner(env: &Env, participant: Address, challenge_id: u64) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + participant.require_auth(); + + let mut challenge = load_challenge(env, challenge_id)?; + + if challenge.status != ChallengeStatus::Open { + return Err(Error::GroupClosed); + } + let now = env.ledger().timestamp(); + if now >= challenge.deadline { + return Err(Error::OperationExpired); + } + + // Minimum participants check. + if challenge.participants.len() < challenge.min_participants { + return Err(Error::InvalidParameters); + } + + // Find participant and verify target. + let idx = find_participant_index(&challenge, &participant) + .ok_or(Error::NotAMember)?; + let p = challenge.participants.get(idx).unwrap(); + if p.saved < challenge.target_amount { + return Err(Error::GoalNotReached); + } + + // Pay out the prize pool. + let prize = challenge.prize_pool; + transfer_out(env, &participant, prize)?; + + challenge.status = ChallengeStatus::Won; + challenge.winner = Some(participant.clone()); + challenge.prize_pool = 0; + save_challenge(env, &challenge); + + env.events().publish( + (symbol_short!("challenge"), symbol_short!("won")), + (challenge_id, participant, prize, now), + ); + Ok(()) +} + +/// Refund all entry fees after the deadline has passed with no winner. +/// +/// Permissionless — any address can trigger the refund after expiry. +/// Each participant's entry fee is returned to them individually. +/// Participants who saved more than their entry fee have their savings +/// held within the contract until they call a withdrawal. +/// +/// # Errors +/// - [`Error::NotFound`] if the challenge does not exist. +/// - [`Error::GroupClosed`] if already won or already refunded. +/// - [`Error::TimeWindowNotOpen`] if the deadline has not yet passed. +pub fn refund_expired(env: &Env, caller: Address, challenge_id: u64) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + caller.require_auth(); + + let mut challenge = load_challenge(env, challenge_id)?; + + if challenge.status != ChallengeStatus::Open { + return Err(Error::GroupClosed); + } + let now = env.ledger().timestamp(); + if now < challenge.deadline { + return Err(Error::TimeWindowNotOpen); + } + + // Return each participant's entry fee + any savings deposited. + for p in challenge.participants.iter() { + let total_held = p.fee_paid.checked_add(p.saved).ok_or(Error::Overflow)?; + if total_held > 0 { + transfer_out(env, &p.address, total_held)?; + } + } + + challenge.status = ChallengeStatus::Expired; + challenge.prize_pool = 0; + save_challenge(env, &challenge); + + env.events().publish( + (symbol_short!("challenge"), symbol_short!("expired")), + (challenge_id, now), + ); + Ok(()) +} + +/// Read a challenge by id. +pub fn get_challenge(env: &Env, challenge_id: u64) -> Result { + load_challenge(env, challenge_id) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{token, Env}; + + use crate::test::register_vault; + + fn advance(env: &Env, seconds: u64) { + env.ledger().with_mut(|l| l.timestamp += seconds); + } + + fn mint(env: &Env, token: &Address, to: &Address, amount: i128) { + let admin = Address::generate(env); + token::StellarAssetClient::new(env, token).mint(to, &amount); + } + + // ── challenge creation ──────────────────────────────────────────────────── + + #[test] + fn create_challenge_returns_id() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 100_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + assert_eq!(id, 1); + } + + #[test] + fn create_challenge_invalid_deadline_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 100_000); + + // Deadline in the past. + assert_eq!( + vault.try_create_challenge(&creator, &50_000i128, &10_000i128, &999u64, &2u32), + Err(Ok(Error::InvalidTimestamp)) + ); + } + + #[test] + fn create_challenge_min_participants_below_2_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 100_000); + + assert_eq!( + vault.try_create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &1u32), + Err(Ok(Error::InvalidParameters)) + ); + } + + // ── join ───────────────────────────────────────────────────────────────── + + #[test] + fn join_adds_participant() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + let participant = Address::generate(&env); + mint(&env, &token, &creator, 100_000); + mint(&env, &token, &participant, 100_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + vault.join_challenge(&participant, &id).unwrap(); + + let c = vault.get_challenge(&id).unwrap(); + assert_eq!(c.participants.len(), 2); + assert_eq!(c.prize_pool, 20_000); + } + + #[test] + fn join_duplicate_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 100_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + + // Creator tries to join again. + assert_eq!( + vault.try_join_challenge(&creator, &id), + Err(Ok(Error::MemberAlreadyExists)) + ); + } + + #[test] + fn join_after_deadline_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + let p2 = Address::generate(&env); + mint(&env, &token, &creator, 100_000); + mint(&env, &token, &p2, 100_000); + + let deadline = 1_000 + 86_400u64; + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &deadline, &2u32) + .unwrap(); + + advance(&env, 86_401); + + assert_eq!( + vault.try_join_challenge(&p2, &id), + Err(Ok(Error::OperationExpired)) + ); + } + + // ── save & verify ───────────────────────────────────────────────────────── + + #[test] + fn save_updates_participant_progress() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 200_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + vault.save_challenge(&creator, &id, &30_000i128).unwrap(); + + let c = vault.get_challenge(&id).unwrap(); + let p = c.participants.get(0).unwrap(); + assert_eq!(p.saved, 30_000); + } + + #[test] + fn claim_winner_pays_prize_pool() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + let p2 = Address::generate(&env); + mint(&env, &token, &creator, 200_000); + mint(&env, &token, &p2, 200_000); + + let entry_fee = 10_000i128; + let target = 50_000i128; + let deadline = 1_000 + 86_400u64; + + let id = vault + .create_challenge(&creator, &target, &entry_fee, &deadline, &2u32) + .unwrap(); + vault.join_challenge(&p2, &id).unwrap(); + + // Creator saves the full target. + vault + .save_challenge(&creator, &id, &target) + .unwrap(); + + let tok = token::Client::new(&env, &token); + let before = tok.balance(&creator); + vault.claim_winner(&creator, &id).unwrap(); + let after = tok.balance(&creator); + + // Won: entry_fee * 2 = 20_000. + assert_eq!(after - before, 20_000); + + let c = vault.get_challenge(&id).unwrap(); + assert_eq!(c.status, ChallengeStatus::Won); + assert_eq!(c.winner, Some(creator.clone())); + } + + #[test] + fn claim_winner_rejected_when_target_not_met() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + let p2 = Address::generate(&env); + mint(&env, &token, &creator, 200_000); + mint(&env, &token, &p2, 200_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + vault.join_challenge(&p2, &id).unwrap(); + + // Only saved 30_000 of 50_000. + vault.save_challenge(&creator, &id, &30_000i128).unwrap(); + + assert_eq!( + vault.try_claim_winner(&creator, &id), + Err(Ok(Error::GoalNotReached)) + ); + } + + #[test] + fn claim_winner_rejected_before_min_participants() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 200_000); + + // min_participants = 3 but only creator has joined. + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &3u32) + .unwrap(); + vault.save_challenge(&creator, &id, &50_000i128).unwrap(); + + assert_eq!( + vault.try_claim_winner(&creator, &id), + Err(Ok(Error::InvalidParameters)) + ); + } + + // ── expired refund ──────────────────────────────────────────────────────── + + #[test] + fn refund_expired_returns_fees_and_savings() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + let p2 = Address::generate(&env); + mint(&env, &token, &creator, 200_000); + mint(&env, &token, &p2, 200_000); + + let entry_fee = 10_000i128; + let deadline = 1_000 + 86_400u64; + + let id = vault + .create_challenge(&creator, &50_000i128, &entry_fee, &deadline, &2u32) + .unwrap(); + vault.join_challenge(&p2, &id).unwrap(); + vault.save_challenge(&creator, &id, &20_000i128).unwrap(); + + let tok = token::Client::new(&env, &token); + let creator_before = tok.balance(&creator); + let p2_before = tok.balance(&p2); + + // Advance past deadline. + advance(&env, 86_401); + vault.refund_expired(&creator, &id).unwrap(); + + // Creator gets fee + 20_000 savings back. + assert_eq!(tok.balance(&creator) - creator_before, entry_fee + 20_000); + // p2 gets entry fee back. + assert_eq!(tok.balance(&p2) - p2_before, entry_fee); + + let c = vault.get_challenge(&id).unwrap(); + assert_eq!(c.status, ChallengeStatus::Expired); + } + + #[test] + fn refund_before_deadline_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000); + let (vault, token, creator) = register_vault(&env); + mint(&env, &token, &creator, 100_000); + + let id = vault + .create_challenge(&creator, &50_000i128, &10_000i128, &(1_000 + 86_400u64), &2u32) + .unwrap(); + + assert_eq!( + vault.try_refund_expired(&creator, &id), + Err(Ok(Error::TimeWindowNotOpen)) + ); + } +} diff --git a/contracts/savings-vault/src/events.rs b/contracts/savings-vault/src/events.rs index 135affa9..7c9547a6 100644 --- a/contracts/savings-vault/src/events.rs +++ b/contracts/savings-vault/src/events.rs @@ -1,4 +1,4 @@ -//! Event topic registry. +//! Event topic registry. //! //! The off-chain indexer decodes contract events by topic. This module is //! the canonical, compile-checked registry of topic name strings; the full diff --git a/contracts/savings-vault/src/inheritance.rs b/contracts/savings-vault/src/inheritance.rs new file mode 100644 index 00000000..b3c71f3f --- /dev/null +++ b/contracts/savings-vault/src/inheritance.rs @@ -0,0 +1,636 @@ +//! Savings inheritance — issue #439. +//! +//! Lets an account owner designate one or more beneficiaries who may claim +//! the balance if the owner has been inactive for a configurable timeout. +//! +//! # How it works +//! +//! 1. **Designate** — The owner calls [`designate_beneficiary`] with an +//! address and a share in basis points (1/100 of a percent). Multiple +//! beneficiaries may be designated; their shares must sum to exactly +//! `10_000` bps (100%) before the inheritance is active. +//! 2. **Timeout** — The owner sets an inactivity timeout in seconds with +//! [`set_inactivity_timeout`]. Any interaction with the flexible-savings +//! module (deposit or withdraw) resets the owner's `last_active_at` +//! timestamp. Once `now >= last_active_at + timeout`, the account is +//! considered inactive and beneficiaries may claim. +//! 3. **Claim** — A beneficiary calls [`claim_inheritance`] with the +//! owner's address. If the timeout has elapsed, each beneficiary's +//! proportional share of the flexible balance is transferred to them. +//! The claim is atomic: if any transfer fails the entire call reverts +//! with the original balance intact. +//! +//! # Storage +//! +//! Each owner's inheritance plan is stored under +//! [`DataKey::Inheritance(owner)`] in persistent storage, with a TTL +//! bumped on every read/write. + +use soroban_sdk::{Address, Env, Vec}; + +use crate::error::Error; +use crate::storage::{extend_instance_ttl, extend_persistent_ttl, transfer_out}; +use crate::types::{DataKey, InheritancePlan}; + +// ── Internal helpers ─────────────────────────────────────────────────────── + +fn load_plan(env: &Env, owner: &Address) -> Result { + let key = DataKey::Inheritance(owner.clone()); + let plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + extend_persistent_ttl(env, &key); + Ok(plan) +} + +fn save_plan(env: &Env, plan: &InheritancePlan) { + let key = DataKey::Inheritance(plan.owner.clone()); + env.storage().persistent().set(&key, plan); + extend_persistent_ttl(env, &key); +} + +fn require_owner(env: &Env, owner: &Address) { + owner.require_auth(); + let _ = env; // used implicitly by require_auth +} + +/// Validate that a set of beneficiary shares sums to `10_000` bps. +fn validate_shares_sum(beneficiaries: &Vec) -> Result<(), Error> { + let mut total: u32 = 0; + for b in beneficiaries.iter() { + total = total.checked_add(b.share_bps).ok_or(Error::Overflow)?; + } + if !beneficiaries.is_empty() && total != 10_000 { + return Err(Error::InvalidShares); + } + Ok(()) +} + +// ── Public entrypoints ───────────────────────────────────────────────────── + +/// Add or update a beneficiary for the caller's flexible savings balance. +/// +/// # Arguments +/// * `owner` — The savings account owner. Must authorise. +/// * `beneficiary`— Address that will receive `share_bps` of the balance. +/// * `share_bps` — This beneficiary's share in basis points (1–10_000). +/// +/// # Behaviour +/// - Creates an [`InheritancePlan`] if one does not already exist. +/// - If `beneficiary` already appears in the plan, updates their share. +/// - Otherwise appends them. +/// - An inactivity timeout of `0` means inheritance is disabled even when +/// shares sum to 10_000; call [`set_inactivity_timeout`] to activate. +/// +/// # Errors +/// - [`Error::InvalidAmount`] if `share_bps == 0 || share_bps > 10_000`. +/// - [`Error::Paused`] if the vault is paused. +pub fn designate_beneficiary( + env: &Env, + owner: Address, + beneficiary: Address, + share_bps: u32, +) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + require_owner(env, &owner); + + if share_bps == 0 || share_bps > 10_000 { + return Err(Error::InvalidAmount); + } + + // Load or create the plan. + let key = DataKey::Inheritance(owner.clone()); + let mut plan: InheritancePlan = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| InheritancePlan { + owner: owner.clone(), + beneficiaries: Vec::new(env), + inactivity_timeout: 0, + last_active_at: env.ledger().timestamp(), + }); + + // Update existing or append new. + let mut found = false; + let mut updated: Vec = Vec::new(env); + for mut b in plan.beneficiaries.iter() { + if b.address == beneficiary { + b.share_bps = share_bps; + found = true; + } + updated.push_back(b); + } + if !found { + updated.push_back(crate::types::Beneficiary { + address: beneficiary.clone(), + share_bps, + }); + } + plan.beneficiaries = updated; + + save_plan(env, &plan); + + env.events().publish( + (soroban_sdk::symbol_short!("inherit"), soroban_sdk::symbol_short!("benef_set")), + (owner, beneficiary, share_bps), + ); + Ok(()) +} + +/// Remove a beneficiary from the caller's inheritance plan. +/// +/// # Errors +/// - [`Error::NotFound`] if no plan exists or the beneficiary is not in it. +/// - [`Error::Paused`] if the vault is paused. +pub fn remove_beneficiary(env: &Env, owner: Address, beneficiary: Address) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + require_owner(env, &owner); + + let mut plan = load_plan(env, &owner)?; + + let mut found = false; + let mut updated: Vec = Vec::new(env); + for b in plan.beneficiaries.iter() { + if b.address == beneficiary { + found = true; + } else { + updated.push_back(b); + } + } + if !found { + return Err(Error::NotFound); + } + plan.beneficiaries = updated; + save_plan(env, &plan); + + env.events().publish( + (soroban_sdk::symbol_short!("inherit"), soroban_sdk::symbol_short!("benef_rm")), + (owner, beneficiary), + ); + Ok(()) +} + +/// Set the inactivity timeout for the caller's inheritance plan. +/// +/// Once `now >= last_active_at + timeout` and the total shares equal +/// `10_000` bps, beneficiaries may call [`claim_inheritance`]. +/// +/// Setting `timeout = 0` effectively disables automatic inheritance (the +/// claim check treats it as no timeout configured). +/// +/// # Errors +/// - [`Error::NotFound`] if no inheritance plan exists for `owner`. +/// - [`Error::Paused`] if the vault is paused. +pub fn set_inactivity_timeout(env: &Env, owner: Address, timeout_seconds: u64) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + require_owner(env, &owner); + + let mut plan = load_plan(env, &owner)?; + plan.inactivity_timeout = timeout_seconds; + save_plan(env, &plan); + + env.events().publish( + (soroban_sdk::symbol_short!("inherit"), soroban_sdk::symbol_short!("timeout_set")), + (owner, timeout_seconds), + ); + Ok(()) +} + +/// Record `now` as `owner`'s last-active timestamp. +/// +/// Called by `flexible::deposit` and `flexible::withdraw` to keep the +/// inactivity clock reset while the owner is actively using their account. +/// Not a public entrypoint — only the flexible module calls this. +pub(crate) fn touch_owner(env: &Env, owner: &Address) { + let key = DataKey::Inheritance(owner.clone()); + if let Some(mut plan) = env.storage().persistent().get::(&key) { + plan.last_active_at = env.ledger().timestamp(); + env.storage().persistent().set(&key, &plan); + extend_persistent_ttl(env, &key); + } + // If no plan exists yet, there's nothing to update. +} + +/// Claim the inheritance of `owner`'s flexible balance. +/// +/// # Conditions for a successful claim +/// 1. An [`InheritancePlan`] must exist for `owner`. +/// 2. The plan's `inactivity_timeout > 0`. +/// 3. The plan's beneficiaries must sum to exactly `10_000` bps. +/// 4. `now >= last_active_at + inactivity_timeout` (owner is inactive). +/// 5. The caller must be one of the designated beneficiaries. +/// +/// When all conditions are met, each beneficiary receives their proportional +/// share of the flexible balance, the flexible account is zeroed, and the +/// inheritance plan is removed from storage. +/// +/// # Errors +/// - [`Error::NotFound`] if no plan or flexible account exists. +/// - [`Error::CooldownActive`] if the inactivity timeout has not elapsed. +/// - [`Error::InvalidShares`] if total shares ≠ 10_000. +/// - [`Error::Unauthorized`] if `claimer` is not a beneficiary. +/// - [`Error::Paused`] if the vault is paused. +pub fn claim_inheritance(env: &Env, claimer: Address, owner: Address) -> Result<(), Error> { + crate::admin::ensure_not_paused(env)?; + extend_instance_ttl(env); + claimer.require_auth(); + + let plan = load_plan(env, &owner)?; + + // Timeout must be configured. + if plan.inactivity_timeout == 0 { + return Err(Error::CooldownActive); + } + + // Inactivity check. + let now = env.ledger().timestamp(); + let inactive_since = plan + .last_active_at + .checked_add(plan.inactivity_timeout) + .ok_or(Error::Overflow)?; + if now < inactive_since { + return Err(Error::CooldownActive); + } + + // Shares must sum to 10_000. + validate_shares_sum(&plan.beneficiaries)?; + + // Claimer must be a beneficiary. + let is_beneficiary = plan.beneficiaries.iter().any(|b| b.address == claimer); + if !is_beneficiary { + return Err(Error::Unauthorized); + } + + // Load the flexible account balance. + let flex_key = DataKey::Flexible(owner.clone()); + let mut account = env + .storage() + .persistent() + .get::(&flex_key) + .ok_or(Error::NotFound)?; + + let total_balance = account.balance; + if total_balance <= 0 { + return Err(Error::InsufficientBalance); + } + + // Pay each beneficiary their share (all-or-nothing). + for b in plan.beneficiaries.iter() { + // Integer arithmetic: share = balance * bps / 10_000. + let share = total_balance + .checked_mul(b.share_bps as i128) + .ok_or(Error::Overflow)? + .checked_div(10_000) + .ok_or(Error::DivisionByZero)?; + if share > 0 { + transfer_out(env, &b.address, share)?; + } + } + + // Zero the flexible account. + account.balance = 0; + account.updated_at = now; + env.storage().persistent().set(&flex_key, &account); + extend_persistent_ttl(env, &flex_key); + + // Remove the inheritance plan — it has been consumed. + env.storage() + .persistent() + .remove(&DataKey::Inheritance(owner.clone())); + + env.events().publish( + (soroban_sdk::symbol_short!("inherit"), soroban_sdk::symbol_short!("claimed")), + (claimer, owner, total_balance, now), + ); + Ok(()) +} + +/// Read the inheritance plan for `owner`, or `Error::NotFound`. +pub fn get_plan(env: &Env, owner: Address) -> Result { + load_plan(env, &owner) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{token, Env}; + + use crate::test::{register_vault, setup_flexible_account}; + + // ── helpers ───────────────────────────────────────────────────────────── + + fn advance(env: &Env, seconds: u64) { + env.ledger().with_mut(|l| l.timestamp += seconds); + } + + // ── beneficiary designation ────────────────────────────────────────────── + + #[test] + fn designate_single_beneficiary() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + + let plan = vault.get_inheritance_plan(&owner).unwrap(); + assert_eq!(plan.beneficiaries.len(), 1); + assert_eq!(plan.beneficiaries.get(0).unwrap().address, heir); + assert_eq!(plan.beneficiaries.get(0).unwrap().share_bps, 10_000); + } + + #[test] + fn designate_two_beneficiaries_split() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir1 = Address::generate(&env); + let heir2 = Address::generate(&env); + + vault + .designate_beneficiary(&owner, &heir1, &6_000u32) + .unwrap(); + vault + .designate_beneficiary(&owner, &heir2, &4_000u32) + .unwrap(); + + let plan = vault.get_inheritance_plan(&owner).unwrap(); + assert_eq!(plan.beneficiaries.len(), 2); + } + + #[test] + fn update_existing_beneficiary_share() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + vault + .designate_beneficiary(&owner, &heir, &5_000u32) + .unwrap(); + // Update the share. + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + + let plan = vault.get_inheritance_plan(&owner).unwrap(); + assert_eq!(plan.beneficiaries.len(), 1); + assert_eq!(plan.beneficiaries.get(0).unwrap().share_bps, 10_000); + } + + #[test] + fn designate_rejects_zero_share() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + assert_eq!( + vault.try_designate_beneficiary(&owner, &heir, &0u32), + Err(Ok(Error::InvalidAmount)) + ); + } + + #[test] + fn designate_rejects_share_above_10000() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + assert_eq!( + vault.try_designate_beneficiary(&owner, &heir, &10_001u32), + Err(Ok(Error::InvalidAmount)) + ); + } + + // ── inactivity timeout ─────────────────────────────────────────────────── + + #[test] + fn set_inactivity_timeout_stores_value() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + vault + .set_inactivity_timeout(&owner, &(30 * 24 * 3600u64)) + .unwrap(); + + let plan = vault.get_inheritance_plan(&owner).unwrap(); + assert_eq!(plan.inactivity_timeout, 30 * 24 * 3600); + } + + // ── claim process ──────────────────────────────────────────────────────── + + #[test] + fn claim_inheritance_succeeds_after_timeout() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + let timeout = 90 * 24 * 3600u64; // 90 days + + // Owner deposits. + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + + // Designate heir and configure timeout. + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + + // Advance past the timeout. + advance(&env, timeout + 1); + + let heir_balance_before = + token::Client::new(&env, &token).balance(&heir); + + vault.claim_inheritance(&heir, &owner).unwrap(); + + let heir_balance_after = + token::Client::new(&env, &token).balance(&heir); + assert!(heir_balance_after > heir_balance_before); + } + + #[test] + fn claim_rejected_before_timeout_elapsed() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + let timeout = 90 * 24 * 3600u64; + + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + + // Only half the timeout has passed — should be rejected. + advance(&env, timeout / 2); + + assert_eq!( + vault.try_claim_inheritance(&heir, &owner), + Err(Ok(Error::CooldownActive)) + ); + } + + #[test] + fn claim_rejected_for_non_beneficiary() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + let stranger = Address::generate(&env); + let timeout = 1u64; + + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + advance(&env, timeout + 1); + + assert_eq!( + vault.try_claim_inheritance(&stranger, &owner), + Err(Ok(Error::Unauthorized)) + ); + } + + #[test] + fn claim_rejected_when_shares_dont_sum_to_10000() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + let timeout = 1u64; + + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + // Only 5 000 bps — not 10 000. + vault + .designate_beneficiary(&owner, &heir, &5_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + advance(&env, timeout + 1); + + assert_eq!( + vault.try_claim_inheritance(&heir, &owner), + Err(Ok(Error::InvalidShares)) + ); + } + + #[test] + fn claim_rejected_when_timeout_is_zero() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + // Timeout stays 0 — inheritance disabled. + + advance(&env, 999_999_999); + + assert_eq!( + vault.try_claim_inheritance(&heir, &owner), + Err(Ok(Error::CooldownActive)) + ); + } + + #[test] + fn deposit_activity_resets_inactivity_clock() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir = Address::generate(&env); + let timeout = 100u64; + + setup_flexible_account(&env, &vault, &token, &owner, 1_000_000); + vault + .designate_beneficiary(&owner, &heir, &10_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + + // Advance 90 seconds — still within timeout. + advance(&env, 90); + + // Owner deposits (resets clock to now = 1_000_090). + vault.deposit(&owner, &100i128).unwrap(); + + // Advance another 90 seconds — only 90s since last activity. + advance(&env, 90); + + // Should still be rejected because clock was reset. + assert_eq!( + vault.try_claim_inheritance(&heir, &owner), + Err(Ok(Error::CooldownActive)) + ); + } + + #[test] + fn multi_beneficiary_split_pays_proportional_amounts() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let (vault, token, owner) = register_vault(&env); + let heir1 = Address::generate(&env); + let heir2 = Address::generate(&env); + let timeout = 1u64; + + let deposit_amount = 1_000_000i128; + setup_flexible_account(&env, &vault, &token, &owner, deposit_amount); + + // 60% / 40% split. + vault + .designate_beneficiary(&owner, &heir1, &6_000u32) + .unwrap(); + vault + .designate_beneficiary(&owner, &heir2, &4_000u32) + .unwrap(); + vault.set_inactivity_timeout(&owner, &timeout).unwrap(); + advance(&env, timeout + 1); + + let tok = token::Client::new(&env, &token); + let before1 = tok.balance(&heir1); + let before2 = tok.balance(&heir2); + + vault.claim_inheritance(&heir1, &owner).unwrap(); + + let got1 = tok.balance(&heir1) - before1; + let got2 = tok.balance(&heir2) - before2; + assert_eq!(got1, 600_000); + assert_eq!(got2, 400_000); + } +} diff --git a/contracts/savings-vault/src/lib.rs b/contracts/savings-vault/src/lib.rs index c43a9803..373091ef 100644 --- a/contracts/savings-vault/src/lib.rs +++ b/contracts/savings-vault/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] //! # Stow Savings Vault //! //! Non-custodial savings on Soroban. Funds move only under contract rules. @@ -24,6 +24,7 @@ mod group; mod group_split; mod insurance; mod interest; +mod challenge; mod locked; mod storage; mod types; @@ -451,3 +452,4 @@ impl SavingsVault { insurance::get_claim(&env, claimant) } } + diff --git a/contracts/savings-vault/src/types.rs b/contracts/savings-vault/src/types.rs index 95694a78..483ec52d 100644 --- a/contracts/savings-vault/src/types.rs +++ b/contracts/savings-vault/src/types.rs @@ -344,3 +344,4 @@ pub enum DataKey { /// Approved insurance claim by user address. InsuranceClaim(Address), } +