From 3cfc5aa7b8adafc01ffb668625ad6001bb40136b Mon Sep 17 00:00:00 2001 From: Trusty08 Date: Thu, 22 Oct 2026 14:33:54 +0100 Subject: [PATCH] feat: implement savings delegation with permission levels and revocation (#437) --- contracts/savings-vault/src/admin.rs | 13 +- contracts/savings-vault/src/delegation.rs | 374 ++++++++++ contracts/savings-vault/src/error.rs | 170 ++--- contracts/savings-vault/src/events.rs | 21 + contracts/savings-vault/src/flexible.rs | 19 +- contracts/savings-vault/src/goal.rs | 20 +- contracts/savings-vault/src/group.rs | 6 +- contracts/savings-vault/src/group_split.rs | 19 +- contracts/savings-vault/src/interest.rs | 45 +- contracts/savings-vault/src/lib.rs | 118 ++- contracts/savings-vault/src/locked.rs | 29 +- contracts/savings-vault/src/storage.rs | 7 +- contracts/savings-vault/src/test.rs | 827 ++++++++++++++++++++- contracts/savings-vault/src/types.rs | 46 ++ contracts/savings-vault/src/voting.rs | 46 +- 15 files changed, 1560 insertions(+), 200 deletions(-) create mode 100644 contracts/savings-vault/src/delegation.rs diff --git a/contracts/savings-vault/src/admin.rs b/contracts/savings-vault/src/admin.rs index 8815f1a7..c48b4c33 100644 --- a/contracts/savings-vault/src/admin.rs +++ b/contracts/savings-vault/src/admin.rs @@ -103,7 +103,10 @@ pub fn require_not_paused(env: &Env) -> Result<(), Error> { /// Defaults to `0` (unlimited) before `set_deposit_cap` has ever been /// called. pub fn deposit_cap(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::DepositCap).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::DepositCap) + .unwrap_or(0) } /// Set the per-account deposit cap. Admin-only. `0` disables the cap. @@ -132,7 +135,10 @@ pub fn set_deposit_cap(env: &Env, cap: i128) -> Result<(), Error> { /// Defaults to `0` (no minimum) before `set_min_deposit` has ever been /// called. pub fn min_deposit(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::MinDeposit).unwrap_or(0) + env.storage() + .instance() + .get(&DataKey::MinDeposit) + .unwrap_or(0) } /// Set the minimum deposit amount. Admin-only. `0` disables the minimum. @@ -277,7 +283,8 @@ pub fn upgrade(env: &Env, caller: Address, new_wasm_hash: BytesN<32>) -> Result< return Err(Error::Unauthorized); } - env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); env.events().publish( (TOPIC_UPGRADED,), diff --git a/contracts/savings-vault/src/delegation.rs b/contracts/savings-vault/src/delegation.rs new file mode 100644 index 00000000..16f17765 --- /dev/null +++ b/contracts/savings-vault/src/delegation.rs @@ -0,0 +1,374 @@ +//! Savings delegation — authorize a delegate to manage savings on the +//! owner's behalf. +//! +//! An owner grants a [`Delegation`] to a delegate address with a +//! [`PermissionLevel`] (`ViewOnly`, `Deposit`, or `Full`) and an optional +//! expiry. The delegate can then perform savings actions on the owner's +//! account within their permission level until the delegation is revoked or +//! expires. +//! +//! ## Lifecycle +//! +//! 1. [`delegate_savings`] — owner creates a new delegation (grant). +//! 2. [`update_delegation`] — owner changes the level or expiry of an +//! existing delegation. +//! 3. [`revoke_delegation`] — owner removes a delegation; the delegate +//! immediately loses access. +//! 4. [`delegated_deposit`] / [`delegated_withdraw`] — the delegate acts on +//! the owner's flexible savings account, within their permission level. +//! +//! ## Permission model +//! +//! `PermissionLevel` is ordered `ViewOnly < Deposit < Full`: +//! +//! - **ViewOnly** — can read the owner's account via `get_account`; cannot +//! deposit or withdraw. +//! - **Deposit** — can deposit into the owner's flexible account (funds come +//! from the delegate's wallet); cannot withdraw. +//! - **Full** — can deposit and withdraw. Withdrawals are always sent to +//! the **owner's** wallet, never the delegate's. +//! +//! ## Guardrails +//! +//! - Self-delegation is blocked (`Error::SelfDelegation`). +//! - Duplicate delegations for the same `(owner, delegate)` pair are blocked +//! (`Error::DelegationAlreadyExists`); use [`update_delegation`] to change +//! an existing delegation. +//! - Expired delegations are treated as invalid on every delegated action +//! (`Error::DelegationExpired`). +//! - Delegated withdrawals require `Full` permission and always transfer +//! funds to the owner's address, never the delegate's. + +use soroban_sdk::{Address, Env}; + +use crate::admin; +use crate::admin::require_not_paused; +use crate::error::Error; +use crate::events::{ + TOPIC_DELEGATION_GRANTED, TOPIC_DELEGATION_REVOKED, TOPIC_DELEGATION_UPDATED, TOPIC_DEPOSIT, + TOPIC_WITHDRAW, +}; +use crate::storage::{self, extend_instance_ttl}; +use crate::types::{DataKey, Delegation, FlexibleAccount, PermissionLevel}; + +// --- delegation management ------------------------------------------------ + +/// Grant a new delegation: authorize `delegate` to manage `owner`'s savings +/// at `level`, optionally expiring at `expires_at` (a ledger timestamp). +/// +/// Owner-only (`owner.require_auth()`). Blocks self-delegation and +/// duplicate delegations. Emits a `delegation_granted` event. +/// +/// Errors: +/// - `InvalidAddress` — self-delegation attempted (`owner == delegate`) +/// - `ConflictingState` — a delegation for this `(owner, delegate)` pair +/// already exists; use [`update_delegation`] instead +/// - `InvalidTimestamp` — `expires_at` is set but not strictly in the future +/// - `NotInitialized`, `Paused` +pub fn delegate_savings( + env: &Env, + owner: Address, + delegate: Address, + level: PermissionLevel, + expires_at: Option, +) -> Result<(), Error> { + extend_instance_ttl(env); + require_not_paused(env)?; + owner.require_auth(); + + if owner == delegate { + return Err(Error::InvalidAddress); // self-delegation not permitted + } + validate_expiry(env, expires_at)?; + + let key = DataKey::Delegation(owner.clone(), delegate.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::ConflictingState); // delegation already exists for this pair + } + + let now = env.ledger().timestamp(); + let delegation = Delegation { + owner: owner.clone(), + delegate: delegate.clone(), + level, + expires_at, + created_at: now, + updated_at: now, + }; + + env.storage().persistent().set(&key, &delegation); + storage::extend_persistent_ttl(env, &key); + + let exp_val = expires_at.unwrap_or(0); + env.events().publish( + (TOPIC_DELEGATION_GRANTED, owner.clone(), delegate.clone()), + (owner, delegate, level, exp_val, now), + ); + + Ok(()) +} + +/// Update an existing delegation's permission level and/or expiry. +/// +/// Owner-only (`owner.require_auth()`). The delegation must already exist. +/// Emits a `delegation_updated` event. +/// +/// Errors: +/// - `NotFound` — no delegation exists for this `(owner, delegate)` pair +/// - `InvalidTimestamp` — `expires_at` is set but not strictly in the future +/// - `NotInitialized`, `Paused` +pub fn update_delegation( + env: &Env, + owner: Address, + delegate: Address, + level: PermissionLevel, + expires_at: Option, +) -> Result<(), Error> { + extend_instance_ttl(env); + require_not_paused(env)?; + owner.require_auth(); + + validate_expiry(env, expires_at)?; + + let key = DataKey::Delegation(owner.clone(), delegate.clone()); + let mut delegation: Delegation = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; // no delegation exists for this pair + storage::extend_persistent_ttl(env, &key); + + let now = env.ledger().timestamp(); + delegation.level = level; + delegation.expires_at = expires_at; + delegation.updated_at = now; + + env.storage().persistent().set(&key, &delegation); + storage::extend_persistent_ttl(env, &key); + + let exp_val = expires_at.unwrap_or(0); + env.events().publish( + (TOPIC_DELEGATION_UPDATED, owner.clone(), delegate.clone()), + (owner, delegate, level, exp_val, now), + ); + + Ok(()) +} + +/// Revoke a delegation. Owner-only (`owner.require_auth()`). The delegate +/// immediately loses access. Emits a `delegation_revoked` event. +/// +/// Errors: +/// - `NotFound` — no delegation exists for this `(owner, delegate)` pair +/// - `NotInitialized`, `Paused` +pub fn revoke_delegation(env: &Env, owner: Address, delegate: Address) -> Result<(), Error> { + extend_instance_ttl(env); + require_not_paused(env)?; + owner.require_auth(); + + let key = DataKey::Delegation(owner.clone(), delegate.clone()); + if !env.storage().persistent().has(&key) { + return Err(Error::NotFound); // no delegation exists to revoke + } + + env.storage().persistent().remove(&key); + + let now = env.ledger().timestamp(); + env.events().publish( + (TOPIC_DELEGATION_REVOKED, owner.clone(), delegate.clone()), + (owner, delegate, now), + ); + + Ok(()) +} + +/// Read a delegation by `(owner, delegate)` pair. +/// +/// Errors: `NotFound` — no delegation exists for this pair. +pub fn get_delegation(env: &Env, owner: Address, delegate: Address) -> Result { + let key = DataKey::Delegation(owner, delegate); + let delegation: Delegation = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + storage::extend_persistent_ttl(env, &key); + Ok(delegation) +} + +// --- internal helpers ----------------------------------------------------- + +/// Validate that an optional expiry timestamp is strictly in the future. +fn validate_expiry(env: &Env, expires_at: Option) -> Result<(), Error> { + if let Some(exp) = expires_at { + if exp <= env.ledger().timestamp() { + return Err(Error::InvalidTimestamp); + } + } + Ok(()) +} + +/// Verify that `delegate` has at least `required` permission for `owner`'s +/// savings, and that the delegation has not expired. Returns the live +/// [`Delegation`] on success. +/// +/// Error mapping (using existing variants to stay within the 60-variant +/// `#[contracterror]` limit): +/// - `NotFound` — no delegation for this `(owner, delegate)` pair +/// - `OperationExpired` — delegation exists but has passed its `expires_at` +/// - `Unauthorized` — delegation level is below `required` +pub fn check_delegation( + env: &Env, + owner: &Address, + delegate: &Address, + required: PermissionLevel, +) -> Result { + let key = DataKey::Delegation(owner.clone(), delegate.clone()); + let delegation: Delegation = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; // no delegation for this pair + storage::extend_persistent_ttl(env, &key); + + if let Some(expires_at) = delegation.expires_at { + if env.ledger().timestamp() >= expires_at { + return Err(Error::OperationExpired); // delegation has expired + } + } + + if delegation.level < required { + return Err(Error::Unauthorized); // level below required permission + } + + Ok(delegation) +} + +// --- delegated flexible savings ------------------------------------------ + +/// Deposit `amount` into `owner`'s flexible account on the delegate's +/// behalf. The delegate authenticates; funds come from the **delegate's** +/// wallet. +/// +/// Requires at least `Deposit` permission. Applies the same deposit-cap and +/// minimum-deposit guards as a regular `deposit`. Emits a `deposit` event +/// keyed by the owner. +/// +/// Errors: `NotFound` (no delegation), `OperationExpired` (delegation +/// expired), `Unauthorized` (level below `Deposit`), `InvalidAmount`, +/// `DepositBelowMinimum`, `DepositCapExceeded`, `Overflow`, +/// `NotInitialized`, `Paused`. +pub fn delegated_deposit( + env: &Env, + delegate: Address, + owner: Address, + amount: i128, +) -> Result<(), Error> { + extend_instance_ttl(env); + require_not_paused(env)?; + delegate.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + check_delegation(env, &owner, &delegate, PermissionLevel::Deposit)?; + + let min = admin::min_deposit(env); + if min > 0 && amount < min { + return Err(Error::DepositBelowMinimum); + } + + let now = env.ledger().timestamp(); + let key = DataKey::Flexible(owner.clone()); + let existing: Option = env.storage().persistent().get(&key); + if existing.is_some() { + storage::extend_persistent_ttl(env, &key); + } + let mut account = existing.unwrap_or(FlexibleAccount { + owner: owner.clone(), + balance: 0, + created_at: now, + updated_at: now, + }); + + let new_balance = account.balance.checked_add(amount).ok_or(Error::Overflow)?; + + let cap = admin::deposit_cap(env); + if cap > 0 && new_balance > cap { + return Err(Error::DepositCapExceeded); + } + + // Funds come from the delegate's wallet, not the owner's. + storage::transfer_in(env, &delegate, amount)?; + + account.balance = new_balance; + account.updated_at = now; + env.storage().persistent().set(&key, &account); + storage::extend_persistent_ttl(env, &key); + + env.events().publish( + (TOPIC_DEPOSIT, owner.clone()), + (owner, amount, new_balance, now), + ); + + Ok(()) +} + +/// Withdraw `amount` from `owner`'s flexible account. The delegate +/// authenticates, but funds are always transferred to the **owner's** +/// wallet, never the delegate's. +/// +/// Requires `Full` permission (delegates with `Deposit` or `ViewOnly` cannot +/// withdraw). Emits a `withdraw` event keyed by the owner. +/// +/// Errors: `NotFound` (no delegation or no flexible account), +/// `OperationExpired` (delegation expired), `Unauthorized` (level below +/// `Full`), `InvalidAmount`, `InsufficientBalance`, `Overflow`, +/// `NotInitialized`, `Paused`. +pub fn delegated_withdraw( + env: &Env, + delegate: Address, + owner: Address, + amount: i128, +) -> Result<(), Error> { + extend_instance_ttl(env); + require_not_paused(env)?; + delegate.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + check_delegation(env, &owner, &delegate, PermissionLevel::Full)?; + + let key = DataKey::Flexible(owner.clone()); + let mut account: FlexibleAccount = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + storage::extend_persistent_ttl(env, &key); + + if amount > account.balance { + return Err(Error::InsufficientBalance); + } + + let new_balance = account.balance.checked_sub(amount).ok_or(Error::Overflow)?; + + // Funds always go to the owner's wallet, never the delegate's. + storage::transfer_out(env, &owner, amount)?; + + let now = env.ledger().timestamp(); + account.balance = new_balance; + account.updated_at = now; + env.storage().persistent().set(&key, &account); + storage::extend_persistent_ttl(env, &key); + + env.events().publish( + (TOPIC_WITHDRAW, owner.clone()), + (owner, amount, new_balance, now), + ); + + Ok(()) +} diff --git a/contracts/savings-vault/src/error.rs b/contracts/savings-vault/src/error.rs index 7fd92073..cf69d3d9 100644 --- a/contracts/savings-vault/src/error.rs +++ b/contracts/savings-vault/src/error.rs @@ -3,7 +3,7 @@ use soroban_sdk::contracterror; /// Contract-wide error codes. /// /// Keep these stable and append-only — clients and the indexer map to them. -/// +/// /// Error codes are organized by category for maintainability: /// - 1-9: Lifecycle and initialization /// - 10-19: Authentication and authorization @@ -31,12 +31,6 @@ pub enum Error { // --- Authentication & Authorization (10-19) --- /// Caller is not authorized for this action. Unauthorized = 10, - /// The provided admin address is invalid. - InvalidAdmin = 11, - /// The action requires admin privileges. - AdminOnly = 12, - /// The caller has been banned from this contract. - CallerBanned = 13, // --- Generic Validation (20-29) --- /// A provided amount was zero or negative. @@ -57,24 +51,10 @@ pub enum Error { StillLocked = 30, /// The provided unlock time is in the past. InvalidUnlockTime = 31, - /// Lock duration exceeds maximum allowed. - LockDurationTooLong = 32, - /// Lock duration is below minimum allowed. - LockDurationTooShort = 33, - /// Early unlock is not permitted for this lock type. - EarlyUnlockNotAllowed = 34, // --- Goal Savings (40-49) --- /// The goal target has not yet been reached. GoalNotReached = 40, - /// Invalid goal target amount. - InvalidGoalTarget = 41, - /// Goal deadline has passed. - GoalDeadlinePassed = 42, - /// Goal has already been completed. - GoalAlreadyCompleted = 43, - /// Goal milestone configuration is invalid. - InvalidMilestone = 44, // --- Group Savings (50-59) --- /// The group is full or the round is closed to new members. @@ -174,13 +154,10 @@ impl Error { Error::AlreadyInitialized => "Contract has already been initialized", Error::NotInitialized => "Contract must be initialized before this call", Error::InvalidInitialization => "Invalid initialization parameters provided", - + // Authentication & Authorization Error::Unauthorized => "Caller is not authorized for this action", - Error::InvalidAdmin => "The provided admin address is invalid", - Error::AdminOnly => "The action requires admin privileges", - Error::CallerBanned => "The caller has been banned from this contract", - + // Generic Validation Error::InvalidAmount => "A provided amount was zero or negative", Error::NotFound => "The referenced resource does not exist", @@ -188,21 +165,14 @@ impl Error { Error::InvalidAddress => "The provided address is invalid", Error::ConflictingState => "The operation conflicts with existing state", Error::InvalidParameters => "Invalid parameter combination provided", - + // Locked Savings Error::StillLocked => "Funds are still locked; unlock time not reached", Error::InvalidUnlockTime => "The provided unlock time is in the past", - Error::LockDurationTooLong => "Lock duration exceeds maximum allowed", - Error::LockDurationTooShort => "Lock duration is below minimum allowed", - Error::EarlyUnlockNotAllowed => "Early unlock is not permitted for this lock type", - + // Goal Savings Error::GoalNotReached => "The goal target has not yet been reached", - Error::InvalidGoalTarget => "Invalid goal target amount", - Error::GoalDeadlinePassed => "Goal deadline has passed", - Error::GoalAlreadyCompleted => "Goal has already been completed", - Error::InvalidMilestone => "Goal milestone configuration is invalid", - + // Group Savings Error::GroupClosed => "The group is full or closed to new members", Error::InvalidShares => "Member shares do not sum to expected total", @@ -213,39 +183,39 @@ impl Error { Error::MemberAlreadyExists => "Member already exists in group", Error::AlreadyVoted => "Caller has already voted on this proposal", Error::ProposalAlreadyExecuted => "The proposal has already been executed", - + // Arithmetic & Limits Error::Overflow => "Arithmetic operation would overflow or underflow", Error::DepositCapExceeded => "Deposit would exceed per-account cap", Error::WithdrawExceedsBalance => "Withdrawal exceeds available balance", Error::DivisionByZero => "Division by zero attempted", Error::NegativeResult => "Result is negative when positive was required", - + // Admin Lifecycle Error::Paused => "The contract is paused", Error::NotPaused => "The contract must be paused for this operation", Error::EmergencyShutdown => "Emergency shutdown has been activated", Error::UpgradeFailed => "Contract upgrade failed", - + // Policy Limits Error::DepositBelowMinimum => "Deposit amount is below minimum", Error::WithdrawBelowMinimum => "Withdrawal amount is below minimum", Error::DailyLimitExceeded => "Daily transaction limit exceeded", Error::RateLimitExceeded => "Rate limit exceeded for this operation", Error::ConcurrentOperationLimitExceeded => "Too many concurrent operations", - + // Time-based Operations Error::CooldownActive => "Cooldown period is still active", Error::InvalidTimestamp => "The provided timestamp is invalid", Error::OperationExpired => "Operation has expired", Error::TimeWindowNotOpen => "Time window for operation has not opened yet", - + // Token Operations Error::TransferFailed => "Token transfer failed", Error::InvalidToken => "Invalid token contract address", Error::InsufficientAllowance => "Token allowance is insufficient", Error::TokenCallFailed => "Token contract call failed", - + // Storage & State Error::StorageFailed => "Storage operation failed", Error::DataCorruption => "Data corruption detected", @@ -260,74 +230,62 @@ impl Error { /// Useful for metrics and categorized error handling. pub fn category(&self) -> &'static str { match self { - Error::AlreadyInitialized - | Error::NotInitialized - | Error::InvalidInitialization => "Lifecycle", - - Error::Unauthorized - | Error::InvalidAdmin - | Error::AdminOnly - | Error::CallerBanned => "Authorization", - - Error::InvalidAmount - | Error::NotFound - | Error::InsufficientBalance - | Error::InvalidAddress - | Error::ConflictingState + Error::AlreadyInitialized | Error::NotInitialized | Error::InvalidInitialization => { + "Lifecycle" + } + + Error::Unauthorized => "Authorization", + + Error::InvalidAmount + | Error::NotFound + | Error::InsufficientBalance + | Error::InvalidAddress + | Error::ConflictingState | Error::InvalidParameters => "Validation", - - Error::StillLocked - | Error::InvalidUnlockTime - | Error::LockDurationTooLong - | Error::LockDurationTooShort - | Error::EarlyUnlockNotAllowed => "LockedSavings", - - Error::GoalNotReached - | Error::InvalidGoalTarget - | Error::GoalDeadlinePassed - | Error::GoalAlreadyCompleted - | Error::InvalidMilestone => "GoalSavings", - - Error::GroupClosed - | Error::InvalidShares - | Error::NotAMember - | Error::GroupFull - | Error::InvalidPayoutSchedule - | Error::InsufficientGroupFunds - | Error::MemberAlreadyExists - | Error::AlreadyVoted + + Error::StillLocked | Error::InvalidUnlockTime => "LockedSavings", + + Error::GoalNotReached => "GoalSavings", + + Error::GroupClosed + | Error::InvalidShares + | Error::NotAMember + | Error::GroupFull + | Error::InvalidPayoutSchedule + | Error::InsufficientGroupFunds + | Error::MemberAlreadyExists + | Error::AlreadyVoted | Error::ProposalAlreadyExecuted => "GroupSavings", - - Error::Overflow - | Error::DepositCapExceeded - | Error::WithdrawExceedsBalance - | Error::DivisionByZero + + Error::Overflow + | Error::DepositCapExceeded + | Error::WithdrawExceedsBalance + | Error::DivisionByZero | Error::NegativeResult => "Arithmetic", - - Error::Paused - | Error::NotPaused - | Error::EmergencyShutdown - | Error::UpgradeFailed => "AdminLifecycle", - - Error::DepositBelowMinimum - | Error::WithdrawBelowMinimum - | Error::DailyLimitExceeded - | Error::RateLimitExceeded + + Error::Paused | Error::NotPaused | Error::EmergencyShutdown | Error::UpgradeFailed => { + "AdminLifecycle" + } + + Error::DepositBelowMinimum + | Error::WithdrawBelowMinimum + | Error::DailyLimitExceeded + | Error::RateLimitExceeded | Error::ConcurrentOperationLimitExceeded => "PolicyLimits", - - Error::CooldownActive - | Error::InvalidTimestamp - | Error::OperationExpired + + Error::CooldownActive + | Error::InvalidTimestamp + | Error::OperationExpired | Error::TimeWindowNotOpen => "TimeBasedOperations", - - Error::TransferFailed - | Error::InvalidToken - | Error::InsufficientAllowance + + Error::TransferFailed + | Error::InvalidToken + | Error::InsufficientAllowance | Error::TokenCallFailed => "TokenOperations", - - Error::StorageFailed - | Error::DataCorruption - | Error::StorageLimitExceeded + + Error::StorageFailed + | Error::DataCorruption + | Error::StorageLimitExceeded | Error::InvalidStateTransition => "StorageAndState", Error::SameLedger | Error::ReentrantCall => "Security", @@ -351,9 +309,7 @@ impl Error { pub fn is_fatal(&self) -> bool { matches!( self, - Error::DataCorruption - | Error::EmergencyShutdown - | Error::UpgradeFailed + Error::DataCorruption | Error::EmergencyShutdown | Error::UpgradeFailed ) } } diff --git a/contracts/savings-vault/src/events.rs b/contracts/savings-vault/src/events.rs index 9a1044c3..ab01c077 100644 --- a/contracts/savings-vault/src/events.rs +++ b/contracts/savings-vault/src/events.rs @@ -21,6 +21,8 @@ pub const EVENT_SCHEMA_VERSION: u32 = 1; pub const TOPIC_INIT: &str = "init"; /// Emitted at the end of a successful `set_admin` call. pub const TOPIC_ADMIN_SET: &str = "admin_set"; +/// Emitted at the end of a successful `upgrade` call. +pub const TOPIC_UPGRADED: &str = "upgraded"; // --- flexible ------------------------------------------------------------ /// Emitted at the end of a successful `deposit` call. @@ -102,3 +104,22 @@ pub const TOPIC_INTEREST_WITHDRAW: &str = "interest_withdraw"; /// Emitted at the end of a successful `interest_claim` call, recording the /// interest credited to principal. pub const TOPIC_INTEREST_CLAIMED: &str = "interest_claimed"; + +// --- delegation ---------------------------------------------------------- +/// Emitted at the end of a successful `delegate_savings` call when a new +/// delegation is created. +/// +/// Topics: `(Symbol("delegation_granted"), owner: Address, delegate: Address)` +/// Data: `(owner: Address, delegate: Address, level: PermissionLevel, expires_at: u64, timestamp: u64)` +pub const TOPIC_DELEGATION_GRANTED: &str = "delegation_granted"; +/// Emitted at the end of a successful `update_delegation` call when an +/// existing delegation's permission level or expiry is changed. +/// +/// Topics: `(Symbol("delegation_updated"), owner: Address, delegate: Address)` +/// Data: `(owner: Address, delegate: Address, level: PermissionLevel, expires_at: u64, timestamp: u64)` +pub const TOPIC_DELEGATION_UPDATED: &str = "delegation_updated"; +/// Emitted at the end of a successful `revoke_delegation` call. +/// +/// Topics: `(Symbol("delegation_revoked"), owner: Address, delegate: Address)` +/// Data: `(owner: Address, delegate: Address, timestamp: u64)` +pub const TOPIC_DELEGATION_REVOKED: &str = "delegation_revoked"; diff --git a/contracts/savings-vault/src/flexible.rs b/contracts/savings-vault/src/flexible.rs index 83629f1d..8bfd4bdb 100644 --- a/contracts/savings-vault/src/flexible.rs +++ b/contracts/savings-vault/src/flexible.rs @@ -66,8 +66,10 @@ pub fn deposit(env: &Env, from: Address, amount: i128) -> Result<(), Error> { env.storage().persistent().set(&key, &account); storage::extend_persistent_ttl(env, &key); - env.events() - .publish((TOPIC_DEPOSIT, from.clone()), (from, amount, new_balance, now)); + env.events().publish( + (TOPIC_DEPOSIT, from.clone()), + (from, amount, new_balance, now), + ); Ok(()) } @@ -96,8 +98,11 @@ pub fn withdraw(env: &Env, owner: Address, amount: i128) -> Result<(), Error> { } let key = DataKey::Flexible(owner.clone()); - let mut account: FlexibleAccount = - env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut account: FlexibleAccount = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if amount > account.balance { @@ -125,7 +130,11 @@ pub fn withdraw(env: &Env, owner: Address, amount: i128) -> Result<(), Error> { /// Read the caller's flexible account (or `NotFound`). pub fn get_account(env: &Env, owner: Address) -> Result { let key = DataKey::Flexible(owner); - let account = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let account = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(account) } diff --git a/contracts/savings-vault/src/goal.rs b/contracts/savings-vault/src/goal.rs index 247a7fb5..56cb7189 100644 --- a/contracts/savings-vault/src/goal.rs +++ b/contracts/savings-vault/src/goal.rs @@ -190,10 +190,8 @@ pub fn claim(env: &Env, owner: Address, goal_id: u64) -> Result<(), Error> { } let contribution_ledger_key = DataKey::GoalLastContributionLedger(goal_id); - let last_contribution_ledger: Option = env - .storage() - .persistent() - .get(&contribution_ledger_key); + let last_contribution_ledger: Option = + env.storage().persistent().get(&contribution_ledger_key); if last_contribution_ledger.is_some_and(|sequence| env.ledger().sequence() <= sequence) { return Err(Error::SameLedger); } @@ -229,9 +227,7 @@ pub fn claim(env: &Env, owner: Address, goal_id: u64) -> Result<(), Error> { goal.saved_amount = 0; goal.reached_at = None; env.storage().persistent().remove(&contributions_key); - env.storage() - .persistent() - .remove(&contribution_ledger_key); + env.storage().persistent().remove(&contribution_ledger_key); env.storage().persistent().set(&key, &goal); storage::extend_persistent_ttl(env, &key); @@ -244,9 +240,7 @@ pub fn claim(env: &Env, owner: Address, goal_id: u64) -> Result<(), Error> { // zero-balance record behind. env.storage().persistent().remove(&key); env.storage().persistent().remove(&contributions_key); - env.storage() - .persistent() - .remove(&contribution_ledger_key); + env.storage().persistent().remove(&contribution_ledger_key); } Ok(()) @@ -292,7 +286,11 @@ pub fn set_auto_renew( pub fn get_goal(env: &Env, goal_id: u64) -> Result { let key = DataKey::Goal(goal_id); - let goal = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let goal = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(goal) } diff --git a/contracts/savings-vault/src/group.rs b/contracts/savings-vault/src/group.rs index 7c98b8cf..de3b86dc 100644 --- a/contracts/savings-vault/src/group.rs +++ b/contracts/savings-vault/src/group.rs @@ -253,7 +253,11 @@ pub fn payout_equal(env: &Env, caller: Address, group_id: u64) -> Result<(), Err pub fn get_group(env: &Env, group_id: u64) -> Result { let key = DataKey::Group(group_id); - let group = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let group = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(group) } diff --git a/contracts/savings-vault/src/group_split.rs b/contracts/savings-vault/src/group_split.rs index bfe3f580..072ba78f 100644 --- a/contracts/savings-vault/src/group_split.rs +++ b/contracts/savings-vault/src/group_split.rs @@ -64,7 +64,11 @@ pub fn set_shares( creator.require_auth(); let key = DataKey::Group(group_id); - let mut group: Group = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut group: Group = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if group.creator != creator { @@ -96,7 +100,12 @@ pub fn set_shares( env.events().publish( (TOPIC_GROUP_SHARES_SET, creator.clone(), group_id), - (group_id, creator, group.shares_bps.clone(), env.ledger().timestamp()), + ( + group_id, + creator, + group.shares_bps.clone(), + env.ledger().timestamp(), + ), ); Ok(()) @@ -137,7 +146,11 @@ pub fn settle(env: &Env, caller: Address, group_id: u64) -> Result<(), Error> { caller.require_auth(); let key = DataKey::Group(group_id); - let mut group: Group = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut group: Group = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); // Shares can only be set on a closed group, so we check `open` first. diff --git a/contracts/savings-vault/src/interest.rs b/contracts/savings-vault/src/interest.rs index 4ce21258..79a73178 100644 --- a/contracts/savings-vault/src/interest.rs +++ b/contracts/savings-vault/src/interest.rs @@ -172,7 +172,10 @@ pub fn deposit(env: &Env, owner: Address, amount: i128) -> Result<(), Error> { account.rate_bps = rate_bps(env); account.frequency = frequency(env); - account.principal = account.principal.checked_add(amount).ok_or(Error::Overflow)?; + account.principal = account + .principal + .checked_add(amount) + .ok_or(Error::Overflow)?; account.updated_at = now; storage::transfer_in(env, &owner, amount)?; @@ -206,8 +209,11 @@ pub fn withdraw(env: &Env, owner: Address, amount: i128) -> Result<(), Error> { } let key = DataKey::InterestAccount(owner.clone()); - let mut account: InterestAccount = - env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut account: InterestAccount = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); let now = env.ledger().timestamp(); @@ -219,7 +225,10 @@ pub fn withdraw(env: &Env, owner: Address, amount: i128) -> Result<(), Error> { return Err(Error::InsufficientBalance); } - let new_principal = account.principal.checked_sub(amount).ok_or(Error::Overflow)?; + let new_principal = account + .principal + .checked_sub(amount) + .ok_or(Error::Overflow)?; storage::transfer_out(env, &owner, amount)?; @@ -250,8 +259,11 @@ pub fn claim(env: &Env, owner: Address) -> Result { owner.require_auth(); let key = DataKey::InterestAccount(owner.clone()); - let mut account: InterestAccount = - env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut account: InterestAccount = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); let now = env.ledger().timestamp(); @@ -274,7 +286,11 @@ pub fn claim(env: &Env, owner: Address) -> Result { /// Read an interest account (or `NotFound`). pub fn get_account(env: &Env, owner: Address) -> Result { let key = DataKey::InterestAccount(owner); - let account = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let account = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(account) } @@ -285,8 +301,11 @@ pub fn get_account(env: &Env, owner: Address) -> Result /// Errors: `NotFound` if the account does not exist, `Overflow`. pub fn accrued(env: &Env, owner: Address) -> Result { let key = DataKey::InterestAccount(owner); - let account: InterestAccount = - env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let account: InterestAccount = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); let now = env.ledger().timestamp(); @@ -303,7 +322,9 @@ pub fn accrued(env: &Env, owner: Address) -> Result { account.frequency.periods_per_year(), periods, )?; - compounded.checked_sub(account.principal).ok_or(Error::Overflow) + compounded + .checked_sub(account.principal) + .ok_or(Error::Overflow) } /// Compound `account.principal` over the whole periods elapsed since @@ -361,7 +382,9 @@ fn period_factor(env: &Env, rate_bps: u32, periods_per_year: u32) -> Result Result { + pub fn locked_create( + env: Env, + owner: Address, + amount: i128, + unlock_at: u64, + ) -> Result { locked::create(&env, owner, amount, unlock_at) } - pub fn locked_top_up(env: Env, owner: Address, plan_id: u64, amount: i128) -> Result<(), Error> { + pub fn locked_top_up( + env: Env, + owner: Address, + plan_id: u64, + amount: i128, + ) -> Result<(), Error> { locked::top_up(&env, owner, plan_id, amount) } - pub fn locked_withdraw(env: Env, owner: Address, plan_id: u64, amount: i128) -> Result<(), Error> { + pub fn locked_withdraw( + env: Env, + owner: Address, + plan_id: u64, + amount: i128, + ) -> Result<(), Error> { locked::withdraw(&env, owner, plan_id, amount) } @@ -172,11 +189,21 @@ impl SavingsVault { } // --- goal -------------------------------------------------------------- - pub fn goal_create(env: Env, owner: Address, name: String, target_amount: i128) -> Result { + pub fn goal_create( + env: Env, + owner: Address, + name: String, + target_amount: i128, + ) -> Result { goal::create(&env, owner, name, target_amount) } - pub fn goal_contribute(env: Env, from: Address, goal_id: u64, amount: i128) -> Result<(), Error> { + pub fn goal_contribute( + env: Env, + from: Address, + goal_id: u64, + amount: i128, + ) -> Result<(), Error> { goal::contribute(&env, from, goal_id, amount) } @@ -212,7 +239,12 @@ impl SavingsVault { group::join(&env, member, group_id) } - pub fn group_contribute(env: Env, member: Address, group_id: u64, amount: i128) -> Result<(), Error> { + pub fn group_contribute( + env: Env, + member: Address, + group_id: u64, + amount: i128, + ) -> Result<(), Error> { group::contribute(&env, member, group_id, amount) } @@ -229,7 +261,12 @@ impl SavingsVault { } // --- group split ------------------------------------------------------- - pub fn group_set_shares(env: Env, creator: Address, group_id: u64, shares_bps: Map) -> Result<(), Error> { + pub fn group_set_shares( + env: Env, + creator: Address, + group_id: u64, + shares_bps: Map, + ) -> Result<(), Error> { group_split::set_shares(&env, creator, group_id, shares_bps) } @@ -335,4 +372,69 @@ impl SavingsVault { pub fn interest_accrued(env: Env, owner: Address) -> Result { interest::accrued(&env, owner) } + + // --- delegation -------------------------------------------------------- + /// Grant a delegate permission to manage `owner`'s savings at `level`, + /// optionally expiring at `expires_at` (a ledger timestamp; `None` means + /// no expiry). Owner-only. See [`delegation::delegate_savings`]. + pub fn delegate_savings( + env: Env, + owner: Address, + delegate: Address, + level: PermissionLevel, + expires_at: Option, + ) -> Result<(), Error> { + delegation::delegate_savings(&env, owner, delegate, level, expires_at) + } + + /// Update an existing delegation's permission level and/or expiry. + /// Owner-only. See [`delegation::update_delegation`]. + pub fn update_delegation( + env: Env, + owner: Address, + delegate: Address, + level: PermissionLevel, + expires_at: Option, + ) -> Result<(), Error> { + delegation::update_delegation(&env, owner, delegate, level, expires_at) + } + + /// Revoke a delegation. The delegate immediately loses access. + /// Owner-only. See [`delegation::revoke_delegation`]. + pub fn revoke_delegation(env: Env, owner: Address, delegate: Address) -> Result<(), Error> { + delegation::revoke_delegation(&env, owner, delegate) + } + + /// Read a delegation by `(owner, delegate)` pair (or `DelegationNotFound`). + pub fn get_delegation( + env: Env, + owner: Address, + delegate: Address, + ) -> Result { + delegation::get_delegation(&env, owner, delegate) + } + + /// Deposit into `owner`'s flexible account on the delegate's behalf. + /// The delegate authenticates; funds come from the delegate's wallet. + /// Requires at least `Deposit` permission. + pub fn delegated_deposit( + env: Env, + delegate: Address, + owner: Address, + amount: i128, + ) -> Result<(), Error> { + delegation::delegated_deposit(&env, delegate, owner, amount) + } + + /// Withdraw from `owner`'s flexible account. The delegate authenticates, + /// but funds are always transferred to the **owner's** wallet, never the + /// delegate's. Requires `Full` permission. + pub fn delegated_withdraw( + env: Env, + delegate: Address, + owner: Address, + amount: i128, + ) -> Result<(), Error> { + delegation::delegated_withdraw(&env, delegate, owner, amount) + } } diff --git a/contracts/savings-vault/src/locked.rs b/contracts/savings-vault/src/locked.rs index 8b2e33dd..87d79294 100644 --- a/contracts/savings-vault/src/locked.rs +++ b/contracts/savings-vault/src/locked.rs @@ -63,7 +63,11 @@ pub fn top_up(env: &Env, owner: Address, plan_id: u64, amount: i128) -> Result<( } let key = DataKey::Locked(plan_id); - let mut plan: LockedPlan = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut plan: LockedPlan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if plan.owner != owner { @@ -110,7 +114,11 @@ pub fn withdraw(env: &Env, owner: Address, plan_id: u64, amount: i128) -> Result } let key = DataKey::Locked(plan_id); - let mut plan: LockedPlan = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut plan: LockedPlan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if plan.owner != owner { @@ -143,7 +151,11 @@ pub fn withdraw(env: &Env, owner: Address, plan_id: u64, amount: i128) -> Result pub fn get_plan(env: &Env, plan_id: u64) -> Result { let key = DataKey::Locked(plan_id); - let plan = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let plan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(plan) } @@ -186,7 +198,11 @@ pub fn early_withdraw(env: &Env, owner: Address, plan_id: u64, amount: i128) -> } let key = DataKey::Locked(plan_id); - let mut plan: LockedPlan = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut plan: LockedPlan = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if plan.owner != owner { @@ -214,10 +230,7 @@ pub fn early_withdraw(env: &Env, owner: Address, plan_id: u64, amount: i128) -> } let bps = admin::early_withdraw_penalty_bps(env); - let penalty = amount - .checked_mul(bps as i128) - .ok_or(Error::Overflow)? - / 10_000; + let penalty = amount.checked_mul(bps as i128).ok_or(Error::Overflow)? / 10_000; let net = amount.checked_sub(penalty).ok_or(Error::Overflow)?; if net > 0 { diff --git a/contracts/savings-vault/src/storage.rs b/contracts/savings-vault/src/storage.rs index 49b58763..a197bb43 100644 --- a/contracts/savings-vault/src/storage.rs +++ b/contracts/savings-vault/src/storage.rs @@ -172,7 +172,12 @@ pub fn transfer_out(env: &Env, to: &Address, amount: i128) -> Result<(), Error> pub(crate) fn begin_token_transfer(env: &Env) -> Result<(), Error> { let key = DataKey::TokenTransferActive; - if env.storage().instance().get::<_, bool>(&key).unwrap_or(false) { + if env + .storage() + .instance() + .get::<_, bool>(&key) + .unwrap_or(false) + { return Err(Error::ReentrantCall); } env.storage().instance().set(&key, &true); diff --git a/contracts/savings-vault/src/test.rs b/contracts/savings-vault/src/test.rs index a6c12cbb..256e3e28 100644 --- a/contracts/savings-vault/src/test.rs +++ b/contracts/savings-vault/src/test.rs @@ -9,15 +9,16 @@ //! the bare contract shell (no token) can still call [`setup`]. use soroban_sdk::{ + contract, contractimpl, testutils::{Address as _, Events as _, Ledger, LedgerInfo}, token::StellarAssetClient, - contract, contractimpl, Address, Env, IntoVal, Map, String, + Address, Env, IntoVal, Map, String, }; use crate::error::Error; use crate::events::{TOPIC_DEPOSIT, TOPIC_WITHDRAW}; use crate::group_split; -use crate::types::CompoundFrequency; +use crate::types::{CompoundFrequency, PermissionLevel}; use crate::{SavingsVault, SavingsVaultClient}; #[contract] @@ -26,30 +27,58 @@ struct ReentrantToken; #[contractimpl] impl ReentrantToken { pub fn configure(env: Env, vault: Address, owner: Address, goal_id: u64) { - env.storage().instance().set(&soroban_sdk::symbol_short!("vault"), &vault); - env.storage().instance().set(&soroban_sdk::symbol_short!("owner"), &owner); - env.storage().instance().set(&soroban_sdk::symbol_short!("goalid"), &goal_id); + env.storage() + .instance() + .set(&soroban_sdk::symbol_short!("vault"), &vault); + env.storage() + .instance() + .set(&soroban_sdk::symbol_short!("owner"), &owner); + env.storage() + .instance() + .set(&soroban_sdk::symbol_short!("goalid"), &goal_id); } pub fn transfer(env: Env, _from: Address, _to: Address, _amount: i128) { - let vault: Address = env.storage().instance().get(&soroban_sdk::symbol_short!("vault")).unwrap(); - let owner: Address = env.storage().instance().get(&soroban_sdk::symbol_short!("owner")).unwrap(); - let goal_id: u64 = env.storage().instance().get(&soroban_sdk::symbol_short!("goalid")).unwrap(); - let result = SavingsVaultClient::new(&env, &vault) - .try_goal_set_auto_renew(&owner, &goal_id, &true); + let vault: Address = env + .storage() + .instance() + .get(&soroban_sdk::symbol_short!("vault")) + .unwrap(); + let owner: Address = env + .storage() + .instance() + .get(&soroban_sdk::symbol_short!("owner")) + .unwrap(); + let goal_id: u64 = env + .storage() + .instance() + .get(&soroban_sdk::symbol_short!("goalid")) + .unwrap(); + let result = + SavingsVaultClient::new(&env, &vault).try_goal_set_auto_renew(&owner, &goal_id, &true); let rejected = matches!(result, Err(Ok(Error::ReentrantCall))); - env.storage().instance().set(&soroban_sdk::symbol_short!("rejected"), &rejected); + env.storage() + .instance() + .set(&soroban_sdk::symbol_short!("rejected"), &rejected); let read_result = SavingsVaultClient::new(&env, &vault).try_goal(&goal_id); let read_rejected = matches!(read_result, Err(Ok(Error::ReentrantCall))); - env.storage().instance().set(&soroban_sdk::symbol_short!("read_rej"), &read_rejected); + env.storage() + .instance() + .set(&soroban_sdk::symbol_short!("read_rej"), &read_rejected); } pub fn reentry_rejected(env: Env) -> bool { - env.storage().instance().get(&soroban_sdk::symbol_short!("rejected")).unwrap_or(false) + env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("rejected")) + .unwrap_or(false) } pub fn readonly_reentry_rejected(env: Env) -> bool { - env.storage().instance().get(&soroban_sdk::symbol_short!("read_rej")).unwrap_or(false) + env.storage() + .instance() + .get(&soroban_sdk::symbol_short!("read_rej")) + .unwrap_or(false) } } @@ -110,11 +139,7 @@ fn set_ledger_time(env: &Env, timestamp: u64, sequence_number: u32) { } fn advance_ledger(env: &Env) { - set_ledger_time( - env, - env.ledger().timestamp(), - env.ledger().sequence() + 1, - ); + set_ledger_time(env, env.ledger().timestamp(), env.ledger().sequence() + 1); } // --------------------------------------------------------------------------- @@ -1231,7 +1256,7 @@ fn event_goal_reached_is_size_optimized() { mint(&env, &token, &token_admin, &owner, TARGET); let goal_id = client.goal_create(&owner, &String::from_str(&env, "milestone"), &TARGET); - + // Contribute exactly to target to trigger goal_reached event. client.goal_contribute(&owner, &goal_id, &TARGET); @@ -1342,7 +1367,11 @@ fn event_interest_claimed_is_gas_efficient() { claimed_event.topics.len() >= 2, "interest_claimed must have indexed user address" ); -}lan's balance — isolates the time-lock guard from the balance guard +} + +/// Locked plan withdrawal before the unlock time must be rejected with +/// `Error::StillLocked`, even when the requested amount is well within the +/// plan's balance — isolates the time-lock guard from the balance guard /// (paired with `locked_withdraw_over_balance_rejected` above, which /// isolates the balance guard from the time-lock guard). #[test] @@ -1556,7 +1585,10 @@ fn locked_early_withdraw_before_min_lock_period_rejected() { // Once the minimum period has elapsed (and before unlock), it succeeds. set_ledger_time(&env, now + MIN_LOCK_PERIOD, 102); client.locked_early_withdraw(&owner, &plan_id, &1_000_000); - assert_eq!(client.locked_plan(&plan_id).balance, LOCKED_AMOUNT - 1_000_000); + assert_eq!( + client.locked_plan(&plan_id).balance, + LOCKED_AMOUNT - 1_000_000 + ); } /// Once `unlock_at` has passed, the penalized early path is closed; the @@ -1664,7 +1696,10 @@ fn locked_early_withdraw_defaults_to_no_penalty() { WITHDRAW, "with no penalty configured the owner receives the full amount", ); - assert_eq!(client.locked_plan(&plan_id).balance, LOCKED_AMOUNT - WITHDRAW); + assert_eq!( + client.locked_plan(&plan_id).balance, + LOCKED_AMOUNT - WITHDRAW + ); } /// The configured penalty rate is capped at 100% (10_000 bps). @@ -2789,7 +2824,14 @@ fn locked_early_withdraw_emits_typed_event_with_expected_topic_and_data() { assert_eq!(topics, expected_topics); assert_eq!( decoded_data, - (plan_id, owner, WITHDRAW, PENALTY, AMOUNT - WITHDRAW, call_time) + ( + plan_id, + owner, + WITHDRAW, + PENALTY, + AMOUNT - WITHDRAW, + call_time + ) ); } @@ -3502,15 +3544,7 @@ fn group_propose_without_voting_period_rejected() { let recipient = Address::generate(&env); // period = 0 disables proposals. - let group_id = closed_group_with_voting( - &env, - &client, - &token, - &token_admin, - &creator, - &[], - 0, - ); + let group_id = closed_group_with_voting(&env, &client, &token, &token_admin, &creator, &[], 0); let result = client.try_group_propose(&creator, &group_id, &recipient, &1_000_000); assert_eq!( @@ -3621,7 +3655,14 @@ fn group_propose_emits_typed_event_with_expected_topic_and_data() { assert_eq!(topics, expected_topics); assert_eq!( decoded_data, - (proposal_id, group_id, recipient, SPEND, now + VOTING_PERIOD, now) + ( + proposal_id, + group_id, + recipient, + SPEND, + now + VOTING_PERIOD, + now + ) ); } @@ -4223,3 +4264,721 @@ fn token_callback_cannot_reenter_vault_mutation() { assert!(!client.goal(&goal_id).auto_renew); assert_eq!(client.goal(&goal_id).saved_amount, 1); } + +// --------------------------------------------------------------------------- +// Issue #437 — Savings delegation +// +// An owner authorizes a delegate to manage savings on their behalf at a +// given PermissionLevel (ViewOnly, Deposit, Full), optionally with an +// expiry. Tests cover: grant, each permission level (allowed/denied), +// expiry, revoke, unauthorized caller, self-delegation, duplicate +// delegation, update, and events. +// --------------------------------------------------------------------------- + +/// Granting a delegation stores it with the right fields and no expiry. +#[test] +fn delegation_grant_stores_delegation() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + let d = client.get_delegation(&owner, &delegate); + assert_eq!(d.owner, owner); + assert_eq!(d.delegate, delegate); + assert_eq!(d.level, PermissionLevel::Full); + assert!(d.expires_at.is_none()); +} + +/// Granting a delegation with an expiry stores the expiry timestamp. +#[test] +fn delegation_grant_with_expiry() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let expiry = env.ledger().timestamp() + 10_000; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &Some(expiry)); + + let d = client.get_delegation(&owner, &delegate); + assert_eq!(d.level, PermissionLevel::Deposit); + assert_eq!(d.expires_at, Some(expiry)); +} + +/// An owner cannot delegate to their own address. +#[test] +fn delegation_self_delegation_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + + let no_expiry: Option = None; + let result = client.try_delegate_savings(&owner, &owner, &PermissionLevel::Full, &no_expiry); + assert_eq!(result, Err(Ok(Error::InvalidAddress))); + + // No delegation should have been stored. + assert_eq!( + client.try_get_delegation(&owner, &owner), + Err(Ok(Error::NotFound)), + ); +} + +/// A duplicate delegation for the same (owner, delegate) pair is rejected. +#[test] +fn delegation_duplicate_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::ViewOnly, &no_expiry); + + let result = client.try_delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + assert_eq!(result, Err(Ok(Error::ConflictingState))); + + // The original delegation must be unchanged. + let d = client.get_delegation(&owner, &delegate); + assert_eq!(d.level, PermissionLevel::ViewOnly); +} + +/// An expiry at or before the current timestamp is rejected. +#[test] +fn delegation_past_expiry_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let now = env.ledger().timestamp(); + + // Expiry equal to now is invalid. + let result = client.try_delegate_savings(&owner, &delegate, &PermissionLevel::Full, &Some(now)); + assert_eq!(result, Err(Ok(Error::InvalidTimestamp))); + + // Expiry before now is invalid (guard against now > 0). + if now > 0 { + let result = + client.try_delegate_savings(&owner, &delegate, &PermissionLevel::Full, &Some(now - 1)); + assert_eq!(result, Err(Ok(Error::InvalidTimestamp))); + } +} + +/// Only the owner can grant a delegation. +#[test] +fn delegation_grant_by_non_owner_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + let result = + client.try_delegate_savings(&attacker, &delegate, &PermissionLevel::Full, &no_expiry); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +/// Only the owner can revoke a delegation. +#[test] +fn delegation_revoke_by_non_owner_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + let result = client.try_revoke_delegation(&attacker, &delegate); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Delegation must still exist. + let d = client.get_delegation(&owner, &delegate); + assert_eq!(d.level, PermissionLevel::Full); +} + +/// Updating a delegation changes its level and expiry. +#[test] +fn delegation_update_changes_level_and_expiry() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::ViewOnly, &no_expiry); + + let new_expiry = env.ledger().timestamp() + 50_000; + client.update_delegation(&owner, &delegate, &PermissionLevel::Full, &Some(new_expiry)); + + let d = client.get_delegation(&owner, &delegate); + assert_eq!(d.level, PermissionLevel::Full); + assert_eq!(d.expires_at, Some(new_expiry)); + // created_at must be unchanged; updated_at may have advanced. + let d0 = client.get_delegation(&owner, &delegate); + assert_eq!(d0.created_at, d.created_at); +} + +/// Updating a non-existent delegation is rejected. +#[test] +fn delegation_update_nonexistent_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + let result = + client.try_update_delegation(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + assert_eq!(result, Err(Ok(Error::NotFound))); +} + +/// Revoking a delegation removes it so get_delegation returns NotFound. +#[test] +fn delegation_revoke_removes_delegation() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + client.revoke_delegation(&owner, &delegate); + + assert_eq!( + client.try_get_delegation(&owner, &delegate), + Err(Ok(Error::NotFound)), + ); +} + +/// Revoking a non-existent delegation is rejected. +#[test] +fn delegation_revoke_nonexistent_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let result = client.try_revoke_delegation(&owner, &delegate); + assert_eq!(result, Err(Ok(Error::NotFound))); +} + +/// A delegate with Deposit permission can deposit into the owner's account. +#[test] +fn delegated_deposit_with_deposit_permission_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &no_expiry); + + client.delegated_deposit(&delegate, &owner, &AMOUNT); + + let account = client.get_account(&owner); + assert_eq!(account.balance, AMOUNT); + assert_eq!(account.owner, owner); +} + +/// A delegate with Full permission can also deposit. +#[test] +fn delegated_deposit_with_full_permission_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 30_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + client.delegated_deposit(&delegate, &owner, &AMOUNT); + assert_eq!(client.get_account(&owner).balance, AMOUNT); +} + +/// A delegate with ViewOnly permission cannot deposit. +#[test] +fn delegated_deposit_with_view_only_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::ViewOnly, &no_expiry); + + let result = client.try_delegated_deposit(&delegate, &owner, &AMOUNT); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Owner's account must not have been created. + assert_eq!(client.try_get_account(&owner), Err(Ok(Error::NotFound))); +} + +/// A delegate with no delegation at all cannot deposit. +#[test] +fn delegated_deposit_without_delegation_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let result = client.try_delegated_deposit(&delegate, &owner, &AMOUNT); + assert_eq!(result, Err(Ok(Error::NotFound))); +} + +/// A delegate with Full permission can withdraw, and the funds go to the +/// **owner's** wallet, never the delegate's. +#[test] +fn delegated_withdraw_with_full_permission_sends_to_owner() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + const WITHDRAW: i128 = 40_000_000; + + // Owner deposits their own funds first. + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + let token_client = soroban_sdk::token::Client::new(&env, &token); + let owner_before = token_client.balance(&owner); + let delegate_before = token_client.balance(&delegate); + + client.delegated_withdraw(&delegate, &owner, &WITHDRAW); + + // Funds must go to the owner, not the delegate. + assert_eq!( + token_client.balance(&owner) - owner_before, + WITHDRAW, + "withdrawn funds must go to the owner", + ); + assert_eq!( + token_client.balance(&delegate), + delegate_before, + "delegate must not receive withdrawn funds", + ); + assert_eq!(client.get_account(&owner).balance, DEPOSIT - WITHDRAW,); +} + +/// A delegate with Deposit permission (not Full) cannot withdraw. +#[test] +fn delegated_withdraw_with_deposit_permission_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &no_expiry); + + let result = client.try_delegated_withdraw(&delegate, &owner, &40_000_000); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + assert_eq!(client.get_account(&owner).balance, DEPOSIT); +} + +/// A delegate with ViewOnly permission cannot withdraw. +#[test] +fn delegated_withdraw_with_view_only_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::ViewOnly, &no_expiry); + + let result = client.try_delegated_withdraw(&delegate, &owner, &40_000_000); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +/// An expired delegation cannot be used to deposit. +#[test] +fn delegated_deposit_after_expiry_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let now = env.ledger().timestamp(); + let expiry = now + 1_000; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &Some(expiry)); + + // Advance past the expiry. + set_ledger_time(&env, expiry, 2); + + let result = client.try_delegated_deposit(&delegate, &owner, &AMOUNT); + assert_eq!(result, Err(Ok(Error::OperationExpired))); +} + +/// An expired delegation cannot be used to withdraw. +#[test] +fn delegated_withdraw_after_expiry_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let now = env.ledger().timestamp(); + let expiry = now + 1_000; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &Some(expiry)); + + // Advance past the expiry. + set_ledger_time(&env, expiry, 2); + + let result = client.try_delegated_withdraw(&delegate, &owner, &40_000_000); + assert_eq!(result, Err(Ok(Error::OperationExpired))); + assert_eq!(client.get_account(&owner).balance, DEPOSIT); +} + +/// A revoked delegation cannot be used to deposit. +#[test] +fn delegated_deposit_after_revoke_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &no_expiry); + client.revoke_delegation(&owner, &delegate); + + let result = client.try_delegated_deposit(&delegate, &owner, &AMOUNT); + assert_eq!(result, Err(Ok(Error::NotFound))); +} + +/// A revoked delegation cannot be used to withdraw. +#[test] +fn delegated_withdraw_after_revoke_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + client.revoke_delegation(&owner, &delegate); + + let result = client.try_delegated_withdraw(&delegate, &owner, &40_000_000); + assert_eq!(result, Err(Ok(Error::NotFound))); + assert_eq!(client.get_account(&owner).balance, DEPOSIT); +} + +/// `delegate_savings` emits a `delegation_granted` event with topics +/// `(delegation_granted, owner, delegate)` and data `(owner, delegate, +/// level, expires_at, timestamp)`. +#[test] +fn delegation_grant_emits_typed_event() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + let now = env.ledger().timestamp(); + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + let events = env.events().all(); + let (contract_id, topics, data) = events.last().unwrap().clone(); + + let expected_topics: soroban_sdk::Vec = ( + crate::events::TOPIC_DELEGATION_GRANTED, + owner.clone(), + delegate.clone(), + ) + .into_val(&env); + let decoded_data: (Address, Address, PermissionLevel, u64, u64) = + soroban_sdk::TryFromVal::try_from_val(&env, &data).unwrap(); + + assert_eq!(contract_id, client.address); + assert_eq!(topics, expected_topics); + assert_eq!( + decoded_data, + (owner, delegate, PermissionLevel::Full, 0, now) + ); +} + +/// `update_delegation` emits a `delegation_updated` event with topics +/// `(delegation_updated, owner, delegate)` and data `(owner, delegate, +/// level, expires_at, timestamp)`. +#[test] +fn delegation_update_emits_typed_event() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::ViewOnly, &no_expiry); + + let new_expiry = env.ledger().timestamp() + 50_000; + let now = env.ledger().timestamp(); + client.update_delegation(&owner, &delegate, &PermissionLevel::Full, &Some(new_expiry)); + + let events = env.events().all(); + let (contract_id, topics, data) = events.last().unwrap().clone(); + + let expected_topics: soroban_sdk::Vec = ( + crate::events::TOPIC_DELEGATION_UPDATED, + owner.clone(), + delegate.clone(), + ) + .into_val(&env); + let decoded_data: (Address, Address, PermissionLevel, u64, u64) = + soroban_sdk::TryFromVal::try_from_val(&env, &data).unwrap(); + + assert_eq!(contract_id, client.address); + assert_eq!(topics, expected_topics); + assert_eq!( + decoded_data, + (owner, delegate, PermissionLevel::Full, new_expiry, now) + ); +} + +/// `revoke_delegation` emits a `delegation_revoked` event with topics +/// `(delegation_revoked, owner, delegate)` and data `(owner, delegate, +/// timestamp)`. +#[test] +fn delegation_revoke_emits_typed_event() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + let now = env.ledger().timestamp(); + client.revoke_delegation(&owner, &delegate); + + let events = env.events().all(); + let (contract_id, topics, data) = events.last().unwrap().clone(); + + let expected_topics: soroban_sdk::Vec = ( + crate::events::TOPIC_DELEGATION_REVOKED, + owner.clone(), + delegate.clone(), + ) + .into_val(&env); + let decoded_data: (Address, Address, u64) = + soroban_sdk::TryFromVal::try_from_val(&env, &data).unwrap(); + + assert_eq!(contract_id, client.address); + assert_eq!(topics, expected_topics); + assert_eq!(decoded_data, (owner, delegate, now)); +} + +/// `delegate_savings` must require the owner's authorization — a call +/// without auth must fail. +#[test] +fn delegate_savings_without_owner_auth_rejected() { + let env = Env::default(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + let result = client.try_delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + assert!( + result.is_err(), + "delegate_savings must fail without owner's authorization" + ); +} + +/// `revoke_delegation` must require the owner's authorization. +#[test] +fn revoke_delegation_without_owner_auth_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _token) = setup_with_token(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + // Disable auth mocking. + env.set_auths(&[]); + + let result = client.try_revoke_delegation(&owner, &delegate); + assert!( + result.is_err(), + "revoke_delegation must fail without owner's authorization" + ); +} + +/// `delegated_deposit` must require the delegate's authorization. +#[test] +fn delegated_deposit_without_delegate_auth_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const AMOUNT: i128 = 50_000_000; + mint(&env, &token, &token_admin, &delegate, AMOUNT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Deposit, &no_expiry); + + env.set_auths(&[]); + + let result = client.try_delegated_deposit(&delegate, &owner, &AMOUNT); + assert!( + result.is_err(), + "delegated_deposit must fail without delegate's authorization" + ); +} + +/// `delegated_withdraw` must require the delegate's authorization. +#[test] +fn delegated_withdraw_without_delegate_auth_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let delegate = Address::generate(&env); + + const DEPOSIT: i128 = 100_000_000; + mint(&env, &token, &token_admin, &owner, DEPOSIT); + client.deposit(&owner, &DEPOSIT); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &delegate, &PermissionLevel::Full, &no_expiry); + + env.set_auths(&[]); + + let result = client.try_delegated_withdraw(&delegate, &owner, &40_000_000); + assert!( + result.is_err(), + "delegated_withdraw must fail without delegate's authorization" + ); +} + +/// An owner can have multiple delegates, each with a different permission +/// level, and they are independent. +#[test] +fn delegation_multiple_delegates_independent() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup_with_token(&env); + let token_admin = Address::generate(&env); + let owner = Address::generate(&env); + let viewer = Address::generate(&env); + let depositor = Address::generate(&env); + let manager = Address::generate(&env); + + let no_expiry: Option = None; + client.delegate_savings(&owner, &viewer, &PermissionLevel::ViewOnly, &no_expiry); + client.delegate_savings(&owner, &depositor, &PermissionLevel::Deposit, &no_expiry); + client.delegate_savings(&owner, &manager, &PermissionLevel::Full, &no_expiry); + + // Viewer cannot deposit. + let result = client.try_delegated_deposit(&viewer, &owner, &1_000_000); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Depositor can deposit but not withdraw. + const AMOUNT: i128 = 30_000_000; + mint(&env, &token, &token_admin, &depositor, AMOUNT); + client.delegated_deposit(&depositor, &owner, &AMOUNT); + let result = client.try_delegated_withdraw(&depositor, &owner, &10_000_000); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Manager can withdraw. + let token_client = soroban_sdk::token::Client::new(&env, &token); + let owner_before = token_client.balance(&owner); + client.delegated_withdraw(&manager, &owner, &10_000_000); + assert_eq!(token_client.balance(&owner) - owner_before, 10_000_000); + + // Revoking the depositor does not affect the manager. + client.revoke_delegation(&owner, &depositor); + assert_eq!( + client.try_get_delegation(&owner, &depositor), + Err(Ok(Error::NotFound)), + ); + let d = client.get_delegation(&owner, &manager); + assert_eq!(d.level, PermissionLevel::Full); +} diff --git a/contracts/savings-vault/src/types.rs b/contracts/savings-vault/src/types.rs index de47b85d..3d16bee8 100644 --- a/contracts/savings-vault/src/types.rs +++ b/contracts/savings-vault/src/types.rs @@ -161,6 +161,46 @@ pub struct InterestAccount { pub updated_at: u64, } +/// Permission level granted to a delegate for savings management. +/// +/// Variants are ordered by capability: `ViewOnly < Deposit < Full`, so a +/// derived `Ord` comparison ("does this level include that level?") matches +/// the intuitive hierarchy. See [`crate::delegation`]. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum PermissionLevel { + /// Read-only access to the owner's savings accounts. + ViewOnly, + /// Can deposit into the owner's flexible account on the owner's behalf. + /// Withdrawals are not permitted. + Deposit, + /// Full management: deposit and withdraw. Withdrawals are always sent to + /// the **owner's** wallet, never the delegate's. + Full, +} + +/// A savings delegation: an owner authorizes a delegate to manage savings on +/// their behalf at a given [`PermissionLevel`], optionally expiring at a +/// ledger timestamp. +/// +/// See [`crate::delegation`] for the full lifecycle (grant, update, revoke, +/// and delegated deposit/withdraw). +#[contracttype] +#[derive(Clone)] +pub struct Delegation { + /// The savings owner who granted this delegation. + pub owner: Address, + /// The authorized delegate address. + pub delegate: Address, + /// What the delegate is allowed to do. + pub level: PermissionLevel, + /// Ledger timestamp after which the delegation is no longer valid. + /// `None` means the delegation never expires on its own. + pub expires_at: Option, + pub created_at: u64, + pub updated_at: u64, +} + /// Storage keys. One variant per logical record family. /// /// See `storage` module docs for the full storage model: durability @@ -301,4 +341,10 @@ pub enum DataKey { GoalLastContributionLedger(u64), /// Set while an external token transfer is in progress. TokenTransferActive, + /// `Delegation` by (owner, delegate) pair. + /// + /// Durability: persistent — one entry per (owner, delegate) pair, an + /// unbounded key space. TTL: bumped on every read/write via + /// [`extend_persistent_ttl`], the same pattern as `InterestAccount`. + Delegation(Address, Address), } diff --git a/contracts/savings-vault/src/voting.rs b/contracts/savings-vault/src/voting.rs index 61440a75..4bde72fb 100644 --- a/contracts/savings-vault/src/voting.rs +++ b/contracts/savings-vault/src/voting.rs @@ -41,7 +41,11 @@ pub fn set_voting_period( creator.require_auth(); let key = DataKey::Group(group_id); - let mut group: Group = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let mut group: Group = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if group.creator != creator { @@ -86,7 +90,11 @@ pub fn propose( } let key = DataKey::Group(group_id); - let group: Group = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let group: Group = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); if !group.members.iter().any(|m| m == proposer) { @@ -104,7 +112,9 @@ pub fn propose( let id = next_proposal_id(env); let now = env.ledger().timestamp(); - let voting_ends_at = now.checked_add(group.voting_period).ok_or(Error::Overflow)?; + let voting_ends_at = now + .checked_add(group.voting_period) + .ok_or(Error::Overflow)?; let proposal = Proposal { id, @@ -143,11 +153,19 @@ pub fn vote(env: &Env, voter: Address, proposal_id: u64, approve: bool) -> Resul voter.require_auth(); let pkey = DataKey::Proposal(proposal_id); - let mut proposal: Proposal = env.storage().persistent().get(&pkey).ok_or(Error::NotFound)?; + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&pkey) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &pkey); let gkey = DataKey::Group(proposal.group_id); - let group: Group = env.storage().persistent().get(&gkey).ok_or(Error::NotFound)?; + let group: Group = env + .storage() + .persistent() + .get(&gkey) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &gkey); if !group.members.iter().any(|m| m == voter) { @@ -204,7 +222,11 @@ pub fn execute(env: &Env, caller: Address, proposal_id: u64) -> Result Result Result Result { let key = DataKey::Proposal(proposal_id); - let proposal = env.storage().persistent().get(&key).ok_or(Error::NotFound)?; + let proposal = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; storage::extend_persistent_ttl(env, &key); Ok(proposal) }