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));
+ }
+}