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 ced19755..b82e9dff 100644 --- a/contracts/savings-vault/src/lib.rs +++ b/contracts/savings-vault/src/lib.rs @@ -19,6 +19,7 @@ mod admin; mod delegation; mod error; mod events; +mod fees; mod flexible; mod goal; mod group; 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)); + } +}