Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

### Issue #107 — Parameters-Contract Multisig: Snapshot Sovereignty, Elevated Quorum & Two-Step Configuration
- **Problem:** Three flaws combined to make the parameters-contract multisig (`contracts/parameters-contract/src/lib.rs`) theater, not security: (1) `configure_multisig()` needed only the single admin key, so one compromised key could silently swap the signer set and sidestep the multisig entirely; (2) proposals stored `approvals: Vec<Address>` and `execute()` only checked `approvals.len() >= threshold`, so approvals from signers *removed* since approval remained counted — revoked signers kept veto/exec power over in-flight proposals (a colluding signer + one stale approval could rewrite `interest bps`, `min_guarantee_percent`, `grace_period_seconds`, etc.); (3) `UpdateSigners` executed against the current config with no escalation guard, so a 2-of-3 committee needed only 2 old-threshold approvals to install a weaker 2-of-2 (or admit a colluder).
- **Fix (`parameters-contract`):**
Expand All @@ -30,6 +43,8 @@ Update this file after every completed contract change, fix, or architectural de
- **Two-step config:** `test_configure_multisig_two_step_propose_confirm_emits_prominent_events` (MSCONFPR before activation, MSCONFIG after confirm, not-active-after-propose), `test_confirm_multisig_without_propose_fails` (#9), `test_configure_multisig_cannot_repropose_without_confirm` (#8), `test_confirm_multisig_cannot_be_called_twice` (#8), updated 2-step setup in `setup_multisig`/`test_three_of_three_with_full_committee_approval`.
- **Invalidation sweep:** `test_in_flight_signer_set_proposals_are_invalidated_on_signer_change` (+PROPIVLD event), `test_approve_rejects_invalidated_signer_set_proposal` (#18), `test_parameter_proposals_survive_signer_change_but_stale_approvals_revalidated`.
- **Verification:** `cargo build --locked` (zero errors), `cargo test --locked` → **399 passed, 0 failed** workspace-wide (parameters 34, creditline 143, liquidity-pool 109, reputation 60, vendor-registry 26, vouching 27).

### 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`):**
- Added explicit, one-time `initialize(env, admin) -> Result<(), ReputationError>` function that requires `admin.require_auth()` and checks `storage::has_admin(&env)` (rejects re-initialization with `AlreadyInitialized = 11`).
Expand Down
4 changes: 2 additions & 2 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -4046,7 +4046,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, ());
Expand Down
1 change: 1 addition & 0 deletions contracts/liquidity-pool-contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

4 changes: 4 additions & 0 deletions contracts/liquidity-pool-contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ pub enum LiquidityPoolError {
UpgradeTimelockNotMet = 13,
UpgradeHashMismatch = 14,
ContractPaused = 15,
OutflowCapExceeded = 16,
MerchantExposureCapExceeded = 17,
VendorNotActive = 18,
InvalidCap = 19,
}
39 changes: 36 additions & 3 deletions contracts/liquidity-pool-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Address>) {
env.events()
.publish((VREG_UPDATED, admin), (registry.clone(),));
}
156 changes: 151 additions & 5 deletions contracts/liquidity-pool-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>,
) {
if storage::has_admin(&env) {
panic_with_error!(&env, LiquidityPoolError::AlreadyInitialized);
Expand All @@ -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);
}

// -------------------------------------------------------------------------
Expand All @@ -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<Address>) {
admin.require_auth();
Self::require_admin(&env, &admin);
storage::set_vendor_registry(&env, &registry);
events::emit_vendor_registry_updated(&env, &admin, &registry);
}

pub fn set_treasury(env: Env, admin: Address, treasury: Address) {
admin.require_auth();
Self::require_admin(&env, &admin);
Expand Down Expand Up @@ -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,
Expand All @@ -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, &registry, &merchant) {
return Err(LiquidityPoolError::VendorNotActive);
}
}

Self::enter_non_reentrant(&env);

let total_liquidity =
Expand All @@ -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);

Expand All @@ -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(())
}
Expand Down Expand Up @@ -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<Address> {
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 {
Expand Down Expand Up @@ -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::<bool, soroban_sdk::Error>(
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))
Expand Down
Loading
Loading