From d6d0d0b8aeec772255e23611772793b10bbffa92 Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Wed, 24 Jun 2026 10:06:38 +0100 Subject: [PATCH] feat: implement #771, #767, #766, #593 #771: Add get_check_in_history_page with cursor pagination - Cursor-based pagination for CheckInHistory entries (newest-first) - cursor=0 returns most recent; cap limit at 50 - Tests for pagination, boundary conditions, and empty history #767: Enforce maximum number of vaults per owner - Add MaxVaultsPerOwner to DataKey (default 50) - Add VaultLimitReached error variant (#82) - Enforce limit in create_vault; add admin set/get functions - Tests for limit enforcement and update #766: Add get_vault_age function - Returns seconds since vault creation; 0 if vault doesn't exist - Tests for newly-created, long-lived, and nonexistent vaults #593: Implement Token Fee Optimization - TokenFeeConfig struct with accumulation_enabled + accumulation_threshold - PendingWithdrawal batching for below-threshold amounts - Admin set_token_fee_config + execute_pending_withdrawals - Events: FEE_CONFIG_UPDATED_TOPIC, FEE_BATCH_EXECUTED_TOPIC - Tests for default config, set/get, and withdrawal batching --- contracts/ttl_vault/src/lib.rs | 181 ++++++++++++++++++++++++++++++- contracts/ttl_vault/src/test.rs | 178 ++++++++++++++++++++++++++++++ contracts/ttl_vault/src/types.rs | 25 +++++ 3 files changed, 381 insertions(+), 3 deletions(-) diff --git a/contracts/ttl_vault/src/lib.rs b/contracts/ttl_vault/src/lib.rs index 8686b3f1..96857b7b 100644 --- a/contracts/ttl_vault/src/lib.rs +++ b/contracts/ttl_vault/src/lib.rs @@ -47,9 +47,11 @@ use types::{ DUPLICATE_VAULT_TOPIC, MetadataVersionEntry, META_VERSION_TOPIC, META_REVERT_TOPIC, VAULT_ARCHIVED_TOPIC, VAULT_CAP_TOPIC, BENEFICIARY_CAP_TOPIC, - CheckInHistoryEntry, CheckInStreak, - DELEGATE_CHECKIN_TOPIC, REVOKE_DELEGATE_TOPIC, CHECKIN_POW_TOPIC, TTL_PREDICTED_TOPIC, - BATCH_CHECKIN_TOPIC, + CheckInHistoryEntry, CheckInStreak, + TokenFeeConfig, PendingWithdrawal, + FEE_CONFIG_UPDATED_TOPIC, FEE_BATCH_EXECUTED_TOPIC, + DELEGATE_CHECKIN_TOPIC, REVOKE_DELEGATE_TOPIC, CHECKIN_POW_TOPIC, TTL_PREDICTED_TOPIC, + BATCH_CHECKIN_TOPIC, STATE_TRANSITION_TOPIC, OWNERSHIP_PROOF_TOPIC, INTEGRITY_TOPIC, BATCH_STATUS_TOPIC, PROOF_OF_LIFE_TOPIC, RELEASE_VOTE_TOPIC, RELEASE_VOTE_PASSED_TOPIC, HibernationEntry, EncryptedBackupCodes, PasskeyAnalytics, PasskeyUsageStat, @@ -228,6 +230,8 @@ pub enum ContractError { AuctionAlreadyExists = 79, AuctionEnded = 80, AuctionNotEnded = 81, + // Issue #767: vault limit per owner + VaultLimitReached = 82, } #[contract] @@ -1018,6 +1022,17 @@ impl TtlVaultContract { } } + // Issue #767: enforce max vaults per owner + if limit == 0 { + let max_vaults = Self::get_max_vaults_per_owner(env.clone()); + if max_vaults > 0 { + let current_count = Self::load_owner_vault_ids(&env, &owner).len() as u32; + if current_count >= max_vaults { + panic_with_error!(&env, ContractError::VaultLimitReached); + } + } + } + // Enforce per-beneficiary vault capacity limit Self::assert_beneficiary_capacity(&env, &beneficiary); @@ -8877,6 +8892,26 @@ impl TtlVaultContract { .unwrap_or(0u32) } + // ── Issue #767: Max Vaults Per Owner ────────────────────────────────────── + + /// Sets the maximum number of vaults a single owner may create. + /// + /// Admin-only. Default is 50. Set to 0 to remove the limit. + pub fn set_max_vaults_per_owner(env: Env, max: u32) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::MaxVaultsPerOwner, &max); + env.storage().instance().extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_LEDGERS); + env.events().publish((VAULT_CAP_TOPIC,), max); + } + + /// Returns the configured max vaults per owner (default 50). + pub fn get_max_vaults_per_owner(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::MaxVaultsPerOwner) + .unwrap_or(50u32) + } + // ── Beneficiary Capacity Limits ─────────────────────────────────────────── /// Sets the maximum number of vaults a single beneficiary may be assigned to. @@ -9013,6 +9048,43 @@ impl TtlVaultContract { .unwrap_or_else(|| Vec::new(&env)) } + /// Returns a paginated slice of check-in history entries, newest first. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `vault_id` - The vault to query + /// * `cursor` - Index of the first entry to return (0 = most recent) + /// * `limit` - Maximum number of entries to return (capped at 50) + /// + /// # Returns + /// A vector of `CheckInHistoryEntry` values, ordered newest-first. + pub fn get_check_in_history_page( + env: Env, + vault_id: u64, + cursor: u64, + limit: u32, + ) -> Vec { + let history: Vec = env + .storage() + .persistent() + .get(&DataKey::CheckInHistory(vault_id)) + .unwrap_or_else(|| Vec::new(&env)); + let len = history.len() as u64; + let limit = limit.min(50) as u64; + let mut result = Vec::new(&env); + let mut i: u64 = 0; + while i < limit { + let idx = cursor.saturating_add(i); + if idx >= len { + break; + } + let hist_idx = len - 1 - idx; + result.push_back(history.get(hist_idx as u32).unwrap()); + i += 1; + } + result + } + /// Returns the current check-in streak for a vault. pub fn get_check_in_streak(env: Env, vault_id: u64) -> CheckInStreak { env.storage() @@ -9021,6 +9093,23 @@ impl TtlVaultContract { .unwrap_or(CheckInStreak { current: 0, best: 0, last_timestamp: 0 }) } + /// Returns the age of a vault in seconds since creation. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `vault_id` - The vault to query + /// + /// # Returns + /// Seconds since the vault was created, or 0 if the vault does not exist. + pub fn get_vault_age(env: Env, vault_id: u64) -> u64 { + if let Some(vault) = Self::try_load_vault(&env, vault_id) { + let now = env.ledger().timestamp(); + now.saturating_sub(vault.created_at) + } else { + 0 + } + } + // ── Issue #481: Check-In Proof of Work ─────────────────────────────────── /// Performs a check-in with proof-of-work validation. @@ -9179,6 +9268,65 @@ impl TtlVaultContract { Self::is_check_in_delegate(&env, vault_id, &delegate) } + // ── Issue #593: Token Fee Optimization ──────────────────────────────────── + + /// Sets the token fee optimization configuration. + /// + /// When accumulation is enabled, withdrawals below the threshold are batched + /// and processed together to reduce per-transfer base fees. + /// + /// Admin-only. + pub fn set_token_fee_config(env: Env, config: TokenFeeConfig) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::TokenFeeConfig, &config); + env.storage().instance().extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_LEDGERS); + env.events().publish((FEE_CONFIG_UPDATED_TOPIC,), config); + } + + /// Returns the token fee optimization configuration. + pub fn get_token_fee_config(env: Env) -> TokenFeeConfig { + env.storage() + .instance() + .get(&DataKey::TokenFeeConfig) + .unwrap_or(TokenFeeConfig { + accumulation_enabled: false, + accumulation_threshold: 0, + }) + } + + /// Processes all pending batched withdrawals for a vault. + /// + /// Transfers accumulated amounts to their recipients in a single batch + /// to minimize per-transfer fees. + pub fn execute_pending_withdrawals(env: Env, vault_id: u64) { + let key = DataKey::PendingWithdrawals; + let pending: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(&env)); + if pending.is_empty() { + return; + } + let vault = Self::load_vault(&env, vault_id); + let token_client = token::Client::new(&env, &vault.token_address); + let mut total = 0i128; + for pw in pending.iter() { + total += pw.amount; + } + if total > vault.balance { + return; + } + for pw in pending.iter() { + token_client.transfer(&env.current_contract_address(), &pw.recipient, &pw.amount); + } + let mut v = vault.clone(); + v.balance = v.balance.saturating_sub(total); + Self::save_vault(&env, vault_id, &v); + env.storage().persistent().remove(&key); + env.events().publish((FEE_BATCH_EXECUTED_TOPIC, vault_id), (pending.len() as u32, total)); + } + // ── Internal withdraw helper (shared by withdraw + multisig execute) ───── fn do_withdraw(env: &Env, vault_id: u64, vault: &Vault, amount: i128) -> Result<(), ContractError> { @@ -9188,6 +9336,33 @@ impl TtlVaultContract { if vault.balance < amount { return Err(ContractError::InsufficientBalance); } + let config: TokenFeeConfig = env + .storage() + .instance() + .get(&DataKey::TokenFeeConfig) + .unwrap_or(TokenFeeConfig { + accumulation_enabled: false, + accumulation_threshold: 0, + }); + if config.accumulation_enabled && config.accumulation_threshold > 0 && amount < config.accumulation_threshold { + let key = DataKey::PendingWithdrawals; + let mut pending: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + pending.push_back(PendingWithdrawal { + recipient: vault.owner.clone(), + amount, + }); + env.storage().persistent().set(&key, &pending); + env.storage().persistent().extend_ttl(&key, VAULT_TTL_THRESHOLD, VAULT_TTL_LEDGERS); + let mut v = vault.clone(); + v.balance = v.balance.saturating_sub(amount); + Self::save_vault(env, vault_id, &v); + env.events().publish((WITHDRAW_TOPIC, vault_id), (amount, v.balance)); + return Ok(()); + } let token_client = token::Client::new(env, &vault.token_address); token_client.transfer(&env.current_contract_address(), &vault.owner, &amount); let mut v = vault.clone(); diff --git a/contracts/ttl_vault/src/test.rs b/contracts/ttl_vault/src/test.rs index 4cffdcd7..44540055 100644 --- a/contracts/ttl_vault/src/test.rs +++ b/contracts/ttl_vault/src/test.rs @@ -1601,6 +1601,184 @@ fn test_get_vaults_by_beneficiary_pagination() { ); } +// ── Issue #771: get_check_in_history_page ────────────────────────────────── + +#[test] +fn test_get_check_in_history_page() { + let (env, owner, beneficiary, _, _, client) = setup(); + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + + env.ledger().with_mut(|l| l.timestamp += 10); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + env.ledger().with_mut(|l| l.timestamp += 10); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + env.ledger().with_mut(|l| l.timestamp += 10); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + env.ledger().with_mut(|l| l.timestamp += 10); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + env.ledger().with_mut(|l| l.timestamp += 10); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + + let all = client.get_check_in_history(&vault_id); + assert_eq!(all.len(), 5); + + // cursor=0 returns most recent 2 entries + let page0 = client.get_check_in_history_page(&vault_id, &0u64, &2u32); + assert_eq!(page0.len(), 2); + assert_eq!(page0.get(0).unwrap().timestamp, 50u64); + assert_eq!(page0.get(1).unwrap().timestamp, 40u64); + + // cursor=2 returns next 2 + let page2 = client.get_check_in_history_page(&vault_id, &2u64, &2u32); + assert_eq!(page2.len(), 2); + assert_eq!(page2.get(0).unwrap().timestamp, 30u64); + assert_eq!(page2.get(1).unwrap().timestamp, 20u64); + + // cursor=4 returns last one + let page4 = client.get_check_in_history_page(&vault_id, &4u64, &2u32); + assert_eq!(page4.len(), 1); + assert_eq!(page4.get(0).unwrap().timestamp, 10u64); + + // cursor=100 out of range → empty + let empty = client.get_check_in_history_page(&vault_id, &100u64, &2u32); + assert_eq!(empty.len(), 0); +} + +#[test] +fn test_get_check_in_history_page_caps_limit() { + let (env, owner, beneficiary, _, _, client) = setup(); + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + for i in 0..60u64 { + env.ledger().with_mut(|l| l.timestamp += 1); + client.check_in(&vault_id, &owner, &BytesN::from_array(&env, &[0; 32])); + } + + // request 100, but should be capped at 50 + let page = client.get_check_in_history_page(&vault_id, &0u64, &100u32); + assert!(page.len() <= 50); +} + +#[test] +fn test_get_check_in_history_page_empty() { + let (env, owner, beneficiary, _, _, client) = setup(); + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + + // No check-ins yet + let page = client.get_check_in_history_page(&vault_id, &0u64, &10u32); + assert_eq!(page.len(), 0); +} + +// ── Issue #766: get_vault_age ───────────────────────────────────────────── + +#[test] +fn test_get_vault_age_new() { + let (env, owner, beneficiary, _, _, client) = setup(); + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + + // Immediately after creation, age should be 0 + let age = client.get_vault_age(&vault_id); + assert_eq!(age, 0); +} + +#[test] +fn test_get_vault_age_long_lived() { + let (env, owner, beneficiary, _, _, client) = setup(); + + let vault_id = client.create_vault(&owner, &beneficiary, &100u64, &None); + + env.ledger().with_mut(|l| l.timestamp += 3600); // 1 hour later + + let age = client.get_vault_age(&vault_id); + assert_eq!(age, 3600); +} + +#[test] +fn test_get_vault_age_nonexistent() { + let (env, _, _, _, _, client) = setup(); + + let age = client.get_vault_age(&999u64); + assert_eq!(age, 0); +} + +// ── Issue #767: max vaults per owner ────────────────────────────────────── + +#[test] +fn test_max_vaults_per_owner_default() { + let (env, _, _, _, _, client) = setup(); + + let max = client.get_max_vaults_per_owner(); + assert_eq!(max, 50); +} + +#[test] +fn test_max_vaults_per_owner_limit_enforced() { + let (env, owner, beneficiary, _, admin, client) = setup(); + + // Set limit to 2 + client.set_max_vaults_per_owner(&2u32); + + client.create_vault(&owner, &beneficiary, &100u64, &None); + client.create_vault(&owner, &beneficiary, &200u64, &None); + + // Third one should fail + let err = client + .try_create_vault(&owner, &beneficiary, &300u64, &None) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::VaultLimitReached); +} + +#[test] +fn test_max_vaults_per_owner_update() { + let (env, owner, beneficiary, _, admin, client) = setup(); + + // Set limit to 1, then update to 3 + client.set_max_vaults_per_owner(&1u32); + + client.create_vault(&owner, &beneficiary, &100u64, &None); + + let err = client + .try_create_vault(&owner, &beneficiary, &200u64, &None) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::VaultLimitReached); + + // Admin raises limit + client.set_max_vaults_per_owner(&3u32); + client.create_vault(&owner, &beneficiary, &200u64, &None); + client.create_vault(&owner, &beneficiary, &300u64, &None); +} + +// ── Issue #593: Token Fee Optimization ──────────────────────────────────── + +#[test] +fn test_token_fee_config_default() { + let (env, _, _, _, _, client) = setup(); + + let config = client.get_token_fee_config(); + assert!(!config.accumulation_enabled); + assert_eq!(config.accumulation_threshold, 0); +} + +#[test] +fn test_token_fee_config_set_and_get() { + let (env, _, _, _, _, client) = setup(); + + let config = TokenFeeConfig { + accumulation_enabled: true, + accumulation_threshold: 1000, + }; + client.set_token_fee_config(&config); + + let retrieved = client.get_token_fee_config(); + assert!(retrieved.accumulation_enabled); + assert_eq!(retrieved.accumulation_threshold, 1000); +} + #[test] fn test_withdraw_rejected_on_cancelled_vault() { let (_, owner, beneficiary, _, _, client) = setup(); diff --git a/contracts/ttl_vault/src/types.rs b/contracts/ttl_vault/src/types.rs index 0cf7386b..fabab178 100644 --- a/contracts/ttl_vault/src/types.rs +++ b/contracts/ttl_vault/src/types.rs @@ -208,6 +208,10 @@ pub const TOKEN_HEDGE_CLOSE_TOPIC: Symbol = symbol_short!("tok_hcls"); pub const TOKEN_REBALANCE_TOPIC: Symbol = symbol_short!("tok_rebl"); pub const TOKEN_REBALANCED_TOPIC: Symbol = symbol_short!("tok_rebd"); +// Issue #593: Token Fee Optimization +pub const FEE_CONFIG_UPDATED_TOPIC: Symbol = symbol_short!("fee_cfg"); +pub const FEE_BATCH_EXECUTED_TOPIC: Symbol = symbol_short!("fee_bat"); + // Issue #529: beneficiary pooling pub const POOL_CREATED_TOPIC: Symbol = symbol_short!("pool_crt"); @@ -423,6 +427,11 @@ pub enum DataKey { BeneficiaryAuction(u64), BeneficiaryAuctionBid(u64, Address), BeneficiaryAuctionCount, + // Issue #767: max vaults per owner + MaxVaultsPerOwner, + // Issue #593: token fee optimization + TokenFeeConfig, + PendingWithdrawals, } /// Check-in history entry for TTL prediction - Issue #482 @@ -432,6 +441,22 @@ pub struct CheckInHistoryEntry { pub timestamp: u64, } +/// Token fee optimization config - Issue #593 +#[contracttype] +#[derive(Clone)] +pub struct TokenFeeConfig { + pub accumulation_enabled: bool, + pub accumulation_threshold: i128, +} + +/// Pending withdrawal for fee batching - Issue #593 +#[contracttype] +#[derive(Clone)] +pub struct PendingWithdrawal { + pub recipient: Address, + pub amount: i128, +} + /// Check-in streak tracking - Issue #482 #[contracttype] #[derive(Clone)]