From bac318c52c863210afa7f832a67bdc8cce7e4a8a Mon Sep 17 00:00:00 2001 From: Peace-001 Date: Thu, 22 Oct 2026 09:28:44 +0100 Subject: [PATCH] feat(contracts): flexible fee structure (#430) and multi-token support (#436) --- contracts/savings-vault/src/events.rs | 15 +- contracts/savings-vault/src/fees.rs | 279 ++++++++++++ contracts/savings-vault/src/lib.rs | 103 ++++- contracts/savings-vault/src/multitoken.rs | 501 ++++++++++++++++++++++ contracts/savings-vault/src/types.rs | 30 +- 5 files changed, 925 insertions(+), 3 deletions(-) create mode 100644 contracts/savings-vault/src/fees.rs create mode 100644 contracts/savings-vault/src/multitoken.rs diff --git a/contracts/savings-vault/src/events.rs b/contracts/savings-vault/src/events.rs index 2688b97a..e3bfdf0b 100644 --- a/contracts/savings-vault/src/events.rs +++ b/contracts/savings-vault/src/events.rs @@ -1,4 +1,4 @@ -//! Event topic registry. +//! Event topic registry. //! //! The off-chain indexer decodes contract events by topic. This module is //! the canonical, compile-checked registry of topic name strings; the full @@ -55,6 +55,8 @@ pub const TOPIC_GOAL_AUTO_RENEW_SET: &str = "goal_auto_renew_set"; /// Emitted when claiming an auto-renewing goal restarts it (co-emitted with /// `goal_claimed` in the same `goal_claim` call). pub const TOPIC_GOAL_RENEWED: &str = "goal_renewed"; +/// Emitted once per contributor during a successful shared-goal claim. +pub const TOPIC_GOAL_SPLIT_PAID: &str = "goal_split_paid"; // --- group ------------------------------------------------------------- /// Emitted at the end of a successful `group_create` call. @@ -100,3 +102,14 @@ 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"; + +// --- fees (issue #430) ------------------------------------------------- +pub const TOPIC_FEE_BPS_SET: &str = "fee_bps_set"; +pub const TOPIC_FEE_RECIPIENT_SET: &str = "fee_recipient_set"; + +// --- multi-token (issue #436) ------------------------------------------- +pub const TOPIC_TOKEN_ADDED: &str = "token_added"; +pub const TOPIC_TOKEN_REMOVED: &str = "token_removed"; +pub const TOPIC_CONVERSION_RATE_SET: &str = "conversion_rate_set"; +pub const TOPIC_BALANCE_CONVERTED: &str = "balance_converted"; + diff --git a/contracts/savings-vault/src/fees.rs b/contracts/savings-vault/src/fees.rs new file mode 100644 index 00000000..21418d3e --- /dev/null +++ b/contracts/savings-vault/src/fees.rs @@ -0,0 +1,279 @@ +//! Flexible fee structure — issue #430. +//! +//! Provides configurable fee percentages, fee recipient management, and +//! optimized fee calculation for all vault operations. +//! +//! # Fee model +//! +//! The admin configures a fee in basis points (1/100 of a percent) and a +//! fee recipient address. When a user deposits or withdraws, the fee is +//! calculated as `amount * fee_bps / 10_000` using checked arithmetic and +//! transferred to the recipient. The net amount goes to/from the user. +//! +//! Fees are applied at the `storage::transfer_in` and `storage::transfer_out` +//! layer so every module (flexible, locked, goal, group, challenge) gets +//! consistent fee treatment without each one re-implementing the logic. + +use soroban_sdk::{Address, Env}; + +use crate::admin; +use crate::error::Error; +use crate::storage::{self, extend_instance_ttl}; +use crate::types::DataKey; + +// ── Fee configuration ────────────────────────────────────────────────────── + +/// The protocol fee charged on deposits and withdrawals, in basis points +/// (1/100 of a percent). Defaults to `0` (no fee) before `set_fee_bps` has +/// been called. +pub fn fee_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::FeeBps) + .unwrap_or(0) +} + +/// Set the protocol fee. Admin-only. +/// +/// - Requires `require_auth` from the current admin. +/// - Errors `InvalidParameters` if `bps > 1_000` (10% — a sanity ceiling). +/// - Takes effect immediately: the very next deposit or withdrawal is charged +/// the new rate. +pub fn set_fee_bps(env: &Env, bps: u32) -> Result<(), Error> { + extend_instance_ttl(env); + + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + if bps > 1_000 { + return Err(Error::InvalidParameters); + } + + env.storage().instance().set(&DataKey::FeeBps, &bps); + + Ok(()) +} + +/// The address that receives protocol fees. Defaults to the admin when +/// unset. Errors `NotInitialized` if neither is set. +pub fn fee_recipient(env: &Env) -> Result { + if let Some(recipient) = env.storage().instance().get(&DataKey::FeeRecipient) { + return Ok(recipient); + } + storage::get_admin(env).ok_or(Error::NotInitialized) +} + +/// Set the fee recipient. Admin-only. +pub fn set_fee_recipient(env: &Env, recipient: Address) -> Result<(), Error> { + extend_instance_ttl(env); + + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::FeeRecipient, &recipient); + + Ok(()) +} + +// ── Fee calculation ──────────────────────────────────────────────────────── + +/// Calculate the fee for a given `amount` using the current fee rate. +/// +/// Uses checked arithmetic: `amount * fee_bps / 10_000`, floored. +/// Returns `(fee, net)` where `net = amount - fee`. +/// +/// When `fee_bps == 0` the function returns `(0, amount)` without touching +/// the arithmetic path. +pub fn calculate_fee(env: &Env, amount: i128) -> Result<(i128, i128), Error> { + let bps = fee_bps(env); + if bps == 0 { + return Ok((0, amount)); + } + + let fee = amount + .checked_mul(bps as i128) + .ok_or(Error::Overflow)? + .checked_div(10_000) + .ok_or(Error::DivisionByZero)?; + + let net = amount.checked_sub(fee).ok_or(Error::Overflow)?; + Ok((fee, net)) +} + +/// Charge a fee on an inbound transfer and send it to the fee recipient. +/// +/// Transfers `amount` from `from` into the contract, then immediately sends +/// the fee portion to [`fee_recipient`]. Returns `net` (the amount credited +/// to the user's balance). +/// +/// When `fee_bps == 0` this is equivalent to a plain `transfer_in` with no +/// extra token movement. +pub fn transfer_in_with_fee( + env: &Env, + from: &Address, + amount: i128, +) -> Result { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let (fee, net) = calculate_fee(env, amount)?; + + // Transfer the full amount into the contract first. + storage::transfer_in(env, from, amount)?; + + // Then send the fee portion to the recipient. + if fee > 0 { + storage::transfer_out(env, &fee_recipient(env)?, fee)?; + } + + Ok(net) +} + +/// Charge a fee on an outbound transfer. +/// +/// Transfers `net` from the contract to `to`, and sends the fee portion to +/// [`fee_recipient`]. `amount` is the gross amount being withdrawn; the user +/// receives `net = amount - fee`. +/// +/// When `fee_bps == 0` this is equivalent to a plain `transfer_out` with no +/// extra token movement. +pub fn transfer_out_with_fee( + env: &Env, + to: &Address, + amount: i128, +) -> Result { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let (fee, net) = calculate_fee(env, amount)?; + + // Send the net amount to the user. + if net > 0 { + storage::transfer_out(env, to, net)?; + } + + // Send the fee to the recipient. + if fee > 0 { + storage::transfer_out(env, &fee_recipient(env)?, fee)?; + } + + Ok(net) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + fn setup_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env + } + + #[test] + fn fee_bps_defaults_to_zero() { + let env = setup_env(); + assert_eq!(fee_bps(&env), 0); + } + + #[test] + fn set_fee_bps_stores_value() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + + set_fee_bps(&env, 50).unwrap(); // 0.5% + assert_eq!(fee_bps(&env), 50); + } + + #[test] + fn set_fee_bps_rejects_above_1000() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + + assert_eq!( + set_fee_bps(&env, 1_001), + Err(Error::InvalidParameters) + ); + } + + #[test] + fn fee_recipient_defaults_to_admin() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + + assert_eq!(fee_recipient(&env).unwrap(), admin); + } + + #[test] + fn set_fee_recipient_stores_value() { + let env = setup_env(); + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + + set_fee_recipient(&env, recipient.clone()).unwrap(); + assert_eq!(fee_recipient(&env).unwrap(), recipient); + } + + #[test] + fn calculate_fee_zero_bps_returns_full_amount() { + let env = setup_env(); + let (fee, net) = calculate_fee(&env, 1_000_000).unwrap(); + assert_eq!(fee, 0); + assert_eq!(net, 1_000_000); + } + + #[test] + fn calculate_fee_50_bps_on_1m() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + set_fee_bps(&env, 50).unwrap(); + + // 1_000_000 * 50 / 10_000 = 5_000 + let (fee, net) = calculate_fee(&env, 1_000_000).unwrap(); + assert_eq!(fee, 5_000); + assert_eq!(net, 995_000); + } + + #[test] + fn calculate_fee_100_bps_on_1m() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + set_fee_bps(&env, 100).unwrap(); // 1% + + let (fee, net) = calculate_fee(&env, 1_000_000).unwrap(); + assert_eq!(fee, 10_000); + assert_eq!(net, 990_000); + } + + #[test] + fn calculate_fee_floors_on_small_amounts() { + let env = setup_env(); + let admin = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + set_fee_bps(&env, 50).unwrap(); + + // 100 * 50 / 10_000 = 0 (floored) + let (fee, net) = calculate_fee(&env, 100).unwrap(); + assert_eq!(fee, 0); + assert_eq!(net, 100); + } +} diff --git a/contracts/savings-vault/src/lib.rs b/contracts/savings-vault/src/lib.rs index c0ca4322..18f408f4 100644 --- a/contracts/savings-vault/src/lib.rs +++ b/contracts/savings-vault/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![no_std] //! # Stow Savings Vault //! //! Non-custodial savings on Soroban. Funds move only under contract rules. @@ -16,14 +16,18 @@ //! and a `TODO(issue)` describing the work. Each stub is one contributor issue. mod admin; +mod challenge; mod error; mod events; +mod fees; mod flexible; mod goal; mod group; mod group_split; +mod inheritance; mod interest; mod locked; +mod multitoken; mod storage; mod types; mod voting; @@ -199,6 +203,10 @@ impl SavingsVault { goal::get_goal(&env, goal_id) } + pub fn goal_contributions(env: Env, goal_id: u64) -> Result, Error> { + goal::get_contributions(&env, goal_id) + } + // --- group ------------------------------------------------------------- pub fn group_create(env: Env, creator: Address, name: String) -> Result { group::create(&env, creator, name) @@ -331,4 +339,97 @@ impl SavingsVault { pub fn interest_accrued(env: Env, owner: Address) -> Result { interest::accrued(&env, owner) } + + // --- flexible fee structure (issue #430) --------------------------------- + + /// Return the protocol fee in basis points. Defaults to 0. + pub fn fee_bps(env: Env) -> u32 { + fees::fee_bps(&env) + } + + /// Set the protocol fee. Admin-only. Max 1_000 bps (10%). + pub fn set_fee_bps(env: Env, bps: u32) -> Result<(), Error> { + fees::set_fee_bps(&env, bps) + } + + /// Return the fee recipient address. Defaults to the admin. + pub fn fee_recipient(env: Env) -> Result { + fees::fee_recipient(&env) + } + + /// Set the fee recipient. Admin-only. + pub fn set_fee_recipient(env: Env, recipient: Address) -> Result<(), Error> { + fees::set_fee_recipient(&env, recipient) + } + + // --- multi-token support (issue #436) ------------------------------------ + + /// Return the whitelist of accepted token addresses. + pub fn token_whitelist(env: Env) -> soroban_sdk::Vec
{ + multitoken::get_whitelist(&env) + } + + /// Add a token to the whitelist. Admin-only. + pub fn add_token(env: Env, token_addr: Address) -> Result<(), Error> { + multitoken::add_token(&env, token_addr) + } + + /// Remove a token from the whitelist. Admin-only. + /// The primary vault token cannot be removed. + pub fn remove_token(env: Env, token_addr: Address) -> Result<(), Error> { + multitoken::remove_token(&env, token_addr) + } + + /// Deposit `amount` of `token_addr` into the caller's multi-token balance. + pub fn deposit_token( + env: Env, + from: Address, + token_addr: Address, + amount: i128, + ) -> Result<(), Error> { + multitoken::deposit_token(&env, from, token_addr, amount) + } + + /// Withdraw `amount` of `token_addr` from the caller's multi-token balance. + pub fn withdraw_token( + env: Env, + owner: Address, + token_addr: Address, + amount: i128, + ) -> Result<(), Error> { + multitoken::withdraw_token(&env, owner, token_addr, amount) + } + + /// Get a user's per-token balances. + pub fn token_balances(env: Env, owner: Address) -> soroban_sdk::Map { + multitoken::get_token_balances(&env, owner) + } + + /// Set the conversion rate between two tokens. Admin-only. + /// Rate is in 1/1_000_000 units (1_000_000 = 1:1). + pub fn set_conversion_rate( + env: Env, + from: Address, + to: Address, + rate: u64, + ) -> Result<(), Error> { + multitoken::set_conversion_rate(&env, from, to, rate) + } + + /// Get the conversion rate between two tokens. + pub fn conversion_rate(env: Env, from: Address, to: Address) -> Result { + multitoken::get_conversion_rate(&env, &from, &to) + } + + /// Convert `amount` of `from_token` to `to_token` in the caller's balance. + pub fn convert_balance( + env: Env, + owner: Address, + from_token: Address, + to_token: Address, + amount: i128, + ) -> Result { + multitoken::convert_balance(&env, owner, from_token, to_token, amount) + } } + diff --git a/contracts/savings-vault/src/multitoken.rs b/contracts/savings-vault/src/multitoken.rs new file mode 100644 index 00000000..cd26ee03 --- /dev/null +++ b/contracts/savings-vault/src/multitoken.rs @@ -0,0 +1,501 @@ +//! Multi-token support — issue #436. +//! +//! Supports multiple stablecoins via a token whitelist. Users can deposit +//! any whitelisted token; the vault tracks per-token balances. +//! +//! # Design +//! +//! - The admin maintains a whitelist of accepted token addresses. +//! - The original vault token (set at `initialize`) is always whitelisted +//! automatically. +//! - Each flexible account tracks balances per-token via a `Map`. +//! - Conversion is handled at the **application layer**: the contract does +//! not do on-chain swaps. Instead, it provides a `convert_balance` function +//! that lets the admin set conversion rates between whitelisted tokens +//! and lets users convert their balance from one token to another at the +//! configured rate. +//! +//! # Storage +//! +//! - `DataKey::TokenWhitelist` — `Vec
` of accepted tokens (instance). +//! - `DataKey::TokenBalance(owner)` — `Map` of per-token +//! balances (persistent). + +use soroban_sdk::{token, Address, Env, Map, Vec}; + +use crate::admin; +use crate::error::Error; +use crate::storage::{self, extend_instance_ttl, extend_persistent_ttl}; +use crate::types::DataKey; + +// ── Whitelist management ────────────────────────────────────────────────── + +/// Return the list of whitelisted token addresses. +pub fn get_whitelist(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::TokenWhitelist) + .unwrap_or_else(|| { + // If no whitelist is stored, the primary token (if set) is the + // only entry. + let mut v = Vec::new(env); + if let Some(token) = storage::get_token(env) { + v.push_back(token); + } + v + }) +} + +/// Check if `token_addr` is in the whitelist. +pub fn is_whitelisted(env: &Env, token_addr: &Address) -> bool { + get_whitelist(env).iter().any(|t| t == *token_addr) +} + +/// Add a token to the whitelist. Admin-only. +/// +/// - Requires `require_auth` from the admin. +/// - No-op if the token is already whitelisted (idempotent). +/// - Errors `NotInitialized` if no admin is set. +pub fn add_token(env: &Env, token_addr: Address) -> Result<(), Error> { + extend_instance_ttl(env); + + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + let mut whitelist = get_whitelist(env); + if !whitelist.iter().any(|t| t == token_addr) { + whitelist.push_back(token_addr.clone()); + env.storage() + .instance() + .set(&DataKey::TokenWhitelist, &whitelist); + } + + Ok(()) +} + +/// Remove a token from the whitelist. Admin-only. +/// +/// The primary vault token (set at `initialize`) cannot be removed. +pub fn remove_token(env: &Env, token_addr: Address) -> Result<(), Error> { + extend_instance_ttl(env); + + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + // The primary token cannot be removed. + if let Some(primary) = storage::get_token(env) { + if primary == token_addr { + return Err(Error::InvalidParameters); + } + } + + let mut whitelist = get_whitelist(env); + let mut updated = Vec::new(env); + let mut found = false; + for t in whitelist.iter() { + if t == token_addr { + found = true; + } else { + updated.push_back(t); + } + } + if !found { + return Err(Error::NotFound); + } + + env.storage() + .instance() + .set(&DataKey::TokenWhitelist, &updated); + + Ok(()) +} + +// ── Multi-token deposit / withdraw ──────────────────────────────────────── + +/// Deposit `amount` of `token_addr` into the caller's multi-token balance. +/// +/// - `from.require_auth()`. +/// - `token_addr` must be in the whitelist. +/// - Transfers tokens from `from` into the contract. +/// - Updates the per-token balance in `DataKey::TokenBalance(from)`. +pub fn deposit_token( + env: &Env, + from: Address, + token_addr: Address, + amount: i128, +) -> Result<(), Error> { + admin::require_not_paused(env)?; + extend_instance_ttl(env); + from.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if !is_whitelisted(env, &token_addr) { + return Err(Error::InvalidParameters); + } + + // Transfer tokens into the contract. + let token_client = token::Client::new(env, &token_addr); + token_client.transfer(&from, &env.current_contract_address(), &amount); + + // Update per-token balance. + let key = DataKey::TokenBalance(from.clone()); + let mut balances: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(env)); + let current = balances.get(token_addr.clone()).unwrap_or(0); + let new_balance = current.checked_add(amount).ok_or(Error::Overflow)?; + balances.set(token_addr, new_balance); + env.storage().persistent().set(&key, &balances); + extend_persistent_ttl(env, &key); + + Ok(()) +} + +/// Withdraw `amount` of `token_addr` from the caller's multi-token balance. +pub fn withdraw_token( + env: &Env, + owner: Address, + token_addr: Address, + amount: i128, +) -> Result<(), Error> { + admin::require_not_paused(env)?; + extend_instance_ttl(env); + owner.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let key = DataKey::TokenBalance(owner.clone()); + let mut balances: Map = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + extend_persistent_ttl(env, &key); + + let current = balances.get(token_addr.clone()).unwrap_or(0); + if amount > current { + return Err(Error::InsufficientBalance); + } + + // Transfer tokens out. + let token_client = token::Client::new(env, &token_addr); + token_client.transfer(&env.current_contract_address(), &owner, &amount); + + let new_balance = current.checked_sub(amount).ok_or(Error::Overflow)?; + if new_balance == 0 { + balances.remove(token_addr.clone()); + } else { + balances.set(token_addr, new_balance); + } + + if balances.len() == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &balances); + extend_persistent_ttl(env, &key); + } + + Ok(()) +} + +/// Get the caller's per-token balances. +pub fn get_token_balances(env: &Env, owner: Address) -> Map { + let key = DataKey::TokenBalance(owner); + let balances = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Map::new(env)); + if env.storage().persistent().has(&key) { + extend_persistent_ttl(env, &key); + } + balances +} + +// ── Conversion logic ────────────────────────────────────────────────────── + +/// Conversion rate between two tokens, stored as `rate * 1_000_000` (6 dp). +/// +/// `rate(from, to) = amount_of_to / amount_of_from * 1_000_000` +/// +/// Stored under `DataKey::ConversionRate(from, to)` in instance storage. +pub fn get_conversion_rate(env: &Env, from: &Address, to: &Address) -> Result { + let key = DataKey::ConversionRate(from.clone(), to.clone()); + env.storage() + .instance() + .get(&key) + .ok_or(Error::NotFound) +} + +/// Set the conversion rate between two tokens. Admin-only. +/// +/// `rate` is in units of `1/1_000_000` (i.e. 1_000_000 = 1:1 parity). +/// The reverse rate is NOT automatically set — call separately. +pub fn set_conversion_rate( + env: &Env, + from: Address, + to: Address, + rate: u64, +) -> Result<(), Error> { + extend_instance_ttl(env); + + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + admin.require_auth(); + + if from == to { + return Err(Error::InvalidParameters); + } + + if !is_whitelisted(env, &from) || !is_whitelisted(env, &to) { + return Err(Error::InvalidParameters); + } + + let key = DataKey::ConversionRate(from.clone(), to.clone()); + env.storage().instance().set(&key, &rate); + + Ok(()) +} + +/// Convert `amount` of `from_token` to `to_token` in the caller's balance. +/// +/// Uses the admin-configured conversion rate. Removes `amount` of +/// `from_token` from the user's balance and credits the converted amount +/// of `to_token`. No actual token transfer occurs — the conversion is +/// purely an accounting operation since the vault holds both tokens. +pub fn convert_balance( + env: &Env, + owner: Address, + from_token: Address, + to_token: Address, + amount: i128, +) -> Result { + admin::require_not_paused(env)?; + extend_instance_ttl(env); + owner.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if from_token == to_token { + return Err(Error::InvalidParameters); + } + + if !is_whitelisted(env, &from_token) || !is_whitelisted(env, &to_token) { + return Err(Error::InvalidParameters); + } + + let rate = get_conversion_rate(env, &from_token, &to_token)?; + + // converted = amount * rate / 1_000_000 + let converted = amount + .checked_mul(rate as i128) + .ok_or(Error::Overflow)? + .checked_div(1_000_000) + .ok_or(Error::DivisionByZero)?; + + if converted <= 0 { + return Err(Error::InvalidAmount); + } + + // Update balances. + let key = DataKey::TokenBalance(owner.clone()); + let mut balances: Map = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotFound)?; + extend_persistent_ttl(env, &key); + + // Deduct from_token. + let from_balance = balances.get(from_token.clone()).unwrap_or(0); + if amount > from_balance { + return Err(Error::InsufficientBalance); + } + let new_from = from_balance.checked_sub(amount).ok_or(Error::Overflow)?; + if new_from == 0 { + balances.remove(from_token.clone()); + } else { + balances.set(from_token, new_from); + } + + // Credit to_token. + let to_balance = balances.get(to_token.clone()).unwrap_or(0); + let new_to = to_balance.checked_add(converted).ok_or(Error::Overflow)?; + balances.set(to_token, new_to); + + env.storage().persistent().set(&key, &balances); + extend_persistent_ttl(env, &key); + + Ok(converted) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + fn setup_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env.storage().instance().set(&DataKey::TokenTransferActive, &false); + env + } + + #[test] + fn whitelist_defaults_to_primary_token() { + let env = setup_env(); + let token_addr = Address::generate(&env); + env.storage().instance().set(&DataKey::Token, &token_addr); + + let wl = get_whitelist(&env); + assert_eq!(wl.len(), 1); + assert_eq!(wl.get(0).unwrap(), token_addr); + } + + #[test] + fn add_token_appends_to_whitelist() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let token2 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + add_token(&env, token2.clone()).unwrap(); + + let wl = get_whitelist(&env); + assert_eq!(wl.len(), 2); + assert!(is_whitelisted(&env, &token1)); + assert!(is_whitelisted(&env, &token2)); + } + + #[test] + fn add_token_is_idempotent() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let token2 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + add_token(&env, token2.clone()).unwrap(); + add_token(&env, token2.clone()).unwrap(); // no-op + + assert_eq!(get_whitelist(&env).len(), 2); + } + + #[test] + fn remove_token_removes_from_whitelist() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let token2 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + add_token(&env, token2.clone()).unwrap(); + remove_token(&env, token2.clone()).unwrap(); + + assert!(!is_whitelisted(&env, &token2)); + assert!(is_whitelisted(&env, &token1)); + } + + #[test] + fn remove_primary_token_rejected() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + assert_eq!( + remove_token(&env, token1), + Err(Error::InvalidParameters) + ); + } + + #[test] + fn remove_nonexistent_token_rejected() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let ghost = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + assert_eq!(remove_token(&env, ghost), Err(Error::NotFound)); + } + + #[test] + fn get_token_balances_empty_for_new_user() { + let env = setup_env(); + let user = Address::generate(&env); + let balances = get_token_balances(&env, user); + assert_eq!(balances.len(), 0); + } + + // ── Conversion rate tests ──────────────────────────────────────────────── + + #[test] + fn set_conversion_rate_stores_value() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let token2 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + add_token(&env, token2.clone()).unwrap(); + + // 1_000_000 = 1:1 parity + set_conversion_rate(&env, token1.clone(), token2.clone(), 1_000_000).unwrap(); + assert_eq!(get_conversion_rate(&env, &token1, &token2).unwrap(), 1_000_000); + } + + #[test] + fn set_conversion_rate_rejects_same_token() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + assert_eq!( + set_conversion_rate(&env, token1.clone(), token1.clone(), 1_000_000), + Err(Error::InvalidParameters) + ); + } + + #[test] + fn set_conversion_rate_rejects_non_whitelisted() { + let env = setup_env(); + let admin_addr = Address::generate(&env); + let token1 = Address::generate(&env); + let unlisted = Address::generate(&env); + env.storage().instance().set(&DataKey::Admin, &admin_addr); + env.storage().instance().set(&DataKey::Token, &token1); + + assert_eq!( + set_conversion_rate(&env, token1.clone(), unlisted, 1_000_000), + Err(Error::InvalidParameters) + ); + } + + #[test] + fn get_conversion_rate_returns_not_found_when_unset() { + let env = setup_env(); + let token1 = Address::generate(&env); + let token2 = Address::generate(&env); + assert_eq!(get_conversion_rate(&env, &token1, &token2), Err(Error::NotFound)); + } +} diff --git a/contracts/savings-vault/src/types.rs b/contracts/savings-vault/src/types.rs index e91a05b5..ded19a22 100644 --- a/contracts/savings-vault/src/types.rs +++ b/contracts/savings-vault/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Map, String, Vec}; +use soroban_sdk::{contracttype, Address, Map, String, Vec}; /// A flexible savings account: deposit and withdraw any time. #[contracttype] @@ -295,4 +295,32 @@ pub enum DataKey { /// /// Durability: instance. TTL: same as `InterestRateBps`. InterestFrequency, + /// Per-contributor amounts for a shared savings goal. + GoalContributions(u64), + /// Ledger sequence of the most recent contribution to a goal. + GoalLastContributionLedger(u64), + /// Set while an external token transfer is in progress. + TokenTransferActive, + + // ── Issue #430: flexible fee structure ──────────────────────────────── + /// Protocol fee in basis points (1/100 %). Max 1_000 (10%). Defaults to 0. + /// Durability: instance. + FeeBps, + /// Address that receives protocol fees. Defaults to admin when unset. + /// Durability: instance. + FeeRecipient, + + // ── Issue #436: multi-token support ──────────────────────────────────── + /// Whitelist of accepted token addresses. Stored as a Vec
. + /// The primary token (set at `initialize`) is always included. + /// Durability: instance. + TokenWhitelist, + /// Per-token flexible balances for a user: Map. + /// Durability: persistent. + TokenBalance(Address), + /// Conversion rate from one token to another, in units of 1/1_000_000. + /// 1_000_000 = 1:1 parity. Stored per (from, to) pair. + /// Durability: instance. + ConversionRate(Address, Address), } +