From 01d970e278c5820daf10dfd5c5abab911f89bcdd Mon Sep 17 00:00:00 2001 From: Nwafor Nnaemeka <100703585+Emeka-12@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:20:42 +0100 Subject: [PATCH] fix(lp): validate fund_loan recipients and cap outflows & merchant exposure (#106) --- Cargo.lock | 1 + context/progress-tracker.md | 13 + contracts/creditline-contract/src/tests.rs | 4 +- contracts/liquidity-pool-contract/Cargo.toml | 1 + .../liquidity-pool-contract/src/errors.rs | 4 + .../liquidity-pool-contract/src/events.rs | 39 ++- contracts/liquidity-pool-contract/src/lib.rs | 156 ++++++++- .../liquidity-pool-contract/src/storage.rs | 94 +++++ .../liquidity-pool-contract/src/tests.rs | 324 +++++++++++++++++- .../liquidity-pool-contract/src/types.rs | 14 + scripts/deploy-testnet.sh | 22 +- 11 files changed, 658 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75b1290..6126ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -814,6 +814,7 @@ name = "liquidity-pool-contract" version = "1.0.0" dependencies = [ "soroban-sdk", + "vendor-registry-contract", ] [[package]] diff --git a/context/progress-tracker.md b/context/progress-tracker.md index e20f7e6..9748771 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -16,6 +16,19 @@ Update this file after every completed contract change, fix, or architectural de ## Completed +### Issue #106 — LP `fund_loan` Recipient Validation & Drain Caps +- **Problem:** `fund_loan()` performed no recipient cross-check once the creditline auth passed (any address was payable) and had no caps on how much liquidity a misbehaving creditline could drain per ledger or concentrate on a single merchant. +- **Fix (`liquidity-pool-contract`):** + - **Optional vendor cross-check:** `initialize()` now accepts an optional `vendor_registry` address. When set, `fund_loan` requires the recipient to be an active (approved) vendor via the registry's `is_active()`; the check is fail-closed (any invocation error blocks funding) and fully skipped when no registry is configured (legacy behavior preserved). Configurable post-init via admin-only `set_vendor_registry` (`None` clears it). + - **Per-ledger outflow cap:** admin-only `set_outflow_cap_bps` caps cumulative `fund_loan` outflows within a single ledger to N basis points of available liquidity (`0` = disabled, `1..=10_000`). The window is a rolling `(ledger_sequence, used)` pair — entering a new ledger sequence resets the used counter automatically. + - **Single-recipient exposure cap:** admin-only `set_merchant_exposure_cap` caps cumulative funding per merchant (`0` = disabled). Cumulative exposure is tracked in persistent storage (with TTL extension) regardless of whether the cap is enabled, so admins can monitor via `get_merchant_funded` before enabling a bound. + - New errors: `OutflowCapExceeded` (#16), `MerchantExposureCapExceeded` (#17), `VendorNotActive` (#18), `InvalidCap` (#19). + - `LOAN_FUNDED` event now carries `(merchant, amount, outflow_remaining, merchant_remaining)`; new `CAPSUPD` and `VREGUPD` events on cap/registry changes for indexer monitoring. + - New queries: `get_outflow_cap_bps`, `get_merchant_exposure_cap`, `get_vendor_registry`, `get_merchant_funded`. + - Added 16 unit tests: default-disabled backward compatibility, invalid/unauthorized cap setters, within-ledger cap enforcement + rolling window reset, per-recipient exposure independence, exposure accounting with caps disabled, unregistered/suspended/approved merchant flows, clearing the registry restores legacy behavior, and event payload assertion. + - Updated creditline integration tests for the new `initialize` signature and `scripts/deploy-testnet.sh` (passes `--vendor_registry`, plus opt-in `OUTFLOW_CAP_BPS` / `MERCHANT_EXPOSURE_CAP` env-gated caps). +- **Tests:** `cargo test --locked` → 401 passing (125 liquidity-pool, 143 creditline, 20 parameters, 60 reputation, 26 vendor-registry, 27 vouching). + ### Security: Close Unauthenticated Admin-Claim Branch in Reputation `set_admin()` - **Problem:** `set_admin()` in `contracts/reputation-contract/src/lib.rs` contained an unauthenticated fallback branch when no admin was stored, allowing anyone to claim admin of the reputation contract without authorization. - **Fix (`reputation-contract`):** diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index 32b6631..bdd8489 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -2562,7 +2562,7 @@ impl RealIntegrationCtx { reputation.set_updater(&admin, &creditline_id, &true); vendor_registry.initialize(&admin); - pool.initialize(&admin, &token_address, &treasury, &vendor_fund); + pool.initialize(&admin, &token_address, &treasury, &vendor_fund, &None); pool.set_creditline(&admin, &creditline_id); parameters.initialize_defaults(&admin); @@ -4045,7 +4045,7 @@ fn test_mark_defaulted_loss_absorption_share_price_impact() { let treasury = Address::generate(&env); let merchant_fund = Address::generate(&env); - lp_client.initialize(&admin, &token_id, &treasury, &merchant_fund); + lp_client.initialize(&admin, &token_id, &treasury, &merchant_fund, &None); // Register reputation mock let rep_id = env.register(MockReputation, ()); diff --git a/contracts/liquidity-pool-contract/Cargo.toml b/contracts/liquidity-pool-contract/Cargo.toml index 2880292..142f981 100644 --- a/contracts/liquidity-pool-contract/Cargo.toml +++ b/contracts/liquidity-pool-contract/Cargo.toml @@ -11,4 +11,5 @@ soroban-sdk = "22.0.0" [dev-dependencies] soroban-sdk = { version = "22.0.0", features = ["testutils"] } +vendor-registry-contract = { path = "../vendor-registry-contract" } diff --git a/contracts/liquidity-pool-contract/src/errors.rs b/contracts/liquidity-pool-contract/src/errors.rs index d3b9f86..90b79a4 100644 --- a/contracts/liquidity-pool-contract/src/errors.rs +++ b/contracts/liquidity-pool-contract/src/errors.rs @@ -19,4 +19,8 @@ pub enum LiquidityPoolError { UpgradeTimelockNotMet = 13, UpgradeHashMismatch = 14, ContractPaused = 15, + OutflowCapExceeded = 16, + MerchantExposureCapExceeded = 17, + VendorNotActive = 18, + InvalidCap = 19, } diff --git a/contracts/liquidity-pool-contract/src/events.rs b/contracts/liquidity-pool-contract/src/events.rs index c00c4d7..3a912e2 100644 --- a/contracts/liquidity-pool-contract/src/events.rs +++ b/contracts/liquidity-pool-contract/src/events.rs @@ -25,9 +25,26 @@ pub fn emit_liquidity_withdrawn( .publish((WITHDRAWN, provider), (shares_burned, amount_returned)); } -/// Emitted when the pool funds a loan (CreditLine → merchant) -pub fn emit_loan_funded(env: &Env, creditline: &Address, amount: i128) { - env.events().publish((LOAN_FUNDED, creditline), amount); +/// Emitted when the pool funds a loan (CreditLine → merchant). +/// Payload carries the recipient, the funded amount, and the remaining +/// per-ledger outflow and per-merchant exposure caps for indexer monitoring. +pub fn emit_loan_funded( + env: &Env, + creditline: &Address, + merchant: &Address, + amount: i128, + outflow_remaining: i128, + merchant_remaining: i128, +) { + env.events().publish( + (LOAN_FUNDED, creditline), + ( + merchant.clone(), + amount, + outflow_remaining, + merchant_remaining, + ), + ); } /// Emitted when principal + interest repayment is received from CreditLine @@ -92,3 +109,19 @@ pub fn emit_unpaused(env: &Env, admin: &Address) { env.ledger().timestamp(), ); } + +const CAPS_UPDATED: Symbol = symbol_short!("CAPSUPD"); +const VREG_UPDATED: Symbol = symbol_short!("VREGUPD"); + +/// Emitted whenever the admin changes the outflow or exposure caps. +/// Carries the new `(outflow_cap_bps, exposure_cap)` for indexers. +pub fn emit_caps_updated(env: &Env, admin: &Address, outflow_cap_bps: u32, exposure_cap: i128) { + env.events() + .publish((CAPS_UPDATED, admin), (outflow_cap_bps, exposure_cap)); +} + +/// Emitted whenever the admin sets or clears the vendor-registry address. +pub fn emit_vendor_registry_updated(env: &Env, admin: &Address, registry: &Option
) { + env.events() + .publish((VREG_UPDATED, admin), (registry.clone(),)); +} diff --git a/contracts/liquidity-pool-contract/src/lib.rs b/contracts/liquidity-pool-contract/src/lib.rs index fc7b923..184ea32 100644 --- a/contracts/liquidity-pool-contract/src/lib.rs +++ b/contracts/liquidity-pool-contract/src/lib.rs @@ -21,16 +21,21 @@ impl LiquidityPoolContract { /// Initialize the contract. Can only be called once. /// - /// * `admin` ΓÇô Contract administrator (can update addresses) - /// * `token` ΓÇô SEP-41 token used by the pool (e.g. USDC) - /// * `treasury` ΓÇô Address that receives the 10% protocol fee - /// * `merchant_fund`ΓÇô Address that receives the 5% merchant incentive fee + /// * `admin` ΓÇô Contract administrator (can update addresses/caps) + /// * `token` ΓÇô SEP-41 token used by the pool (e.g. USDC) + /// * `treasury` ΓÇô Address that receives the 10% protocol fee + /// * `merchant_fund` ΓÇô Address that receives the 5% merchant incentive fee + /// * `vendor_registry`ΓÇô Optional registered vendor-registry contract. When + /// set, `fund_loan` requires the recipient merchant to be an active + /// (approved) vendor before transferring. When `None`, the check is + /// skipped (backward compatible). pub fn initialize( env: Env, admin: Address, token: Address, treasury: Address, merchant_fund: Address, + vendor_registry: Option
, ) { if storage::has_admin(&env) { panic_with_error!(&env, LiquidityPoolError::AlreadyInitialized); @@ -41,6 +46,7 @@ impl LiquidityPoolContract { storage::set_token(&env, &token); storage::set_treasury(&env, &treasury); storage::set_merchant_fund(&env, &merchant_fund); + storage::set_vendor_registry(&env, &vendor_registry); } // ------------------------------------------------------------------------- @@ -53,6 +59,41 @@ impl LiquidityPoolContract { storage::set_creditline(&env, &creditline); } + /// Configure the per-ledger outflow cap (basis points of available liquidity). + /// `0` = cap disabled (only the available-liquidity check applies); + /// `1..=10_000` caps cumulative `fund_loan` outflows within a single ledger + /// to that fraction of available liquidity. Admin only. + pub fn set_outflow_cap_bps(env: Env, admin: Address, bps: u32) { + admin.require_auth(); + Self::require_admin(&env, &admin); + if bps > 10_000 { + panic_with_error!(&env, LiquidityPoolError::InvalidCap); + } + storage::set_outflow_cap_bps(&env, bps); + events::emit_caps_updated(&env, &admin, bps, storage::get_merchant_exposure_cap(&env)); + } + + /// Configure the cumulative single-recipient exposure ceiling (token units). + /// `0` = cap disabled. Admin only. + pub fn set_merchant_exposure_cap(env: Env, admin: Address, amount: i128) { + admin.require_auth(); + Self::require_admin(&env, &admin); + if amount < 0 { + panic_with_error!(&env, LiquidityPoolError::InvalidCap); + } + storage::set_merchant_exposure_cap(&env, amount); + events::emit_caps_updated(&env, &admin, storage::get_outflow_cap_bps(&env), amount); + } + + /// Set or clear (`None`) the vendor-registry contract used to cross-check + /// recipients before funding. Admin only. + pub fn set_vendor_registry(env: Env, admin: Address, registry: Option
) { + admin.require_auth(); + Self::require_admin(&env, &admin); + storage::set_vendor_registry(&env, ®istry); + events::emit_vendor_registry_updated(&env, &admin, ®istry); + } + pub fn set_treasury(env: Env, admin: Address, treasury: Address) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -311,6 +352,17 @@ impl LiquidityPoolContract { /// Transfer `amount` tokens to `merchant` to fund a loan. /// Only the registered CreditLine contract may call this. + /// + /// Defense-in-depth guards (layered on top of the existing + /// `require_creditline` restriction, which is kept): + /// + /// 1. Optional vendor cross-check: if a vendor registry is configured, + /// `merchant` must be an active (approved) vendor. + /// 2. Per-ledger outflow cap: cumulative `fund_loan` outflows within the + /// current ledger are bounded to a configurable fraction of available + /// liquidity. The window rolls automatically on ledger change. + /// 3. Single-recipient concentration cap: cumulative funding to a single + /// merchant is bounded by a configurable ceiling. pub fn fund_loan( env: Env, creditline: Address, @@ -325,6 +377,15 @@ impl LiquidityPoolContract { return Err(LiquidityPoolError::InvalidAmount); } + // Optional vendor cross-check (skipped entirely when no registry set). + if let Some(registry) = storage::get_vendor_registry(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) + { + if !Self::vendor_is_active(&env, ®istry, &merchant) { + return Err(LiquidityPoolError::VendorNotActive); + } + } + Self::enter_non_reentrant(&env); let total_liquidity = @@ -337,6 +398,45 @@ impl LiquidityPoolContract { return Err(LiquidityPoolError::InsufficientLiquidity); } + // Per-ledger outflow cap with rolling window reset. Disabled when the + // configured cap is zero. The window is keyed to the current ledger + // sequence: entering a new ledger resets the used counter. + let mut outflow_remaining = 0_i128; + let outflow_cap_bps = storage::get_outflow_cap_bps(&env); + if outflow_cap_bps > 0 { + let outflow_cap = safe_math::div_i128( + safe_math::mul_i128(available, outflow_cap_bps as i128)?, + types::TOTAL_BPS, + )?; + let current_seq = env.ledger().sequence(); + let (window_seq, mut used) = storage::get_outflow_window(&env); + if window_seq != current_seq { + used = 0; + } + let new_used = safe_math::add_i128(used, amount)?; + if new_used > outflow_cap { + return Err(LiquidityPoolError::OutflowCapExceeded); + } + storage::set_outflow_window(&env, current_seq, new_used); + outflow_remaining = safe_math::sub_i128(outflow_cap, new_used)?; + } + + // Single-recipient concentration cap (cumulative, never reset). + // Disabled when the configured ceiling is zero. Cumulative exposure is + // tracked regardless so admins can monitor it via `get_merchant_funded`. + let mut merchant_remaining = 0_i128; + let exposure_cap = storage::get_merchant_exposure_cap(&env); + let funded_so_far = storage::get_merchant_funded(&env, &merchant) + .unwrap_or_else(|err| panic_with_error!(&env, err)); + let new_funded = safe_math::add_i128(funded_so_far, amount)?; + if exposure_cap > 0 { + if new_funded > exposure_cap { + return Err(LiquidityPoolError::MerchantExposureCapExceeded); + } + merchant_remaining = safe_math::sub_i128(exposure_cap, new_funded)?; + } + storage::set_merchant_funded(&env, &merchant, new_funded); + let new_locked = safe_math::add_i128(locked_liquidity, amount)?; storage::set_locked_liquidity(&env, new_locked); @@ -345,7 +445,14 @@ impl LiquidityPoolContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &merchant, &amount); - events::emit_loan_funded(&env, &creditline, amount); + events::emit_loan_funded( + &env, + &creditline, + &merchant, + amount, + outflow_remaining, + merchant_remaining, + ); Self::exit_non_reentrant(&env); Ok(()) } @@ -657,6 +764,28 @@ impl LiquidityPoolContract { storage::get_lp_shares(&env, &provider).unwrap_or_else(|err| panic_with_error!(&env, err)) } + /// Return the configured per-ledger outflow cap (basis points of available + /// liquidity; 10000 = 100%). + pub fn get_outflow_cap_bps(env: Env) -> u32 { + storage::get_outflow_cap_bps(&env) + } + + /// Return the configured cumulative single-recipient exposure ceiling. + pub fn get_merchant_exposure_cap(env: Env) -> i128 { + storage::get_merchant_exposure_cap(&env) + } + + /// Return the optional vendor-registry contract used for recipient checks. + pub fn get_vendor_registry(env: Env) -> Option
{ + storage::get_vendor_registry(&env).unwrap_or_else(|err| panic_with_error!(&env, err)) + } + + /// Return the cumulative amount funded to a single merchant. + pub fn get_merchant_funded(env: Env, merchant: Address) -> i128 { + storage::get_merchant_funded(&env, &merchant) + .unwrap_or_else(|err| panic_with_error!(&env, err)) + } + /// Calculate how many tokens `shares` are worth at the current share price. pub fn calculate_withdrawal(env: Env, shares: i128) -> i128 { if shares == 0 { @@ -718,6 +847,23 @@ impl LiquidityPoolContract { } } + /// Cross-check a recipient against the configured vendor registry's + /// `is_active(merchant)`. Any invocation failure resolves to `false` + /// (fail-closed): a broken, suspended, or uninitialized registry blocks + /// funding rather than allowing unvalidated payouts. + fn vendor_is_active(env: &Env, registry: &Address, merchant: &Address) -> bool { + use soroban_sdk::IntoVal; + if let Ok(Ok(active)) = env.try_invoke_contract::( + registry, + &soroban_sdk::Symbol::new(env, "is_active"), + (merchant.clone(),).into_val(env), + ) { + active + } else { + false + } + } + fn require_creditline(env: &Env, caller: &Address) { let creditline = storage::get_creditline(env) .unwrap_or_else(|err| panic_with_error!(env, err)) diff --git a/contracts/liquidity-pool-contract/src/storage.rs b/contracts/liquidity-pool-contract/src/storage.rs index eab568f..c6844bd 100644 --- a/contracts/liquidity-pool-contract/src/storage.rs +++ b/contracts/liquidity-pool-contract/src/storage.rs @@ -197,3 +197,97 @@ pub fn is_paused(env: &Env) -> bool { pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&PAUSED_KEY, &paused); } + +// --- Per-Ledger Outflow Cap (instance) --- +pub const OUTFLOW_CAP_BPS_KEY: Symbol = symbol_short!("OUTFLOW"); + +/// Get the per-ledger outflow cap expressed in basis points of available +/// liquidity (10000 = 100%). Falls back to `DEFAULT_OUTFLOW_CAP_BPS`. +pub fn get_outflow_cap_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&OUTFLOW_CAP_BPS_KEY) + .unwrap_or(crate::types::DEFAULT_OUTFLOW_CAP_BPS) +} + +pub fn set_outflow_cap_bps(env: &Env, bps: u32) { + env.storage().instance().set(&OUTFLOW_CAP_BPS_KEY, &bps); +} + +// --- Single-Recipient Exposure Cap (instance) --- +pub const MERCHANT_EXPOSURE_CAP_KEY: Symbol = symbol_short!("MCRCAP"); + +/// Get the cumulative exposure ceiling (token units) for a single recipient. +/// Falls back to `DEFAULT_MERCHANT_EXPOSURE_CAP`. +pub fn get_merchant_exposure_cap(env: &Env) -> i128 { + env.storage() + .instance() + .get(&MERCHANT_EXPOSURE_CAP_KEY) + .unwrap_or(crate::types::DEFAULT_MERCHANT_EXPOSURE_CAP) +} + +pub fn set_merchant_exposure_cap(env: &Env, amount: i128) { + env.storage().instance().set(&MERCHANT_EXPOSURE_CAP_KEY, &amount); +} + +// --- Optional Vendor Registry (instance) --- +pub const VENDOR_REGISTRY_KEY: Symbol = symbol_short!("VREGD"); + +/// Get the optional vendor-registry address. When `None`, `fund_loan` performs +/// no vendor cross-check (legacy behavior preserved). A missing key is treated +/// as `None` — the key is only ever present when holding a real address, so +/// reads never hit a stored `void` value. +pub fn get_vendor_registry(env: &Env) -> Result, LiquidityPoolError> { + Ok(env.storage().instance().get(&VENDOR_REGISTRY_KEY)) +} + +pub fn set_vendor_registry(env: &Env, registry: &Option
) { + match registry { + Some(addr) => env.storage().instance().set(&VENDOR_REGISTRY_KEY, addr), + None => env.storage().instance().remove(&VENDOR_REGISTRY_KEY), + } +} + +// --- Rolling Outflow Window (instance) --- +pub const OUTFLOW_SEQ_KEY: Symbol = symbol_short!("LEDSEQ"); +pub const OUTFLOW_USED_KEY: Symbol = symbol_short!("LEDOUT"); + +/// Read the current outflow window as `(ledger_sequence, amount_already_used)`. +/// Persisting the ledger sequence is what makes the window "rolling": the next +/// `fund_loan` on a new ledger sequence resets the used counter to zero. +pub fn get_outflow_window(env: &Env) -> (u32, i128) { + let seq: u32 = env.storage().instance().get(&OUTFLOW_SEQ_KEY).unwrap_or(0); + let used: i128 = env + .storage() + .instance() + .get(&OUTFLOW_USED_KEY) + .unwrap_or(0); + (seq, used) +} + +pub fn set_outflow_window(env: &Env, ledger_seq: u32, used: i128) { + env.storage().instance().set(&OUTFLOW_SEQ_KEY, &ledger_seq); + env.storage().instance().set(&OUTFLOW_USED_KEY, &used); +} + +// --- Per-Merchant Cumulative Exposure (persistent) --- +pub const MERCHANT_FUNDED_PREFIX: Symbol = symbol_short!("MERCHEX"); + +/// Get the cumulative amount the pool has funded to a single merchant. +pub fn get_merchant_funded(env: &Env, merchant: &Address) -> Result { + Ok(env + .storage() + .persistent() + .get(&(MERCHANT_FUNDED_PREFIX, merchant.clone())) + .unwrap_or(0)) +} + +/// Set the cumulative amount funded to a merchant, extending TTL per the +/// persistent-storage rule. +pub fn set_merchant_funded(env: &Env, merchant: &Address, amount: i128) { + let key = (MERCHANT_FUNDED_PREFIX, merchant.clone()); + env.storage().persistent().set(&key, &amount); + env.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); +} diff --git a/contracts/liquidity-pool-contract/src/tests.rs b/contracts/liquidity-pool-contract/src/tests.rs index ec9d765..77dcc4c 100644 --- a/contracts/liquidity-pool-contract/src/tests.rs +++ b/contracts/liquidity-pool-contract/src/tests.rs @@ -4,6 +4,7 @@ use soroban_sdk::{ token::{Client as TokenClient, StellarAssetClient}, Address, Env, IntoVal, }; +use vendor_registry_contract::{VendorRegistryContract, VendorRegistryContractClient}; // ΓöÇΓöÇΓöÇ helpers ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ @@ -45,8 +46,8 @@ impl TestEnv { let merchant_fund = Address::generate(&env); let creditline = Address::generate(&env); - // Initialize pool - client.initialize(&admin, &token_address, &treasury, &merchant_fund); + // Initialize pool (no vendor registry configured by default) + client.initialize(&admin, &token_address, &treasury, &merchant_fund, &None); client.set_creditline(&admin, &creditline); // Mint tokens into some standard accounts for tests to use @@ -102,6 +103,7 @@ fn test_initialize_twice_fails() { &t.token.address, &t.treasury, &t.merchant_fund, + &None, ); } @@ -3045,3 +3047,321 @@ fn test_paused_state_blocks_mutating_functions_and_allows_repayment_and_queries( assert_eq!(t.client.is_paused(), false); assert!(t.client.deposit(&provider, &1_000) > 0); } + +// ΓöÇΓöÇΓöÇ issue #106: per-ledger outflow cap, merchant exposure cap, vendor validation +// ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ + +/// Deploy a real VendorRegistryContract, initialize it with `admin`, and wire +/// it into the liquidity pool. Returns the registry client for vendor ops. +fn setup_vendor_registry(t: &TestEnv) -> VendorRegistryContractClient { + let registry_id = t.env.register(VendorRegistryContract, ()); + let registry = VendorRegistryContractClient::new(&t.env, ®istry_id); + registry.initialize(&t.admin); + t.client.set_vendor_registry(&t.admin, &Some(registry_id)); + registry +} + +fn approve_vendor(registry: &VendorRegistryContractClient, admin: &Address, vendor: &Address) { + let name = soroban_sdk::String::from_str(®istry.env, "Test Vendor"); + registry.register_vendor(admin, vendor, &name); + registry.approve_vendor(admin, vendor); +} + +// ΓöÇΓöÇΓöÇ per-ledger outflow cap ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ + +#[test] +fn test_outflow_cap_disabled_by_default() { + // Backward compatibility: caps default to disabled, so honest single-loop + // funding is unaffected by the new guards. + let t = TestEnv::setup(); + assert_eq!(t.client.get_outflow_cap_bps(), 0); + assert_eq!(t.client.get_merchant_exposure_cap(), 0); + + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 1_000); + t.client.deposit(&provider, &1_000); + + // Multiple fund_loan calls in the same ledger still succeed (legacy). + t.client.fund_loan(&t.creditline, &merchant, &400); + t.client.fund_loan(&t.creditline, &merchant, &400); + assert_eq!(t.client.get_pool_stats().locked_liquidity, 800); +} + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] +fn test_set_outflow_cap_invalid_bps_fails() { + let t = TestEnv::setup(); + t.client.set_outflow_cap_bps(&t.admin, &10_001); +} + +#[test] +fn test_set_outflow_cap_by_non_admin_fails() { + let t = TestEnv::setup(); + let non_admin = Address::generate(&t.env); + let expected_err = + soroban_sdk::Error::from_contract_error(LiquidityPoolError::NotAdmin as u32); + assert_eq!( + t.client.try_set_outflow_cap_bps(&non_admin, &5_000), + Err(Ok(expected_err)) + ); + assert_eq!(t.client.get_outflow_cap_bps(), 0); +} + +#[test] +fn test_outflow_cap_enforced_within_ledger() { + let t = TestEnv::setup(); + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + let merchant2 = Address::generate(&t.env); + t.mint(&provider, 1_000); + t.client.deposit(&provider, &1_000); + + // 5000 BPS = 50% of available liquidity per ledger. + t.client.set_outflow_cap_bps(&t.admin, &5_000); + assert_eq!(t.client.get_outflow_cap_bps(), 5_000); + + // Exactly at the computed cap (50% of 1000 available = 500) is allowed. + t.client.fund_loan(&t.creditline, &merchant, &500); + assert_eq!(t.client.get_pool_stats().locked_liquidity, 500); + + // A second funding in the same ledger pushes used beyond the cap. + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant2, &200), + Err(Ok(LiquidityPoolError::OutflowCapExceeded)) + ); +} + +#[test] +fn test_outflow_cap_window_resets_on_new_ledger() { + let t = TestEnv::setup(); + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 1_000); + t.client.deposit(&provider, &1_000); + + t.client.set_outflow_cap_bps(&t.admin, &5_000); + + // Ledger N: fund 500 (= cap), fills the window. + let ledger_seq = t.env.ledger().sequence(); + t.client.fund_loan(&t.creditline, &merchant, &500); + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant, &200), + Err(Ok(LiquidityPoolError::OutflowCapExceeded)) + ); + + // Ledger N+10: the window rolls — the used counter resets. available is + // now 500, so the new cap is 250 (50%). + t.env.ledger().set_sequence_number(ledger_seq + 10); + t.client.fund_loan(&t.creditline, &merchant, &200); + assert_eq!(t.client.get_pool_stats().locked_liquidity, 700); + + // And the fresh window is enforced again. + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant, &200), + Err(Ok(LiquidityPoolError::OutflowCapExceeded)) + ); +} + +// ΓöÇΓöÇΓöÇ single-recipient (merchant) exposure cap ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] +fn test_set_merchant_exposure_cap_negative_fails() { + let t = TestEnv::setup(); + t.client.set_merchant_exposure_cap(&t.admin, &-1); +} + +#[test] +fn test_set_merchant_exposure_cap_by_non_admin_fails() { + let t = TestEnv::setup(); + let non_admin = Address::generate(&t.env); + let expected_err = + soroban_sdk::Error::from_contract_error(LiquidityPoolError::NotAdmin as u32); + assert_eq!( + t.client.try_set_merchant_exposure_cap(&non_admin, &5_000), + Err(Ok(expected_err)) + ); +} + +#[test] +fn test_merchant_exposure_cap_enforced_per_recipient() { + let t = TestEnv::setup(); + let provider = Address::generate(&t.env); + let merchant_a = Address::generate(&t.env); + let merchant_b = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + t.client.set_merchant_exposure_cap(&t.admin, &2_500); + + t.client.fund_loan(&t.creditline, &merchant_a, &2_000); + assert_eq!(t.client.get_merchant_funded(&merchant_a), 2_000); + + // Cumulative exposure to A exceeds the ceiling. + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant_a, &600), + Err(Ok(LiquidityPoolError::MerchantExposureCapExceeded)) + ); + + // A different recipient has its own independent ceiling. + t.client.fund_loan(&t.creditline, &merchant_b, &2_000); + assert_eq!(t.client.get_merchant_funded(&merchant_a), 2_000); + assert_eq!(t.client.get_merchant_funded(&merchant_b), 2_000); + + // The rejected call was reverted — locked only reflects the two successes. + assert_eq!(t.client.get_pool_stats().locked_liquidity, 4_000); +} + +#[test] +fn test_merchant_exposure_tracked_even_when_cap_disabled() { + // Exposure accounting is kept regardless so admins can monitor `get_merchant_funded` + // before choosing to enable the cap. + let t = TestEnv::setup(); + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + assert_eq!(t.client.get_merchant_exposure_cap(), 0); + t.client.fund_loan(&t.creditline, &merchant, &1_000); + t.client.fund_loan(&t.creditline, &merchant, &500); + assert_eq!(t.client.get_merchant_funded(&merchant), 1_500); +} + +// ΓöÇΓöÇΓöÇ optional vendor-registry cross-check ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ + +#[test] +fn test_vendor_registry_none_by_default() { + let t = TestEnv::setup(); + assert!(t.client.get_vendor_registry().is_none()); + + // Arbitrary recipient is payable while no registry is configured. + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 1_000); + t.client.deposit(&provider, &1_000); + t.client.fund_loan(&t.creditline, &merchant, &100); + assert_eq!(t.client.get_merchant_funded(&merchant), 100); +} + +#[test] +fn test_vendor_registry_blocks_unregistered_merchant() { + let t = TestEnv::setup(); + let registry = setup_vendor_registry(&t); + assert_eq!(t.client.get_vendor_registry(), Some(registry.address.clone())); + + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + // Merchant was never registered/approved → funding is rejected. + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant, &500), + Err(Ok(LiquidityPoolError::VendorNotActive)) + ); +} + +#[test] +fn test_vendor_registry_allows_approved_merchant() { + let t = TestEnv::setup(); + let registry = setup_vendor_registry(&t); + + let merchant = Address::generate(&t.env); + approve_vendor(®istry, &t.admin, &merchant); + assert!(registry.is_active(&merchant)); + + let provider = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + t.client.fund_loan(&t.creditline, &merchant, &500); + assert_eq!(t.client.get_merchant_funded(&merchant), 500); +} + +#[test] +fn test_vendor_registry_blocks_suspended_merchant() { + let t = TestEnv::setup(); + let registry = setup_vendor_registry(&t); + + let merchant = Address::generate(&t.env); + approve_vendor(®istry, &t.admin, &merchant); + registry.suspend_vendor(&t.admin, &merchant); + assert!(!registry.is_active(&merchant)); + + let provider = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant, &500), + Err(Ok(LiquidityPoolError::VendorNotActive)) + ); +} + +#[test] +fn test_vendor_registry_cleared_restores_legacy_behavior() { + let t = TestEnv::setup(); + let registry = setup_vendor_registry(&t); + + let merchant = Address::generate(&t.env); + let provider = Address::generate(&t.env); + t.mint(&provider, 10_000); + t.client.deposit(&provider, &10_000); + + // Blocked while registry configured. + assert_eq!( + t.client.try_fund_loan(&t.creditline, &merchant, &500), + Err(Ok(LiquidityPoolError::VendorNotActive)) + ); + + // Admin clears the registry → unvalidated recipients payable again. + t.client.set_vendor_registry(&t.admin, &None); + assert!(t.client.get_vendor_registry().is_none()); + t.client.fund_loan(&t.creditline, &merchant, &500); + assert_eq!(t.client.get_merchant_funded(&merchant), 500); +} + +#[test] +fn test_set_vendor_registry_by_non_admin_fails() { + let t = TestEnv::setup(); + let non_admin = Address::generate(&t.env); + let registry_id = t.env.register(VendorRegistryContract, ()); + let expected_err = + soroban_sdk::Error::from_contract_error(LiquidityPoolError::NotAdmin as u32); + assert_eq!( + t.client.try_set_vendor_registry(&non_admin, &Some(registry_id)), + Err(Ok(expected_err)) + ); +} + +#[test] +fn test_loan_funded_event_carries_recipient_and_remaining_caps() { + let t = TestEnv::setup(); + let provider = Address::generate(&t.env); + let merchant = Address::generate(&t.env); + t.mint(&provider, 1_000); + t.client.deposit(&provider, &1_000); + + t.client.set_outflow_cap_bps(&t.admin, &5_000); + t.client.set_merchant_exposure_cap(&t.admin, &2_000); + + t.client.fund_loan(&t.creditline, &merchant, &300); + + let events: soroban_sdk::Vec<( + soroban_sdk::Address, + soroban_sdk::Vec, + soroban_sdk::Val, + )> = t.env.events().all(); + let expected_sym = soroban_sdk::Symbol::new(&t.env, "LQFUND"); + let mut found = false; + for e in events.iter() { + let topic: soroban_sdk::Symbol = e.1.get_unchecked(0).into_val(&t.env); + if topic == expected_sym { + found = true; + break; + } + } + assert!(found, "LQFUND event with remaining caps not found"); +} diff --git a/contracts/liquidity-pool-contract/src/types.rs b/contracts/liquidity-pool-contract/src/types.rs index c332a82..377f845 100644 --- a/contracts/liquidity-pool-contract/src/types.rs +++ b/contracts/liquidity-pool-contract/src/types.rs @@ -21,6 +21,20 @@ pub const TOTAL_BPS: i128 = 10000; /// Precision used for share price calculation (10000 = 1.0) pub const SHARE_PRICE_PRECISION: i128 = 10_000; +/// Default per-ledger outflow cap in basis points of available liquidity. +/// `0` means the cap is **disabled** — `fund_loan` is bounded only by the +/// available-liquidity check (legacy behavior). Admins enable a bound via +/// `set_outflow_cap_bps` (1..=10_000, e.g. 5_000 = 50% of available liquidity +/// per ledger) so a misbehaving creditline cannot drain the pool faster than +/// the operator tolerates. +pub const DEFAULT_OUTFLOW_CAP_BPS: u32 = 0; + +/// Default cumulative single-recipient (vendor) exposure ceiling in token +/// units. `0` means the cap is **disabled**. Admins enable a bound via +/// `set_merchant_exposure_cap` so cumulative funding to any one merchant is +/// capped. +pub const DEFAULT_MERCHANT_EXPOSURE_CAP: i128 = 0; + /// Minimum deposit / withdrawal amount to prevent dust attacks and rounding exploits. /// On first deposit this also determines the number of dead (unclaimable) shares /// seeded into the pool so a single dust depositor cannot own 100 % of the pool. diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh index 0c64dc1..3e26238 100755 --- a/scripts/deploy-testnet.sh +++ b/scripts/deploy-testnet.sh @@ -119,7 +119,25 @@ stellar contract invoke --id $LIQUIDITY_POOL_ID --source $SOURCE --network $NETW --token $TOKEN_ID \ --treasury $TREASURY \ --admin $ADMIN_PUBKEY \ - --merchant_fund $MERCHANT_FUND 2>&1 | tail -1 + --merchant_fund $MERCHANT_FUND \ + --vendor_registry $VENDOR_REGISTRY_ID 2>&1 | tail -1 + +# Defense-in-depth caps (issue #106) — opt-in via env vars. +# OUTFLOW_CAP_BPS: cumulative fund_loan outflows per ledger, as bps of available +# liquidity. 0 = disabled, 10_000 = 100%. 5_000 chokes a draining creditline +# to at most 50% of available liquidity per ledger. +# MERCHANT_EXPOSURE_CAP: cumulative funding ceiling per merchant (token units). +# 0 = disabled. +if [ -n "${OUTFLOW_CAP_BPS:-}" ] && [ "${OUTFLOW_CAP_BPS:-0}" -gt 0 ]; then + echo "Setting outflow cap to ${OUTFLOW_CAP_BPS} bps..." + stellar contract invoke --id $LIQUIDITY_POOL_ID --source $SOURCE --network $NETWORK \ + -- set_outflow_cap_bps --admin $ADMIN_PUBKEY --bps $OUTFLOW_CAP_BPS 2>&1 | tail -1 +fi +if [ -n "${MERCHANT_EXPOSURE_CAP:-}" ] && [ "${MERCHANT_EXPOSURE_CAP:-0}" -gt 0 ]; then + echo "Setting merchant exposure cap to ${MERCHANT_EXPOSURE_CAP} units..." + stellar contract invoke --id $LIQUIDITY_POOL_ID --source $SOURCE --network $NETWORK \ + -- set_merchant_exposure_cap --admin $ADMIN_PUBKEY --amount $MERCHANT_EXPOSURE_CAP 2>&1 | tail -1 +fi # vouch_boost=10: DEFAULT_VOUCH_BOOST from vouching-contract types.rs. # On the 0-100 reputation scale, 10 points is a significant single-mentor @@ -191,7 +209,7 @@ cat > contracts/deployed-testnet.json << JSONEOF "id": "$LIQUIDITY_POOL_ID", "initialized": true, "initializedAt": "$TODAY", - "initMethod": "initialize(token, treasury, admin, merchant_fund)" + "initMethod": "initialize(token, treasury, admin, merchant_fund, vendor_registry)" }, "vouching": { "id": "$VOUCHING_ID",