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