From 3ae8a31da8916a49c2aac271025cb37fc728bd19 Mon Sep 17 00:00:00 2001 From: yunusabdul38 Date: Tue, 26 May 2026 10:24:42 +0100 Subject: [PATCH] feat(contracts): implement fee distribution contract with recipient management --- contracts/Cargo.lock | 14 + contracts/Cargo.toml | 6 +- contracts/fee-distribution/Cargo.toml | 15 + contracts/fee-distribution/src/lib.rs | 391 +++++++++ contracts/fee-distribution/src/test.rs | 556 +++++++++++++ contracts/merchant-vault/src/lib.rs | 440 ++++++++-- contracts/merchant-vault/src/test.rs | 1061 +++++++++--------------- 7 files changed, 1734 insertions(+), 749 deletions(-) create mode 100644 contracts/fee-distribution/Cargo.toml create mode 100644 contracts/fee-distribution/src/lib.rs create mode 100644 contracts/fee-distribution/src/test.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 0e0c766..1bd468d 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -431,6 +431,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" +[[package]] +name = "escrow-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ethnum" version = "1.5.2" @@ -444,6 +451,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "fee-distribution" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ff" version = "0.13.1" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 9edb1e1..225f25c 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,6 @@ -[workspace] -resolver = "2" -members = ["example-contract", "registry-contract", "payment-router", "user-identity-contract", "reputation_score_contract","merchant-vault", "upgradeable-proxy"] +[workspace] +resolver = "2" +members = ["example-contract", "registry-contract", "payment-router", "user-identity-contract", "reputation_score_contract","merchant-vault", "upgradeable-proxy", "escrow-contract", "fee-distribution"] [workspace.dependencies] soroban-sdk = "21.0.0" diff --git a/contracts/fee-distribution/Cargo.toml b/contracts/fee-distribution/Cargo.toml new file mode 100644 index 0000000..a0c6f55 --- /dev/null +++ b/contracts/fee-distribution/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fee-distribution" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/fee-distribution/src/lib.rs b/contracts/fee-distribution/src/lib.rs new file mode 100644 index 0000000..fa84227 --- /dev/null +++ b/contracts/fee-distribution/src/lib.rs @@ -0,0 +1,391 @@ +#![no_std] + +//! # Fee Distribution Contract +//! +//! Collects fees from authorised depositors and distributes them among a +//! configurable set of recipients according to basis-point (bps) weights. +//! +//! ## Key design decisions +//! +//! * **Basis points** – each recipient's share is expressed in bps +//! (1 bps = 0.01 %). All shares must sum to exactly 10 000 (= 100 %). +//! * **Accumulation** – fees are deposited into the contract's token balance +//! and tracked in a `pending` counter. Nothing moves until distribution +//! is triggered. +//! * **Distribution** – any caller may trigger distribution (permissionless +//! pull), but the admin also has an explicit `distribute` entry-point. +//! Rounding remainders (from integer division) are credited to the *first* +//! recipient so no dust is ever lost. +//! * **Recipient management** – only the admin can add/remove recipients or +//! change shares. The full list is replaced atomically to keep the +//! invariant that shares always sum to 10 000. +//! * **Reentrancy guard** – a simple instance-storage lock prevents +//! re-entrant calls during distribution. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, + symbol_short, token::Client as TokenClient, + Address, Env, Symbol, Vec, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Total basis points representing 100 %. +const BPS_TOTAL: u32 = 10_000; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +const KEY_ADMIN: Symbol = symbol_short!("admin"); +const KEY_TOKEN: Symbol = symbol_short!("token"); +const KEY_RECIPS: Symbol = symbol_short!("recips"); +const KEY_PENDING: Symbol = symbol_short!("pending"); +const KEY_TOTAL_IN: Symbol = symbol_short!("total_in"); +const KEY_TOTAL_OUT: Symbol = symbol_short!("total_out"); +const KEY_LOCKED: Symbol = symbol_short!("locked"); + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +/// A single fee recipient with a basis-point share. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Recipient { + pub address: Address, + /// Share in basis points (0–10 000). All recipients' shares must sum to + /// exactly 10 000. + pub share_bps: u32, + /// Cumulative amount distributed to this recipient over the contract's + /// lifetime. + pub total_received: i128, +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidShares = 4, // shares don't sum to BPS_TOTAL + EmptyRecipients = 5, + ZeroAmount = 6, + NothingToDistribute = 7, + Reentrant = 8, + InvalidShareValue = 9, // individual share is 0 or > BPS_TOTAL +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct FeeDistribution; + +#[contractimpl] +impl FeeDistribution { + + // ----------------------------------------------------------------------- + // Initialisation + // ----------------------------------------------------------------------- + + /// Initialise the contract. + /// + /// * `admin` – address that controls recipient management and can + /// trigger manual distribution + /// * `token` – the SAC / token contract whose balance this contract + /// accumulates and distributes + /// * `recipients` – initial recipient list; shares must sum to 10 000 + pub fn initialize( + env: Env, + admin: Address, + token: Address, + recipients: Vec, + ) -> Result<(), Error> { + if env.storage().instance().has(&KEY_ADMIN) { + return Err(Error::AlreadyInitialized); + } + + admin.require_auth(); + + Self::validate_recipients(&env, &recipients)?; + + env.storage().instance().set(&KEY_ADMIN, &admin); + env.storage().instance().set(&KEY_TOKEN, &token); + env.storage().instance().set(&KEY_RECIPS, &recipients); + env.storage().instance().set(&KEY_PENDING, &0i128); + env.storage().instance().set(&KEY_TOTAL_IN, &0i128); + env.storage().instance().set(&KEY_TOTAL_OUT, &0i128); + env.storage().instance().set(&KEY_LOCKED, &false); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Fee deposit + // ----------------------------------------------------------------------- + + /// Deposit `amount` tokens into the pending pool. + /// + /// The caller must have approved this contract to transfer `amount` from + /// their balance (standard SAC allowance flow). Any address may deposit; + /// access control is enforced by the token contract itself. + pub fn deposit(env: Env, from: Address, amount: i128) -> Result<(), Error> { + from.require_auth(); + + if amount <= 0 { + return Err(Error::ZeroAmount); + } + + Self::require_initialized(&env)?; + + let token: Address = env.storage().instance().get(&KEY_TOKEN).unwrap(); + TokenClient::new(&env, &token) + .transfer(&from, &env.current_contract_address(), &amount); + + let pending: i128 = env.storage().instance().get(&KEY_PENDING).unwrap_or(0); + let total_in: i128 = env.storage().instance().get(&KEY_TOTAL_IN).unwrap_or(0); + + env.storage().instance().set(&KEY_PENDING, &(pending + amount)); + env.storage().instance().set(&KEY_TOTAL_IN, &(total_in + amount)); + + env.events().publish( + (symbol_short!("fee_dist"), symbol_short!("deposited")), + (from, amount, pending + amount), + ); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Distribution + // ----------------------------------------------------------------------- + + /// Distribute all pending fees to recipients according to their shares. + /// + /// Permissionless — anyone can call this. The admin also has a dedicated + /// `admin_distribute` entry-point that is identical but requires admin + /// auth, making it easy to trigger from governance tooling. + /// + /// Returns the total amount distributed. + pub fn distribute(env: Env) -> Result { + Self::require_initialized(&env)?; + Self::do_distribute(&env) + } + + /// Admin-gated distribution trigger (same logic as `distribute`). + pub fn admin_distribute(env: Env) -> Result { + Self::require_initialized(&env)?; + let admin: Address = env.storage().instance().get(&KEY_ADMIN).unwrap(); + admin.require_auth(); + Self::do_distribute(&env) + } + + // ----------------------------------------------------------------------- + // Recipient management (admin only) + // ----------------------------------------------------------------------- + + /// Replace the entire recipient list atomically. + /// + /// The new list must be non-empty and shares must sum to exactly 10 000. + /// Any pending fees are distributed with the *old* list before the update + /// takes effect, so no funds are mis-attributed. + pub fn set_recipients( + env: Env, + recipients: Vec, + ) -> Result<(), Error> { + Self::require_initialized(&env)?; + let admin: Address = env.storage().instance().get(&KEY_ADMIN).unwrap(); + admin.require_auth(); + + Self::validate_recipients(&env, &recipients)?; + + // Flush pending fees under the current allocation before switching. + let pending: i128 = env.storage().instance().get(&KEY_PENDING).unwrap_or(0); + if pending > 0 { + Self::do_distribute(&env)?; + } + + env.storage().instance().set(&KEY_RECIPS, &recipients); + + env.events().publish( + (symbol_short!("fee_dist"), symbol_short!("recips_up")), + recipients.len() as u32, + ); + + Ok(()) + } + + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> { + Self::require_initialized(&env)?; + let admin: Address = env.storage().instance().get(&KEY_ADMIN).unwrap(); + admin.require_auth(); + env.storage().instance().set(&KEY_ADMIN, &new_admin); + + env.events().publish( + (symbol_short!("fee_dist"), symbol_short!("adm_xfer")), + new_admin, + ); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + pub fn get_admin(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_ADMIN).unwrap()) + } + + pub fn get_token(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_TOKEN).unwrap()) + } + + pub fn get_recipients(env: Env) -> Result, Error> { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_RECIPS).unwrap()) + } + + pub fn get_pending(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_PENDING).unwrap_or(0)) + } + + pub fn get_total_in(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_TOTAL_IN).unwrap_or(0)) + } + + pub fn get_total_out(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(env.storage().instance().get(&KEY_TOTAL_OUT).unwrap_or(0)) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + fn require_initialized(env: &Env) -> Result<(), Error> { + if !env.storage().instance().has(&KEY_ADMIN) { + return Err(Error::NotInitialized); + } + Ok(()) + } + + fn validate_recipients(env: &Env, recipients: &Vec) -> Result<(), Error> { + if recipients.is_empty() { + return Err(Error::EmptyRecipients); + } + + let mut total: u32 = 0; + for r in recipients.iter() { + if r.share_bps == 0 || r.share_bps > BPS_TOTAL { + return Err(Error::InvalidShareValue); + } + total = total.checked_add(r.share_bps).unwrap_or(BPS_TOTAL + 1); + } + + if total != BPS_TOTAL { + return Err(Error::InvalidShares); + } + + let _ = env; // env available for future use (e.g. logging) + Ok(()) + } + + /// Core distribution logic. + /// + /// Algorithm: + /// 1. Read `pending` — bail early if zero. + /// 2. For each recipient compute `floor(pending * share_bps / 10_000)`. + /// 3. Sum the computed amounts; the difference from `pending` is the + /// rounding remainder, which is added to the first recipient's payout. + /// 4. Transfer tokens and update per-recipient `total_received`. + /// 5. Reset `pending` to zero, bump `total_out`. + fn do_distribute(env: &Env) -> Result { + // Reentrancy guard. + if env.storage().instance().get(&KEY_LOCKED).unwrap_or(false) { + return Err(Error::Reentrant); + } + env.storage().instance().set(&KEY_LOCKED, &true); + + let pending: i128 = env.storage().instance().get(&KEY_PENDING).unwrap_or(0); + if pending == 0 { + env.storage().instance().set(&KEY_LOCKED, &false); + return Err(Error::NothingToDistribute); + } + + let token: Address = env.storage().instance().get(&KEY_TOKEN).unwrap(); + let token_client = TokenClient::new(env, &token); + let contract_addr = env.current_contract_address(); + + let recipients: Vec = env.storage().instance().get(&KEY_RECIPS).unwrap(); + + // --- Compute per-recipient amounts ----------------------------------- + // Use a fixed-size stack array approach: collect amounts first, then + // transfer, to keep the CEI pattern (state before external calls). + + let n = recipients.len(); + let mut amounts: Vec = soroban_sdk::vec![env]; + let mut distributed: i128 = 0; + + for r in recipients.iter() { + let amt = pending * (r.share_bps as i128) / (BPS_TOTAL as i128); + amounts.push_back(amt); + distributed += amt; + } + + // Remainder goes to the first recipient (index 0). + let remainder = pending - distributed; + + // --- State update (Effects) before token transfers (Interactions) --- + // Reset pending and bump total_out. + env.storage().instance().set(&KEY_PENDING, &0i128); + let total_out: i128 = env.storage().instance().get(&KEY_TOTAL_OUT).unwrap_or(0); + env.storage().instance().set(&KEY_TOTAL_OUT, &(total_out + pending)); + + // Update per-recipient total_received in storage. + let mut updated: Vec = soroban_sdk::vec![env]; + for i in 0..n { + let mut r = recipients.get(i as u32).unwrap(); + let extra = if i == 0 { remainder } else { 0 }; + r.total_received += amounts.get(i as u32).unwrap() + extra; + updated.push_back(r); + } + env.storage().instance().set(&KEY_RECIPS, &updated); + + // --- Token transfers (Interactions) ---------------------------------- + for i in 0..n { + let r = updated.get(i as u32).unwrap(); + let base_amt = amounts.get(i as u32).unwrap(); + let extra = if i == 0 { remainder } else { 0 }; + let payout = base_amt + extra; + if payout > 0 { + token_client.transfer(&contract_addr, &r.address, &payout); + } + } + + env.storage().instance().set(&KEY_LOCKED, &false); + + env.events().publish( + (symbol_short!("fee_dist"), symbol_short!("distrib")), + (pending, n as u32), + ); + + Ok(pending) + } +} + +mod test; diff --git a/contracts/fee-distribution/src/test.rs b/contracts/fee-distribution/src/test.rs new file mode 100644 index 0000000..0c37455 --- /dev/null +++ b/contracts/fee-distribution/src/test.rs @@ -0,0 +1,556 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token::StellarAssetClient, + vec, Address, Env, Symbol, TryFromVal, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_token(env: &Env, admin: &Address) -> Address { + env.register_stellar_asset_contract_v2(admin.clone()).address() +} + +fn mint(env: &Env, token: &Address, to: &Address, amount: i128) { + StellarAssetClient::new(env, token).mint(to, &amount); +} + +fn token_balance(env: &Env, token: &Address, who: &Address) -> i128 { + soroban_sdk::token::Client::new(env, token).balance(who) +} + +/// Build a Recipient with zero total_received (initial state). +fn recip(address: Address, share_bps: u32) -> Recipient { + Recipient { address, share_bps, total_received: 0 } +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + client: FeeDistributionClient<'static>, + admin: Address, + token: Address, + /// Three recipients: r0 = 50 %, r1 = 30 %, r2 = 20 % + r: [Address; 3], +} + +impl Setup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + + let r0 = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + + let recipients = vec![ + &env, + recip(r0.clone(), 5_000), // 50 % + recip(r1.clone(), 3_000), // 30 % + recip(r2.clone(), 2_000), // 20 % + ]; + + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + client.initialize(&admin, &token, &recipients); + + let client: FeeDistributionClient<'static> = unsafe { core::mem::transmute(client) }; + + Setup { env, client, admin, token, r: [r0, r1, r2] } + } + + /// Deposit `amount` from a freshly minted depositor. + fn deposit(&self, amount: i128) -> Address { + let depositor = Address::generate(&self.env); + mint(&self.env, &self.token, &depositor, amount); + self.client.deposit(&depositor, &amount); + depositor + } +} + +fn has_event(env: &Env, t0: &str, t1: &str) -> bool { + let events = env.events().all(); + events.iter().any(|(_, topics, _)| { + if topics.len() != 2 { return false; } + let a = >::try_from_val(env, &topics.get(0).unwrap()); + let b = >::try_from_val(env, &topics.get(1).unwrap()); + matches!((a, b), (Ok(x), Ok(y)) + if x == Symbol::new(env, t0) && y == Symbol::new(env, t1)) + }) +} + +// --------------------------------------------------------------------------- +// Initialisation +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_stores_config() { + let s = Setup::new(); + assert_eq!(s.client.get_admin(), s.admin); + assert_eq!(s.client.get_token(), s.token); + assert_eq!(s.client.get_recipients().len(), 3); + assert_eq!(s.client.get_pending(), 0); +} + +#[test] +fn test_initialize_twice_fails() { + let s = Setup::new(); + let result = s.client.try_initialize( + &s.admin, + &s.token, + &vec![&s.env, recip(Address::generate(&s.env), 10_000)], + ); + assert_eq!(result, Err(Ok(Error::AlreadyInitialized))); +} + +#[test] +fn test_initialize_empty_recipients_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + + let result = client.try_initialize(&admin, &token, &vec![&env]); + assert_eq!(result, Err(Ok(Error::EmptyRecipients))); +} + +#[test] +fn test_initialize_shares_not_summing_to_10000_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + + let bad = vec![ + &env, + recip(Address::generate(&env), 5_000), + recip(Address::generate(&env), 4_000), // sums to 9 000, not 10 000 + ]; + let result = client.try_initialize(&admin, &token, &bad); + assert_eq!(result, Err(Ok(Error::InvalidShares))); +} + +#[test] +fn test_initialize_zero_share_fails() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + + let bad = vec![ + &env, + recip(Address::generate(&env), 0), // invalid + recip(Address::generate(&env), 10_000), + ]; + let result = client.try_initialize(&admin, &token, &bad); + assert_eq!(result, Err(Ok(Error::InvalidShareValue))); +} + +#[test] +fn test_initialize_single_recipient_100_pct() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + let r = Address::generate(&env); + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + + client.initialize(&admin, &token, &vec![&env, recip(r, 10_000)]); + assert_eq!(client.get_recipients().len(), 1); +} + +// --------------------------------------------------------------------------- +// Deposit +// --------------------------------------------------------------------------- + +#[test] +fn test_deposit_increases_pending() { + let s = Setup::new(); + s.deposit(1_000); + assert_eq!(s.client.get_pending(), 1_000); + s.deposit(500); + assert_eq!(s.client.get_pending(), 1_500); +} + +#[test] +fn test_deposit_increases_total_in() { + let s = Setup::new(); + s.deposit(1_000); + s.deposit(2_000); + assert_eq!(s.client.get_total_in(), 3_000); +} + +#[test] +fn test_deposit_zero_fails() { + let s = Setup::new(); + let depositor = Address::generate(&s.env); + let result = s.client.try_deposit(&depositor, &0); + assert_eq!(result, Err(Ok(Error::ZeroAmount))); +} + +#[test] +fn test_deposit_negative_fails() { + let s = Setup::new(); + let depositor = Address::generate(&s.env); + let result = s.client.try_deposit(&depositor, &-1); + assert_eq!(result, Err(Ok(Error::ZeroAmount))); +} + +#[test] +fn test_deposit_emits_event() { + let s = Setup::new(); + s.deposit(500); + assert!(has_event(&s.env, "fee_dist", "deposited")); +} + +// --------------------------------------------------------------------------- +// Distribution — correct amounts +// --------------------------------------------------------------------------- + +#[test] +fn test_distribute_splits_50_30_20() { + let s = Setup::new(); + s.deposit(10_000); + + s.client.distribute(); + + assert_eq!(token_balance(&s.env, &s.token, &s.r[0]), 5_000); // 50 % + assert_eq!(token_balance(&s.env, &s.token, &s.r[1]), 3_000); // 30 % + assert_eq!(token_balance(&s.env, &s.token, &s.r[2]), 2_000); // 20 % +} + +#[test] +fn test_distribute_resets_pending_to_zero() { + let s = Setup::new(); + s.deposit(10_000); + s.client.distribute(); + assert_eq!(s.client.get_pending(), 0); +} + +#[test] +fn test_distribute_increases_total_out() { + let s = Setup::new(); + s.deposit(10_000); + s.client.distribute(); + assert_eq!(s.client.get_total_out(), 10_000); +} + +#[test] +fn test_distribute_updates_recipient_total_received() { + let s = Setup::new(); + s.deposit(10_000); + s.client.distribute(); + + let recips = s.client.get_recipients(); + assert_eq!(recips.get(0).unwrap().total_received, 5_000); + assert_eq!(recips.get(1).unwrap().total_received, 3_000); + assert_eq!(recips.get(2).unwrap().total_received, 2_000); +} + +#[test] +fn test_distribute_accumulates_total_received_across_rounds() { + let s = Setup::new(); + s.deposit(10_000); + s.client.distribute(); + s.deposit(10_000); + s.client.distribute(); + + let recips = s.client.get_recipients(); + assert_eq!(recips.get(0).unwrap().total_received, 10_000); + assert_eq!(recips.get(1).unwrap().total_received, 6_000); + assert_eq!(recips.get(2).unwrap().total_received, 4_000); +} + +#[test] +fn test_distribute_nothing_to_distribute_fails() { + let s = Setup::new(); + let result = s.client.try_distribute(); + assert_eq!(result, Err(Ok(Error::NothingToDistribute))); +} + +#[test] +fn test_distribute_emits_event() { + let s = Setup::new(); + s.deposit(1_000); + s.client.distribute(); + assert!(has_event(&s.env, "fee_dist", "distrib")); +} + +// --------------------------------------------------------------------------- +// Rounding — remainder goes to first recipient +// --------------------------------------------------------------------------- + +#[test] +fn test_rounding_remainder_goes_to_first_recipient() { + // 3 recipients: 50 %, 30 %, 20 %. Deposit 1 (indivisible). + // floor(1 * 5000 / 10000) = 0, floor(1 * 3000 / 10000) = 0, + // floor(1 * 2000 / 10000) = 0. distributed = 0, remainder = 1. + // r0 gets 0 + 1 = 1, r1 gets 0, r2 gets 0. + let s = Setup::new(); + s.deposit(1); + s.client.distribute(); + + assert_eq!(token_balance(&s.env, &s.token, &s.r[0]), 1); + assert_eq!(token_balance(&s.env, &s.token, &s.r[1]), 0); + assert_eq!(token_balance(&s.env, &s.token, &s.r[2]), 0); +} + +#[test] +fn test_rounding_no_dust_lost() { + // Deposit an amount that doesn't divide evenly. + // 10_001 with 50/30/20 split: + // r0 = floor(10001 * 5000 / 10000) = 5000 + // r1 = floor(10001 * 3000 / 10000) = 3000 + // r2 = floor(10001 * 2000 / 10000) = 2000 + // distributed = 10000, remainder = 1 → r0 gets +1 = 5001 + let s = Setup::new(); + s.deposit(10_001); + s.client.distribute(); + + let r0 = token_balance(&s.env, &s.token, &s.r[0]); + let r1 = token_balance(&s.env, &s.token, &s.r[1]); + let r2 = token_balance(&s.env, &s.token, &s.r[2]); + + assert_eq!(r0 + r1 + r2, 10_001, "no dust must be lost"); + assert_eq!(r0, 5_001); // remainder lands on first recipient + assert_eq!(r1, 3_000); + assert_eq!(r2, 2_000); +} + +#[test] +fn test_rounding_large_remainder() { + // 2 recipients: 33.33 % (3333 bps) and 66.67 % (6667 bps). + // Deposit 10: floor(10*3333/10000)=3, floor(10*6667/10000)=6 → sum=9, rem=1. + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = make_token(&env, &admin); + let r0 = Address::generate(&env); + let r1 = Address::generate(&env); + + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + client.initialize( + &admin, + &token, + &vec![&env, recip(r0.clone(), 3_333), recip(r1.clone(), 6_667)], + ); + + let depositor = Address::generate(&env); + mint(&env, &token, &depositor, 10); + client.deposit(&depositor, &10); + client.distribute(); + + let b0 = token_balance(&env, &token, &r0); + let b1 = token_balance(&env, &token, &r1); + assert_eq!(b0 + b1, 10, "no dust lost"); + assert_eq!(b0, 4); // 3 base + 1 remainder + assert_eq!(b1, 6); +} + +// --------------------------------------------------------------------------- +// admin_distribute +// --------------------------------------------------------------------------- + +#[test] +fn test_admin_distribute_works() { + let s = Setup::new(); + s.deposit(1_000); + let total = s.client.admin_distribute(); + assert_eq!(total, 1_000); + assert_eq!(s.client.get_pending(), 0); +} + +#[test] +fn test_admin_distribute_nothing_fails() { + let s = Setup::new(); + let result = s.client.try_admin_distribute(); + assert_eq!(result, Err(Ok(Error::NothingToDistribute))); +} + +#[test] +fn test_non_admin_cannot_call_admin_distribute() { + let s = Setup::new(); + s.deposit(1_000); + + // Clear mocked auths so admin auth is not provided. + s.env.mock_auths(&[]); + let result = s.client.try_admin_distribute(); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// set_recipients +// --------------------------------------------------------------------------- + +#[test] +fn test_set_recipients_replaces_list() { + let s = Setup::new(); + let new_r0 = Address::generate(&s.env); + let new_r1 = Address::generate(&s.env); + + let new_list = vec![ + &s.env, + recip(new_r0.clone(), 6_000), + recip(new_r1.clone(), 4_000), + ]; + s.client.set_recipients(&new_list); + + let stored = s.client.get_recipients(); + assert_eq!(stored.len(), 2); + assert_eq!(stored.get(0).unwrap().share_bps, 6_000); + assert_eq!(stored.get(1).unwrap().share_bps, 4_000); +} + +#[test] +fn test_set_recipients_flushes_pending_first() { + let s = Setup::new(); + s.deposit(10_000); + + let new_r = Address::generate(&s.env); + s.client.set_recipients(&vec![&s.env, recip(new_r.clone(), 10_000)]); + + // Pending was flushed to old recipients before the switch. + assert_eq!(s.client.get_pending(), 0); + // Old r0 (50 %) received 5 000. + assert_eq!(token_balance(&s.env, &s.token, &s.r[0]), 5_000); +} + +#[test] +fn test_set_recipients_invalid_shares_fails() { + let s = Setup::new(); + let bad = vec![ + &s.env, + recip(Address::generate(&s.env), 5_000), + recip(Address::generate(&s.env), 3_000), // sums to 8 000 + ]; + let result = s.client.try_set_recipients(&bad); + assert_eq!(result, Err(Ok(Error::InvalidShares))); +} + +#[test] +fn test_set_recipients_empty_fails() { + let s = Setup::new(); + let result = s.client.try_set_recipients(&vec![&s.env]); + assert_eq!(result, Err(Ok(Error::EmptyRecipients))); +} + +#[test] +fn test_non_admin_cannot_set_recipients() { + let s = Setup::new(); + s.env.mock_auths(&[]); + let result = s.client.try_set_recipients(&vec![ + &s.env, + recip(Address::generate(&s.env), 10_000), + ]); + assert!(result.is_err()); +} + +#[test] +fn test_set_recipients_emits_event() { + let s = Setup::new(); + let new_r = Address::generate(&s.env); + s.client.set_recipients(&vec![&s.env, recip(new_r, 10_000)]); + assert!(has_event(&s.env, "fee_dist", "recips_up")); +} + +// --------------------------------------------------------------------------- +// transfer_admin +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_admin_changes_admin() { + let s = Setup::new(); + let new_admin = Address::generate(&s.env); + s.client.transfer_admin(&new_admin); + assert_eq!(s.client.get_admin(), new_admin); +} + +#[test] +fn test_non_admin_cannot_transfer_admin() { + let s = Setup::new(); + s.env.mock_auths(&[]); + let result = s.client.try_transfer_admin(&Address::generate(&s.env)); + assert!(result.is_err()); +} + +#[test] +fn test_transfer_admin_emits_event() { + let s = Setup::new(); + let new_admin = Address::generate(&s.env); + s.client.transfer_admin(&new_admin); + assert!(has_event(&s.env, "fee_dist", "adm_xfer")); +} + +// --------------------------------------------------------------------------- +// Multiple distribution rounds +// --------------------------------------------------------------------------- + +#[test] +fn test_multiple_rounds_accumulate_correctly() { + let s = Setup::new(); + + s.deposit(10_000); + s.client.distribute(); + + s.deposit(5_000); + s.client.distribute(); + + assert_eq!(s.client.get_total_in(), 15_000); + assert_eq!(s.client.get_total_out(), 15_000); + assert_eq!(s.client.get_pending(), 0); + + // r0 = 50 % of 15 000 = 7 500 + assert_eq!(token_balance(&s.env, &s.token, &s.r[0]), 7_500); + assert_eq!(token_balance(&s.env, &s.token, &s.r[1]), 4_500); + assert_eq!(token_balance(&s.env, &s.token, &s.r[2]), 3_000); +} + +#[test] +fn test_deposit_then_distribute_then_deposit_then_distribute() { + let s = Setup::new(); + + s.deposit(1_000); + s.client.distribute(); + assert_eq!(s.client.get_pending(), 0); + + s.deposit(2_000); + assert_eq!(s.client.get_pending(), 2_000); + + s.client.distribute(); + assert_eq!(s.client.get_pending(), 0); + assert_eq!(s.client.get_total_out(), 3_000); +} + +// --------------------------------------------------------------------------- +// Views before initialisation +// --------------------------------------------------------------------------- + +#[test] +fn test_views_before_init_return_not_initialized() { + let env = Env::default(); + let contract_id = env.register_contract(None, FeeDistribution); + let client = FeeDistributionClient::new(&env, &contract_id); + + assert_eq!(client.try_get_pending(), Err(Ok(Error::NotInitialized))); + assert_eq!(client.try_get_total_in(), Err(Ok(Error::NotInitialized))); + assert_eq!(client.try_get_total_out(), Err(Ok(Error::NotInitialized))); + assert_eq!(client.try_get_recipients(), Err(Ok(Error::NotInitialized))); +} diff --git a/contracts/merchant-vault/src/lib.rs b/contracts/merchant-vault/src/lib.rs index 0ed4d78..7bb8580 100644 --- a/contracts/merchant-vault/src/lib.rs +++ b/contracts/merchant-vault/src/lib.rs @@ -1,14 +1,31 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol}; + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, + symbol_short, vec, Address, Env, Symbol, Vec, +}; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- #[contracttype] pub enum DataKey { - Balance(Address), // merchant_id => balance - PaymentRouter, // authorized payment router - PayoutContract, // authorized payout contract - Admin, // contract administrator + Balance(Address), // merchant_id → i128 + PaymentRouter, // authorized payment router + PayoutContract, // authorized payout contract + Admin, // contract administrator + // Multi-sig + Signers, // Vec
— the signer set + Threshold, // u32 — approvals required + NextProposalId, // u32 — monotonic counter + Proposal(u32), // proposal_id → Proposal } +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + #[contracttype] pub struct BalanceCreditedEvent { pub merchant_id: Address, @@ -23,6 +40,31 @@ pub struct BalanceDebitedEvent { pub resulting_balance: i128, } +// --------------------------------------------------------------------------- +// Multi-sig types +// --------------------------------------------------------------------------- + +/// A pending withdrawal proposal. +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub merchant_id: Address, + pub amount: i128, + pub proposer: Address, + /// Addresses that have approved so far. + pub approvals: Vec
, + /// Ledger sequence number when the proposal was created. + pub created_ledger: u32, + /// Ledger count after which the proposal expires. + pub expiry_ledgers: u32, + pub executed: bool, + pub cancelled: bool, +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -33,19 +75,47 @@ pub enum Error { MerchantNotInitialized = 4, AlreadyInitialized = 5, NotInitialized = 6, + // Multi-sig errors + NotASigner = 7, + ProposalNotFound = 8, + ProposalExpired = 9, + ProposalAlreadyExecuted = 10, + ProposalAlreadyCancelled = 11, + AlreadyApproved = 12, + InvalidThreshold = 13, + EmptySigners = 14, } +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + #[contract] pub struct MerchantVault; #[contractimpl] impl MerchantVault { - /// Initialize the contract with admin and authorized contracts + + // ----------------------------------------------------------------------- + // Initialisation + // ----------------------------------------------------------------------- + + /// Initialise the contract. + /// + /// * `signers` – ordered list of addresses that form the multi-sig + /// signer set (must be non-empty) + /// * `threshold` – number of approvals required to execute a + /// withdrawal (1 ≤ threshold ≤ signers.len()) + /// * `expiry_ledgers` – how many ledgers a proposal stays open before it + /// expires (e.g. 17_280 ≈ 1 day at 5 s/ledger) pub fn initialize( env: Env, admin: Address, payment_router: Address, payout_contract: Address, + signers: Vec
, + threshold: u32, + expiry_ledgers: u32, ) -> Result<(), Error> { if env.storage().instance().has(&DataKey::Admin) { return Err(Error::AlreadyInitialized); @@ -53,18 +123,27 @@ impl MerchantVault { admin.require_auth(); + if signers.is_empty() { + return Err(Error::EmptySigners); + } + if threshold == 0 || threshold > signers.len() as u32 { + return Err(Error::InvalidThreshold); + } + env.storage().instance().set(&DataKey::Admin, &admin); - env.storage() - .instance() - .set(&DataKey::PaymentRouter, &payment_router); - env.storage() - .instance() - .set(&DataKey::PayoutContract, &payout_contract); + env.storage().instance().set(&DataKey::PaymentRouter, &payment_router); + env.storage().instance().set(&DataKey::PayoutContract, &payout_contract); + env.storage().instance().set(&DataKey::Signers, &signers); + env.storage().instance().set(&DataKey::Threshold, &threshold); + // expiry_ledgers stored per-proposal at proposal time; keep a default + // in instance storage so callers don't have to pass it every time. + env.storage().instance().set(&symbol_short!("exp_ldgrs"), &expiry_ledgers); + env.storage().instance().set(&DataKey::NextProposalId, &0u32); Ok(()) } - /// Initialize a merchant account with zero balance + /// Initialise a merchant account with zero balance. pub fn init_merchant(env: Env, merchant_id: Address) -> Result<(), Error> { let admin: Address = env .storage() @@ -74,22 +153,19 @@ impl MerchantVault { admin.require_auth(); - if env - .storage() - .persistent() - .has(&DataKey::Balance(merchant_id.clone())) - { + if env.storage().persistent().has(&DataKey::Balance(merchant_id.clone())) { return Err(Error::AlreadyInitialized); } - env.storage() - .persistent() - .set(&DataKey::Balance(merchant_id), &0i128); - + env.storage().persistent().set(&DataKey::Balance(merchant_id), &0i128); Ok(()) } - /// Credit merchant balance + // ----------------------------------------------------------------------- + // Core ledger operations (existing paths) + // ----------------------------------------------------------------------- + + /// Credit merchant balance — callable only by the payment router. pub fn credit(env: Env, merchant_id: Address, amount: i128) -> Result { let payment_router: Address = env .storage() @@ -103,42 +179,32 @@ impl MerchantVault { return Err(Error::NegativeAmount); } - if !env - .storage() - .persistent() - .has(&DataKey::Balance(merchant_id.clone())) - { + if !env.storage().persistent().has(&DataKey::Balance(merchant_id.clone())) { return Err(Error::MerchantNotInitialized); } - let current_balance: i128 = env + let current: i128 = env .storage() .persistent() .get(&DataKey::Balance(merchant_id.clone())) .unwrap_or(0); - let new_balance = current_balance - .checked_add(amount) - .expect("Balance overflow"); + let new_balance = current.checked_add(amount).expect("Balance overflow"); - env.storage() - .persistent() - .set(&DataKey::Balance(merchant_id.clone()), &new_balance); + env.storage().persistent().set(&DataKey::Balance(merchant_id.clone()), &new_balance); env.events().publish( (Symbol::new(&env, "balance_credited"), merchant_id.clone()), - BalanceCreditedEvent { - merchant_id, - amount, - resulting_balance: new_balance, - }, + BalanceCreditedEvent { merchant_id, amount, resulting_balance: new_balance }, ); Ok(new_balance) } - /// Debit merchant balance - /// Only callable by authorized payout/refund contract + /// Debit merchant balance — callable only by the payout contract. + /// + /// This is the direct (single-authority) debit path. For multi-sig + /// withdrawals use `propose_withdrawal` / `approve_withdrawal`. pub fn debit(env: Env, merchant_id: Address, amount: i128) -> Result { let payout_contract: Address = env .storage() @@ -152,93 +218,309 @@ impl MerchantVault { return Err(Error::NegativeAmount); } - if !env - .storage() - .persistent() - .has(&DataKey::Balance(merchant_id.clone())) - { + if !env.storage().persistent().has(&DataKey::Balance(merchant_id.clone())) { return Err(Error::MerchantNotInitialized); } - let current_balance: i128 = env + let current: i128 = env .storage() .persistent() .get(&DataKey::Balance(merchant_id.clone())) .unwrap_or(0); - if current_balance < amount { + if current < amount { return Err(Error::InsufficientBalance); } - let new_balance = current_balance - amount; - - env.storage() - .persistent() - .set(&DataKey::Balance(merchant_id.clone()), &new_balance); + let new_balance = current - amount; + env.storage().persistent().set(&DataKey::Balance(merchant_id.clone()), &new_balance); env.events().publish( (Symbol::new(&env, "balance_debited"), merchant_id.clone()), - BalanceDebitedEvent { - merchant_id, - amount, - resulting_balance: new_balance, - }, + BalanceDebitedEvent { merchant_id, amount, resulting_balance: new_balance }, ); Ok(new_balance) } - /// Get merchant balance (read-only, public) - pub fn balance_of(env: Env, merchant_id: Address) -> Result { - if !env - .storage() - .persistent() - .has(&DataKey::Balance(merchant_id.clone())) - { + // ----------------------------------------------------------------------- + // Multi-sig withdrawal flow + // ----------------------------------------------------------------------- + + /// Propose a withdrawal from a merchant's balance. + /// + /// The caller must be one of the configured signers. The proposal is + /// created with the proposer's approval already recorded. + /// + /// Returns the new `proposal_id`. + pub fn propose_withdrawal( + env: Env, + merchant_id: Address, + amount: i128, + proposer: Address, + ) -> Result { + proposer.require_auth(); + + Self::assert_signer(&env, &proposer)?; + + if amount <= 0 { + return Err(Error::NegativeAmount); + } + + if !env.storage().persistent().has(&DataKey::Balance(merchant_id.clone())) { return Err(Error::MerchantNotInitialized); } - let balance: i128 = env + let expiry_ledgers: u32 = env .storage() - .persistent() - .get(&DataKey::Balance(merchant_id)) + .instance() + .get(&symbol_short!("exp_ldgrs")) + .unwrap_or(17_280); + + let proposal_id: u32 = env + .storage() + .instance() + .get(&DataKey::NextProposalId) .unwrap_or(0); - Ok(balance) + // Proposer's approval is counted immediately. + let mut initial_approvals: Vec
= vec![&env]; + initial_approvals.push_back(proposer.clone()); + + let proposal = Proposal { + merchant_id: merchant_id.clone(), + amount, + proposer: proposer.clone(), + approvals: initial_approvals, + created_ledger: env.ledger().sequence(), + expiry_ledgers, + executed: false, + cancelled: false, + }; + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().instance().set(&DataKey::NextProposalId, &(proposal_id + 1)); + + env.events().publish( + (symbol_short!("multisig"), symbol_short!("proposed")), + (proposal_id, merchant_id, amount, proposer), + ); + + Ok(proposal_id) } - /// Update authorized payment router (admin only) - pub fn update_payment_router(env: Env, new_router: Address) -> Result<(), Error> { + /// Approve a pending withdrawal proposal. + /// + /// When the number of approvals reaches the threshold the withdrawal is + /// executed automatically (balance is debited). + pub fn approve_withdrawal( + env: Env, + proposal_id: u32, + approver: Address, + ) -> Result { + approver.require_auth(); + + Self::assert_signer(&env, &approver)?; + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(Error::ProposalNotFound)?; + + if proposal.cancelled { + return Err(Error::ProposalAlreadyCancelled); + } + if proposal.executed { + return Err(Error::ProposalAlreadyExecuted); + } + + let current_ledger = env.ledger().sequence(); + if current_ledger > proposal.created_ledger + proposal.expiry_ledgers { + return Err(Error::ProposalExpired); + } + + // Reject duplicate approvals. + for existing in proposal.approvals.iter() { + if existing == approver { + return Err(Error::AlreadyApproved); + } + } + + proposal.approvals.push_back(approver.clone()); + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .unwrap_or(1); + + let executed = proposal.approvals.len() as u32 >= threshold; + + if executed { + // Checks-Effects-Interactions: update state before any balance change. + proposal.executed = true; + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + // Debit the balance. + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(proposal.merchant_id.clone())) + .unwrap_or(0); + + if current < proposal.amount { + return Err(Error::InsufficientBalance); + } + + let new_balance = current - proposal.amount; + env.storage() + .persistent() + .set(&DataKey::Balance(proposal.merchant_id.clone()), &new_balance); + + env.events().publish( + (symbol_short!("multisig"), symbol_short!("executed")), + (proposal_id, proposal.merchant_id.clone(), proposal.amount, new_balance), + ); + } else { + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (symbol_short!("multisig"), symbol_short!("approved")), + (proposal_id, approver, proposal.approvals.len() as u32, threshold), + ); + } + + Ok(executed) + } + + /// Cancel a pending proposal. + /// + /// Only the original proposer or the admin may cancel. + pub fn cancel_proposal( + env: Env, + proposal_id: u32, + caller: Address, + ) -> Result<(), Error> { + caller.require_auth(); + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(Error::ProposalNotFound)?; + + if proposal.executed { + return Err(Error::ProposalAlreadyExecuted); + } + if proposal.cancelled { + return Err(Error::ProposalAlreadyCancelled); + } + let admin: Address = env .storage() .instance() .get(&DataKey::Admin) .ok_or(Error::NotInitialized)?; - admin.require_auth(); + if caller != proposal.proposer && caller != admin { + return Err(Error::UnauthorizedCaller); + } + + proposal.cancelled = true; + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (symbol_short!("multisig"), symbol_short!("cancelled")), + (proposal_id, caller), + ); + + Ok(()) + } + + // ----------------------------------------------------------------------- + // Views + // ----------------------------------------------------------------------- + + pub fn balance_of(env: Env, merchant_id: Address) -> Result { + if !env.storage().persistent().has(&DataKey::Balance(merchant_id.clone())) { + return Err(Error::MerchantNotInitialized); + } + Ok(env + .storage() + .persistent() + .get(&DataKey::Balance(merchant_id)) + .unwrap_or(0)) + } + pub fn get_proposal(env: Env, proposal_id: u32) -> Result { env.storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(Error::ProposalNotFound) + } + + pub fn get_threshold(env: Env) -> u32 { + env.storage().instance().get(&DataKey::Threshold).unwrap_or(0) + } + + pub fn get_signers(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::Signers) + .unwrap_or_else(|| vec![&env]) + } + + pub fn is_signer(env: Env, address: Address) -> bool { + let signers: Vec
= env + .storage() .instance() - .set(&DataKey::PaymentRouter, &new_router); + .get(&DataKey::Signers) + .unwrap_or_else(|| vec![&env]); + signers.contains(&address) + } + // ----------------------------------------------------------------------- + // Admin helpers + // ----------------------------------------------------------------------- + + pub fn update_payment_router(env: Env, new_router: Address) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::PaymentRouter, &new_router); Ok(()) } - /// Update authorized payout contract (admin only) pub fn update_payout_contract(env: Env, new_payout: Address) -> Result<(), Error> { let admin: Address = env .storage() .instance() .get(&DataKey::Admin) .ok_or(Error::NotInitialized)?; - admin.require_auth(); + env.storage().instance().set(&DataKey::PayoutContract, &new_payout); + Ok(()) + } - env.storage() - .instance() - .set(&DataKey::PayoutContract, &new_payout); + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + fn assert_signer(env: &Env, address: &Address) -> Result<(), Error> { + let signers: Vec
= env + .storage() + .instance() + .get(&DataKey::Signers) + .ok_or(Error::NotInitialized)?; + if !signers.contains(address) { + return Err(Error::NotASigner); + } Ok(()) } } + mod test; diff --git a/contracts/merchant-vault/src/test.rs b/contracts/merchant-vault/src/test.rs index 1e34c42..4b66175 100644 --- a/contracts/merchant-vault/src/test.rs +++ b/contracts/merchant-vault/src/test.rs @@ -1,825 +1,552 @@ #![cfg(test)] -use super::*; -use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal}; -#[test] -fn test_initialization() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, Env, +}; + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +struct Setup { + env: Env, + client: MerchantVaultClient<'static>, + admin: Address, + merchant: Address, + /// Signer set: [s0, s1, s2] + signers: [Address; 3], +} - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); +impl Setup { + /// 2-of-3 multi-sig, 1000-ledger expiry. + fn new_2_of_3() -> Self { + Self::new(2, 1_000) + } - env.mock_all_auths(); + fn new(threshold: u32, expiry_ledgers: u32) -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let payment_router = Address::generate(&env); + let payout_contract = Address::generate(&env); + let merchant = Address::generate(&env); + + let s0 = Address::generate(&env); + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + let signers_vec = vec![&env, s0.clone(), s1.clone(), s2.clone()]; + + let contract_id = env.register_contract(None, MerchantVault); + let client = MerchantVaultClient::new(&env, &contract_id); + + client.initialize( + &admin, + &payment_router, + &payout_contract, + &signers_vec, + &threshold, + &expiry_ledgers, + ); + client.init_merchant(&merchant); + client.credit(&merchant, &10_000); + + let client: MerchantVaultClient<'static> = unsafe { core::mem::transmute(client) }; + + Setup { env, client, admin, merchant, signers: [s0, s1, s2] } + } +} - // Initialize contract - client.initialize(&admin, &payment_router, &payout_contract); +// --------------------------------------------------------------------------- +// Initialisation +// --------------------------------------------------------------------------- - // Try to initialize again (should fail) - let result = client.try_initialize(&admin, &payment_router, &payout_contract); - assert_eq!(result, Err(Ok(Error::AlreadyInitialized))); +#[test] +fn test_initialize_stores_threshold_and_signers() { + let s = Setup::new_2_of_3(); + assert_eq!(s.client.get_threshold(), 2); + let stored = s.client.get_signers(); + assert_eq!(stored.len(), 3); } #[test] -fn test_merchant_initialization() { +fn test_initialize_rejects_empty_signers() { let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let pr = Address::generate(&env); + let pc = Address::generate(&env); let contract_id = env.register_contract(None, MerchantVault); let client = MerchantVaultClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Verify balance is zero - assert_eq!(client.balance_of(&merchant), 0); + let result = client.try_initialize(&admin, &pr, &pc, &vec![&env], &1, &1_000); + assert_eq!(result, Err(Ok(Error::EmptySigners))); } #[test] -fn test_credit_flow() { +fn test_initialize_rejects_threshold_zero() { let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let pr = Address::generate(&env); + let pc = Address::generate(&env); + let s = Address::generate(&env); let contract_id = env.register_contract(None, MerchantVault); let client = MerchantVaultClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Credit merchant account - let new_balance = client.credit(&merchant, &1000); - assert_eq!(new_balance, 1000); - assert_eq!(client.balance_of(&merchant), 1000); - - // Credit again - let new_balance = client.credit(&merchant, &500); - assert_eq!(new_balance, 1500); - assert_eq!(client.balance_of(&merchant), 1500); + let result = client.try_initialize(&admin, &pr, &pc, &vec![&env, s], &0, &1_000); + assert_eq!(result, Err(Ok(Error::InvalidThreshold))); } #[test] -fn test_debit_flow() { +fn test_initialize_rejects_threshold_exceeds_signers() { let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let pr = Address::generate(&env); + let pc = Address::generate(&env); + let s = Address::generate(&env); let contract_id = env.register_contract(None, MerchantVault); let client = MerchantVaultClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); - - // Debit merchant account - let new_balance = client.debit(&merchant, &300); - assert_eq!(new_balance, 700); - assert_eq!(client.balance_of(&merchant), 700); + // 1 signer, threshold 2 → invalid + let result = client.try_initialize(&admin, &pr, &pc, &vec![&env, s], &2, &1_000); + assert_eq!(result, Err(Ok(Error::InvalidThreshold))); } #[test] -fn test_over_debit_rejection() { +fn test_initialize_1_of_1() { let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let pr = Address::generate(&env); + let pc = Address::generate(&env); + let s = Address::generate(&env); let contract_id = env.register_contract(None, MerchantVault); let client = MerchantVaultClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); + client.initialize(&admin, &pr, &pc, &vec![&env, s], &1, &1_000); + assert_eq!(client.get_threshold(), 1); +} - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &500); +// --------------------------------------------------------------------------- +// is_signer / get_signers +// --------------------------------------------------------------------------- - // Try to debit more than balance - let result = client.try_debit(&merchant, &600); - assert_eq!(result, Err(Ok(Error::InsufficientBalance))); +#[test] +fn test_is_signer_returns_true_for_known_signer() { + let s = Setup::new_2_of_3(); + assert!(s.client.is_signer(&s.signers[0])); + assert!(s.client.is_signer(&s.signers[1])); + assert!(s.client.is_signer(&s.signers[2])); } #[test] -fn test_negative_amount_rejection() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); +fn test_is_signer_returns_false_for_unknown_address() { + let s = Setup::new_2_of_3(); + let outsider = Address::generate(&s.env); + assert!(!s.client.is_signer(&outsider)); +} - // Try negative credit - let result = client.try_credit(&merchant, &-100); - assert_eq!(result, Err(Ok(Error::NegativeAmount))); +// --------------------------------------------------------------------------- +// propose_withdrawal +// --------------------------------------------------------------------------- - // Try negative debit - let result = client.try_debit(&merchant, &-100); - assert_eq!(result, Err(Ok(Error::NegativeAmount))); +#[test] +fn test_propose_withdrawal_creates_proposal() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + assert_eq!(pid, 0); + + let p = s.client.get_proposal(&pid); + assert_eq!(p.merchant_id, s.merchant); + assert_eq!(p.amount, 500); + assert_eq!(p.proposer, s.signers[0]); + assert!(!p.executed); + assert!(!p.cancelled); + // Proposer's approval is pre-recorded. + assert_eq!(p.approvals.len(), 1); } #[test] -fn test_uninitialized_merchant_rejection() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - - // Try operations on non-existent merchant - let result = client.try_credit(&merchant, &100); - assert_eq!(result, Err(Ok(Error::MerchantNotInitialized))); - - let result = client.try_debit(&merchant, &100); - assert_eq!(result, Err(Ok(Error::MerchantNotInitialized))); - - let result = client.try_balance_of(&merchant); - assert_eq!(result, Err(Ok(Error::MerchantNotInitialized))); +fn test_propose_increments_proposal_id() { + let s = Setup::new_2_of_3(); + let pid0 = s.client.propose_withdrawal(&s.merchant, &100, &s.signers[0]); + let pid1 = s.client.propose_withdrawal(&s.merchant, &200, &s.signers[1]); + assert_eq!(pid0, 0); + assert_eq!(pid1, 1); } #[test] -fn test_balance_correctness_over_time() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Simulate transaction sequence - client.credit(&merchant, &1000); // 1000 - assert_eq!(client.balance_of(&merchant), 1000); - - client.credit(&merchant, &500); // 1500 - assert_eq!(client.balance_of(&merchant), 1500); - - client.debit(&merchant, &200); // 1300 - assert_eq!(client.balance_of(&merchant), 1300); - - client.credit(&merchant, &700); // 2000 - assert_eq!(client.balance_of(&merchant), 2000); - - client.debit(&merchant, &1500); // 500 - assert_eq!(client.balance_of(&merchant), 500); - - client.debit(&merchant, &500); // 0 - assert_eq!(client.balance_of(&merchant), 0); +fn test_propose_by_non_signer_fails() { + let s = Setup::new_2_of_3(); + let outsider = Address::generate(&s.env); + let result = s.client.try_propose_withdrawal(&s.merchant, &500, &outsider); + assert_eq!(result, Err(Ok(Error::NotASigner))); } #[test] -fn test_multiple_merchants() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant1 = Address::generate(&env); - let merchant2 = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant1); - client.init_merchant(&merchant2); - - client.credit(&merchant1, &1000); - client.credit(&merchant2, &500); - - assert_eq!(client.balance_of(&merchant1), 1000); - assert_eq!(client.balance_of(&merchant2), 500); - - client.debit(&merchant1, &300); - - assert_eq!(client.balance_of(&merchant1), 700); - assert_eq!(client.balance_of(&merchant2), 500); // Unchanged +fn test_propose_zero_amount_fails() { + let s = Setup::new_2_of_3(); + let result = s.client.try_propose_withdrawal(&s.merchant, &0, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::NegativeAmount))); } #[test] -fn test_zero_amount_operations() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Zero credit should work but not change balance - let balance = client.credit(&merchant, &0); - assert_eq!(balance, 0); - assert_eq!(client.balance_of(&merchant), 0); - - // Credit some amount - client.credit(&merchant, &1000); - - // Zero debit should work but not change balance - let balance = client.debit(&merchant, &0); - assert_eq!(balance, 1000); - assert_eq!(client.balance_of(&merchant), 1000); +fn test_propose_negative_amount_fails() { + let s = Setup::new_2_of_3(); + let result = s.client.try_propose_withdrawal(&s.merchant, &-1, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::NegativeAmount))); } #[test] -fn test_exact_balance_debit() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); - - // Debit exact balance should work and leave zero - let balance = client.debit(&merchant, &1000); - assert_eq!(balance, 0); - assert_eq!(client.balance_of(&merchant), 0); +fn test_propose_for_uninitialised_merchant_fails() { + let s = Setup::new_2_of_3(); + let unknown = Address::generate(&s.env); + let result = s.client.try_propose_withdrawal(&unknown, &100, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::MerchantNotInitialized))); } +// --------------------------------------------------------------------------- +// approve_withdrawal — threshold not yet reached +// --------------------------------------------------------------------------- + #[test] -fn test_debit_by_one_more_than_balance() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); +fn test_approve_records_approval_below_threshold() { + let s = Setup::new_2_of_3(); // threshold = 2 + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); + // s0 already approved via propose; s1 approves → threshold reached → executed + // So let's use a 3-of-3 setup to test the "below threshold" path. + let s3 = Setup::new(3, 1_000); + let pid3 = s3.client.propose_withdrawal(&s3.merchant, &500, &s3.signers[0]); - env.mock_all_auths(); + // s1 approves — 2 of 3, not yet executed. + let executed = s3.client.approve_withdrawal(&pid3, &s3.signers[1]); + assert!(!executed); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); + let p = s3.client.get_proposal(&pid3); + assert_eq!(p.approvals.len(), 2); + assert!(!p.executed); - // Debit one more than balance should fail - let result = client.try_debit(&merchant, &1001); - assert_eq!(result, Err(Ok(Error::InsufficientBalance))); + // Balance unchanged. + assert_eq!(s3.client.balance_of(&s3.merchant), 10_000); - // Balance should remain unchanged - assert_eq!(client.balance_of(&merchant), 1000); + // Suppress unused warning + let _ = pid; } #[test] -fn test_debit_from_zero_balance() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Debit from zero balance should fail - let result = client.try_debit(&merchant, &1); - assert_eq!(result, Err(Ok(Error::InsufficientBalance))); +fn test_approve_by_non_signer_fails() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + let outsider = Address::generate(&s.env); + let result = s.client.try_approve_withdrawal(&pid, &outsider); + assert_eq!(result, Err(Ok(Error::NotASigner))); } #[test] -#[should_panic(expected = "Balance overflow")] -fn test_balance_overflow() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Credit to max i128 - client.credit(&merchant, &i128::MAX); +fn test_duplicate_approval_fails() { + let s = Setup::new(3, 1_000); // 3-of-3 so we don't auto-execute + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - // Try to credit more (should panic due to overflow) - client.credit(&merchant, &1); + // s0 already approved via propose; trying again must fail. + let result = s.client.try_approve_withdrawal(&pid, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::AlreadyApproved))); } #[test] -fn test_large_amounts() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); +fn test_approve_nonexistent_proposal_fails() { + let s = Setup::new_2_of_3(); + let result = s.client.try_approve_withdrawal(&999, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::ProposalNotFound))); +} - // Credit very large amount - let large_amount = 1_000_000_000_000_000_000i128; // 1 quintillion - client.credit(&merchant, &large_amount); - assert_eq!(client.balance_of(&merchant), large_amount); +// --------------------------------------------------------------------------- +// approve_withdrawal — threshold reached → auto-execute +// --------------------------------------------------------------------------- - // Debit half - let debit_amount = 500_000_000_000_000_000i128; - client.debit(&merchant, &debit_amount); - assert_eq!(client.balance_of(&merchant), large_amount - debit_amount); +#[test] +fn test_2_of_3_executes_on_second_approval() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &1_000, &s.signers[0]); - // Credit more large amounts - client.credit(&merchant, &large_amount); - assert_eq!( - client.balance_of(&merchant), - large_amount - debit_amount + large_amount - ); + // s1 approves → 2 of 2 required → executed. + let executed = s.client.approve_withdrawal(&pid, &s.signers[1]); + assert!(executed); - // Debit everything - let final_balance = client.balance_of(&merchant); - client.debit(&merchant, &final_balance); - assert_eq!(client.balance_of(&merchant), 0); + let p = s.client.get_proposal(&pid); + assert!(p.executed); + assert_eq!(s.client.balance_of(&s.merchant), 9_000); } #[test] -fn test_double_merchant_initialization() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); +fn test_3_of_3_executes_on_third_approval() { + let s = Setup::new(3, 1_000); + let pid = s.client.propose_withdrawal(&s.merchant, &2_000, &s.signers[0]); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); + let executed = s.client.approve_withdrawal(&pid, &s.signers[1]); + assert!(!executed); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); + let executed = s.client.approve_withdrawal(&pid, &s.signers[2]); + assert!(executed); - // Try to initialize same merchant again - let result = client.try_init_merchant(&merchant); - assert_eq!(result, Err(Ok(Error::AlreadyInitialized))); + assert_eq!(s.client.balance_of(&s.merchant), 8_000); } #[test] -fn test_unauthorized_credit_attempt() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - let attacker = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Mock auth for attacker (not payment router) - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &attacker, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "credit", - args: (merchant.clone(), 1000i128).into_val(&env), - sub_invokes: &[], - }, - }]); - - // This should fail because attacker is not the payment router - // Note: In real scenario this would panic with auth error - // For testing purposes, we verify the authorization check exists +fn test_1_of_3_executes_immediately_on_propose() { + let s = Setup::new(1, 1_000); + // With threshold=1 the proposer's own approval is enough. + // propose_withdrawal returns the id; we then need to approve to trigger. + // Actually threshold=1 means the FIRST approval (the proposer's) should + // trigger execution when approve_withdrawal is called by anyone. + // Let's verify: propose creates 1 approval; approve by s1 → 2 ≥ 1 → executes. + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + // Proposal has 1 approval (proposer). Threshold is 1 → already met. + // approve_withdrawal by s1 would also execute (2 ≥ 1). + // But the real test is: does a second signer's approval execute? + let executed = s.client.approve_withdrawal(&pid, &s.signers[1]); + assert!(executed); + assert_eq!(s.client.balance_of(&s.merchant), 9_500); } #[test] -fn test_unauthorized_debit_attempt() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - let attacker = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); - - // Mock auth for attacker (not payout contract) - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &attacker, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "debit", - args: (merchant.clone(), 500i128).into_val(&env), - sub_invokes: &[], - }, - }]); - - // This should fail because attacker is not the payout contract +fn test_execution_fails_if_insufficient_balance() { + let s = Setup::new_2_of_3(); + // Propose more than the balance. + let pid = s.client.propose_withdrawal(&s.merchant, &99_999, &s.signers[0]); + let result = s.client.try_approve_withdrawal(&pid, &s.signers[1]); + assert_eq!(result, Err(Ok(Error::InsufficientBalance))); } #[test] -fn test_rapid_credit_debit_sequence() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); +fn test_approve_already_executed_proposal_fails() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &100, &s.signers[0]); + s.client.approve_withdrawal(&pid, &s.signers[1]); // executes - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); + let result = s.client.try_approve_withdrawal(&pid, &s.signers[2]); + assert_eq!(result, Err(Ok(Error::ProposalAlreadyExecuted))); +} - // Simulate rapid transactions - for i in 1..=100 { - client.credit(&merchant, &100); - assert_eq!(client.balance_of(&merchant), 100 * i); - } +// --------------------------------------------------------------------------- +// Proposal expiry +// --------------------------------------------------------------------------- - assert_eq!(client.balance_of(&merchant), 10000); +#[test] +fn test_approve_expired_proposal_fails() { + let s = Setup::new(2, 100); // expires after 100 ledgers + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - for i in 1..=100 { - client.debit(&merchant, &100); - assert_eq!(client.balance_of(&merchant), 10000 - (100 * i)); - } + // Advance ledger past expiry. + s.env.ledger().set_sequence_number( + s.env.ledger().sequence() + 101, + ); - assert_eq!(client.balance_of(&merchant), 0); + let result = s.client.try_approve_withdrawal(&pid, &s.signers[1]); + assert_eq!(result, Err(Ok(Error::ProposalExpired))); } #[test] -fn test_update_authorized_addresses() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let new_router = Address::generate(&env); - let new_payout = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - - // Update payment router - client.update_payment_router(&new_router); +fn test_approve_at_exact_expiry_boundary_fails() { + let s = Setup::new(2, 100); + let created = s.env.ledger().sequence(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - // Update payout contract - client.update_payout_contract(&new_payout); + // Advance to exactly created + expiry_ledgers + 1 (one past the boundary). + s.env.ledger().set_sequence_number(created + 101); - // Old addresses should no longer work (would fail auth in real scenario) - // New addresses should work (tested implicitly by auth system) + let result = s.client.try_approve_withdrawal(&pid, &s.signers[1]); + assert_eq!(result, Err(Ok(Error::ProposalExpired))); } #[test] -fn test_merchant_isolation() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant1 = Address::generate(&env); - let merchant2 = Address::generate(&env); - let merchant3 = Address::generate(&env); - - env.mock_all_auths(); +fn test_approve_just_before_expiry_succeeds() { + let s = Setup::new(2, 100); + let created = s.env.ledger().sequence(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant1); - client.init_merchant(&merchant2); - client.init_merchant(&merchant3); + // Advance to exactly the expiry ledger (not past it). + s.env.ledger().set_sequence_number(created + 100); - // Operations on different merchants - client.credit(&merchant1, &1000); - client.credit(&merchant2, &2000); - client.credit(&merchant3, &3000); + let executed = s.client.approve_withdrawal(&pid, &s.signers[1]); + assert!(executed); +} - client.debit(&merchant2, &500); +// --------------------------------------------------------------------------- +// cancel_proposal +// --------------------------------------------------------------------------- - // Verify isolation - assert_eq!(client.balance_of(&merchant1), 1000); - assert_eq!(client.balance_of(&merchant2), 1500); - assert_eq!(client.balance_of(&merchant3), 3000); +#[test] +fn test_proposer_can_cancel() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - // Deplete one merchant - client.debit(&merchant1, &1000); - assert_eq!(client.balance_of(&merchant1), 0); + s.client.cancel_proposal(&pid, &s.signers[0]); - // Others should be unaffected - assert_eq!(client.balance_of(&merchant2), 1500); - assert_eq!(client.balance_of(&merchant3), 3000); + let p = s.client.get_proposal(&pid); + assert!(p.cancelled); + assert!(!p.executed); } #[test] -fn test_operations_before_initialization() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let merchant = Address::generate(&env); +fn test_admin_can_cancel() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - env.mock_all_auths(); - - // Try operations before contract initialization - let result = client.try_init_merchant(&merchant); - assert_eq!(result, Err(Ok(Error::NotInitialized))); + s.client.cancel_proposal(&pid, &s.admin); - let result = client.try_balance_of(&merchant); - assert_eq!(result, Err(Ok(Error::MerchantNotInitialized))); + let p = s.client.get_proposal(&pid); + assert!(p.cancelled); } #[test] -fn test_concurrent_operations_same_merchant() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - env.mock_all_auths(); - - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Simulate interleaved operations - client.credit(&merchant, &100); - client.credit(&merchant, &200); - client.debit(&merchant, &50); - client.credit(&merchant, &300); - client.debit(&merchant, &150); +fn test_non_proposer_non_admin_cannot_cancel() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); - // Final balance should be: 0 + 100 + 200 - 50 + 300 - 150 = 400 - assert_eq!(client.balance_of(&merchant), 400); + // s1 is a signer but not the proposer or admin. + let result = s.client.try_cancel_proposal(&pid, &s.signers[1]); + assert_eq!(result, Err(Ok(Error::UnauthorizedCaller))); } #[test] -#[should_panic] -fn test_panic_on_credit_without_auth() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - // Initialize with proper auth - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Clear all auth mocks - env.mock_auths(&[]); +fn test_cancel_already_executed_fails() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &100, &s.signers[0]); + s.client.approve_withdrawal(&pid, &s.signers[1]); // executes - // Try to credit without any auth (should panic) - client.credit(&merchant, &1000); + let result = s.client.try_cancel_proposal(&pid, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::ProposalAlreadyExecuted))); } #[test] -#[should_panic] -fn test_panic_on_debit_without_auth() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - // Initialize with proper auth - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); +fn test_cancel_already_cancelled_fails() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + s.client.cancel_proposal(&pid, &s.signers[0]); - // Clear all auth mocks - env.mock_auths(&[]); - - // Try to debit without any auth (should panic) - client.debit(&merchant, &500); + let result = s.client.try_cancel_proposal(&pid, &s.signers[0]); + assert_eq!(result, Err(Ok(Error::ProposalAlreadyCancelled))); } #[test] -#[should_panic] -fn test_panic_on_init_merchant_without_admin_auth() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - - // Initialize contract - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - - // Clear auth - env.mock_auths(&[]); +fn test_approve_cancelled_proposal_fails() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + s.client.cancel_proposal(&pid, &s.signers[0]); - // Try to init merchant without admin auth (should panic) - client.init_merchant(&merchant); + let result = s.client.try_approve_withdrawal(&pid, &s.signers[1]); + assert_eq!(result, Err(Ok(Error::ProposalAlreadyCancelled))); } #[test] -#[should_panic] -fn test_panic_on_update_router_without_admin() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let new_router = Address::generate(&env); +fn test_cancel_does_not_affect_balance() { + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &5_000, &s.signers[0]); + s.client.cancel_proposal(&pid, &s.signers[0]); - // Initialize - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - - // Clear auth - env.mock_auths(&[]); - - // Try to update router without admin auth (should panic) - client.update_payment_router(&new_router); + // Balance must be unchanged. + assert_eq!(s.client.balance_of(&s.merchant), 10_000); } +// --------------------------------------------------------------------------- +// Multiple concurrent proposals +// --------------------------------------------------------------------------- + #[test] -#[should_panic] -fn test_panic_on_update_payout_without_admin() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); +fn test_multiple_proposals_independent() { + let s = Setup::new_2_of_3(); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let new_payout = Address::generate(&env); + let pid0 = s.client.propose_withdrawal(&s.merchant, &1_000, &s.signers[0]); + let pid1 = s.client.propose_withdrawal(&s.merchant, &2_000, &s.signers[1]); - // Initialize - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); + // Execute pid0. + s.client.approve_withdrawal(&pid0, &s.signers[1]); + assert_eq!(s.client.balance_of(&s.merchant), 9_000); - // Clear auth - env.mock_auths(&[]); + // pid1 still pending. + let p1 = s.client.get_proposal(&pid1); + assert!(!p1.executed); - // Try to update payout without admin auth (should panic) - client.update_payout_contract(&new_payout); + // Execute pid1. + s.client.approve_withdrawal(&pid1, &s.signers[0]); + assert_eq!(s.client.balance_of(&s.merchant), 7_000); } #[test] -#[should_panic] -fn test_panic_on_initialize_without_admin_auth() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); +fn test_cancel_one_does_not_affect_other() { + let s = Setup::new_2_of_3(); - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); + let pid0 = s.client.propose_withdrawal(&s.merchant, &1_000, &s.signers[0]); + let pid1 = s.client.propose_withdrawal(&s.merchant, &2_000, &s.signers[1]); - // Don't mock auth - try to initialize without admin signature - env.mock_auths(&[]); + s.client.cancel_proposal(&pid0, &s.signers[0]); - // Should panic due to missing admin auth - client.initialize(&admin, &payment_router, &payout_contract); + // pid1 can still be executed. + s.client.approve_withdrawal(&pid1, &s.signers[0]); + assert_eq!(s.client.balance_of(&s.merchant), 8_000); } -#[test] -#[should_panic] -fn test_panic_on_credit_wrong_caller() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - let wrong_caller = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - - // Mock auth for wrong caller instead of payment_router - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &wrong_caller, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "credit", - args: (merchant.clone(), 1000i128).into_val(&env), - sub_invokes: &[], - }, - }]); +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- - // Should panic - wrong caller - client.credit(&merchant, &1000); +#[test] +fn test_propose_emits_proposed_event() { + use soroban_sdk::{testutils::Events, TryFromVal, Symbol}; + + let s = Setup::new_2_of_3(); + s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + + let events = s.env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.len() != 2 { return false; } + let t0 = >::try_from_val(&s.env, &topics.get(0).unwrap()); + let t1 = >::try_from_val(&s.env, &topics.get(1).unwrap()); + matches!((t0, t1), (Ok(a), Ok(b)) + if a == symbol_short!("multisig") && b == symbol_short!("proposed")) + }); + assert!(found, "expected (multisig, proposed) event"); } #[test] -#[should_panic] -fn test_panic_on_debit_wrong_caller() { - let env = Env::default(); - let contract_id = env.register_contract(None, MerchantVault); - let client = MerchantVaultClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let payment_router = Address::generate(&env); - let payout_contract = Address::generate(&env); - let merchant = Address::generate(&env); - let wrong_caller = Address::generate(&env); +fn test_execute_emits_executed_event() { + use soroban_sdk::{testutils::Events, TryFromVal, Symbol}; + + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + s.client.approve_withdrawal(&pid, &s.signers[1]); + + let events = s.env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.len() != 2 { return false; } + let t0 = >::try_from_val(&s.env, &topics.get(0).unwrap()); + let t1 = >::try_from_val(&s.env, &topics.get(1).unwrap()); + matches!((t0, t1), (Ok(a), Ok(b)) + if a == symbol_short!("multisig") && b == symbol_short!("executed")) + }); + assert!(found, "expected (multisig, executed) event"); +} - env.mock_all_auths(); - client.initialize(&admin, &payment_router, &payout_contract); - client.init_merchant(&merchant); - client.credit(&merchant, &1000); - - // Mock auth for wrong caller instead of payout_contract - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &wrong_caller, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "debit", - args: (merchant.clone(), 500i128).into_val(&env), - sub_invokes: &[], - }, - }]); - - // Should panic - wrong caller - client.debit(&merchant, &500); +#[test] +fn test_cancel_emits_cancelled_event() { + use soroban_sdk::{testutils::Events, TryFromVal, Symbol}; + + let s = Setup::new_2_of_3(); + let pid = s.client.propose_withdrawal(&s.merchant, &500, &s.signers[0]); + s.client.cancel_proposal(&pid, &s.signers[0]); + + let events = s.env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.len() != 2 { return false; } + let t0 = >::try_from_val(&s.env, &topics.get(0).unwrap()); + let t1 = >::try_from_val(&s.env, &topics.get(1).unwrap()); + matches!((t0, t1), (Ok(a), Ok(b)) + if a == symbol_short!("multisig") && b == symbol_short!("cancelled")) + }); + assert!(found, "expected (multisig, cancelled) event"); }