From 61172732e089a59e4f2411ba11d03b2bc8559d95 Mon Sep 17 00:00:00 2001 From: emteebug Date: Fri, 26 Jun 2026 12:12:12 +0100 Subject: [PATCH] Add OperationalLimits getter for single-read batch/limit/fee ceilings Adds get_operational_limits(), returning max_batch, max_limit, and max_fee in one call instead of requiring separate probes per value (the fee ceiling previously had no getter at all). Closes #1539 --- docs/contracts/limits.md | 23 ++++++++ quicklendx-contracts/src/init.rs | 2 +- quicklendx-contracts/src/lib.rs | 14 +++++ .../src/operational_limits.rs | 53 +++++++++++++++++++ .../src/test_operational_limits.rs | 49 +++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 quicklendx-contracts/src/operational_limits.rs create mode 100644 quicklendx-contracts/src/test_operational_limits.rs diff --git a/docs/contracts/limits.md b/docs/contracts/limits.md index 729fc77e..87da398c 100644 --- a/docs/contracts/limits.md +++ b/docs/contracts/limits.md @@ -77,6 +77,29 @@ store_invoice / upload_invoice - The grace-period/horizon constraint prevents impossible configurations. - String limits prevent storage DoS from oversized payloads. +## Operational Limits (single-read getter) + +`get_operational_limits()` returns an `OperationalLimits` struct combining the +three most commonly-probed operational ceilings in one call, instead of +requiring a separate call (or trial and error) per value: + +| Field | Value | Source | +|-------|-------|--------| +| `max_batch` | 100 | `defaults::max_overdue_scan_batch_limit()` — cap on funded-invoice batch size per overdue-scan call | +| `max_limit` | 100 | `MAX_QUERY_LIMIT` — cap on items returned per paginated query, see [queries.md](./queries.md) | +| `max_fee` | 1000 | `init::MAX_FEE_BPS` — cap on protocol fees in basis points (10%) accepted by `set_protocol_config`/`set_fee_config` | + +This is a pure read: it requires no authorization and stores no new state. +Defined in `src/operational_limits.rs`. + +```bash +soroban contract invoke \ + --id \ + --source \ + --network \ + -- get_operational_limits +``` + ## Test Coverage (Issue #826) `src/test_protocol_limits_boundary.rs` — 35 tests across 10 groups: diff --git a/quicklendx-contracts/src/init.rs b/quicklendx-contracts/src/init.rs index cdbec8c4..117bba5a 100644 --- a/quicklendx-contracts/src/init.rs +++ b/quicklendx-contracts/src/init.rs @@ -85,7 +85,7 @@ const DEFAULT_GRACE_PERIOD_SECONDS: u64 = 7 * 24 * 60 * 60; // 7 days const DEFAULT_FEE_BPS: u32 = 200; // 2% // Security limits -const MAX_FEE_BPS: u32 = 1000; // 10% maximum fee +pub(crate) const MAX_FEE_BPS: u32 = 1000; // 10% maximum fee const MIN_FEE_BPS: u32 = 0; // 0% minimum fee const MAX_DUE_DATE_DAYS: u64 = 730; // 2 years maximum const MAX_GRACE_PERIOD_SECONDS: u64 = 30 * 24 * 60 * 60; // 30 days maximum diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 9887b0f5..67270e21 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -90,6 +90,7 @@ pub mod invoice_search; pub mod maintenance; pub mod monitor; pub mod notifications; +pub mod operational_limits; pub mod pagination; pub mod pause; pub mod payments; @@ -173,6 +174,8 @@ mod test_freshness_bounds; mod test_health_status; #[cfg(all(test, feature = "legacy-tests"))] mod test_init; +#[cfg(test)] +mod test_operational_limits; #[cfg(all(test, feature = "legacy-tests"))] mod test_invariant_self_check; #[cfg(all(test, feature = "legacy-tests"))] @@ -827,6 +830,17 @@ impl QuickLendXContract { monitor::get_health_status(&env) } + /// Consolidated operational limits snapshot: max batch size, max query limit, + /// and max fee (bps) in a single read. + /// + /// Replaces the previous workaround of probing `get_overdue_scan_batch_limit_max`, + /// the pagination cap, and the fee ceiling via separate calls (the fee ceiling + /// previously had no getter at all). All fields are read-through aggregates of + /// existing protocol constants; no new state is stored. + pub fn get_operational_limits(_env: Env) -> operational_limits::OperationalLimits { + operational_limits::get_operational_limits() + } + /// Get a snapshot of the protocol's current health status. /// /// This is a read-only canonical endpoint returning a single struct aggregating diff --git a/quicklendx-contracts/src/operational_limits.rs b/quicklendx-contracts/src/operational_limits.rs new file mode 100644 index 00000000..92e130ad --- /dev/null +++ b/quicklendx-contracts/src/operational_limits.rs @@ -0,0 +1,53 @@ +//! Consolidated operational ceilings for clients, indexers, and integrators. +//! +//! [`OperationalLimits`] composes the protocol's batch-scan cap, query-page +//! cap, and fee cap into a single read via [`get_operational_limits`]. Without +//! it, a caller has to know which module owns each constant (and, for the fee +//! cap, there was previously no getter at all) and probe each one separately +//! or via trial and error. + +use crate::defaults; +use soroban_sdk::contracttype; + +/// Single-read snapshot of protocol-wide operational ceilings. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OperationalLimits { + /// Hard cap on funded-invoice batch size per overdue-scan call. + /// See [`defaults::max_overdue_scan_batch_limit`]. + pub max_batch: u32, + /// Hard cap on items returned per paginated query call. + /// See [`crate::MAX_QUERY_LIMIT`]. + pub max_limit: u32, + /// Hard cap on protocol fees, in basis points (1000 = 10%). + /// See `init::MAX_FEE_BPS`. + pub max_fee: u32, +} + +/// Build a fresh [`OperationalLimits`] snapshot from existing protocol constants. +pub fn get_operational_limits() -> OperationalLimits { + OperationalLimits { + max_batch: defaults::max_overdue_scan_batch_limit(), + max_limit: crate::MAX_QUERY_LIMIT, + max_fee: crate::init::MAX_FEE_BPS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_operational_limits_matches_source_constants() { + let limits = get_operational_limits(); + + assert_eq!(limits.max_batch, defaults::max_overdue_scan_batch_limit()); + assert_eq!(limits.max_limit, crate::MAX_QUERY_LIMIT); + assert_eq!(limits.max_fee, crate::init::MAX_FEE_BPS); + } + + #[test] + fn test_operational_limits_is_deterministic() { + assert_eq!(get_operational_limits(), get_operational_limits()); + } +} diff --git a/quicklendx-contracts/src/test_operational_limits.rs b/quicklendx-contracts/src/test_operational_limits.rs new file mode 100644 index 00000000..f96086f1 --- /dev/null +++ b/quicklendx-contracts/src/test_operational_limits.rs @@ -0,0 +1,49 @@ +//! Tests for `get_operational_limits` consolidated read (#1539). + +#![cfg(test)] + +use crate::{QuickLendXContract, QuickLendXContractClient}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn setup(env: &Env) -> (QuickLendXContractClient<'static>, Address) { + env.mock_all_auths(); + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(env, &contract_id); + let admin = Address::generate(env); + client.initialize_admin(&admin); + (client, admin) +} + +#[test] +fn test_get_operational_limits_returns_protocol_constants() { + let env = Env::default(); + let (client, _admin) = setup(&env); + + let limits = client.get_operational_limits(); + + assert_eq!(limits.max_batch, crate::defaults::max_overdue_scan_batch_limit()); + assert_eq!(limits.max_limit, crate::MAX_QUERY_LIMIT); + assert_eq!(limits.max_fee, crate::init::MAX_FEE_BPS); +} + +#[test] +fn test_get_operational_limits_does_not_require_auth() { + let env = Env::default(); + let (client, _admin) = setup(&env); + + // No mock_auths/require_auth wiring needed: this is a pure read. + let limits = client.get_operational_limits(); + assert!(limits.max_batch > 0); + assert!(limits.max_limit > 0); + assert!(limits.max_fee > 0); +} + +#[test] +fn test_get_operational_limits_stable_across_calls() { + let env = Env::default(); + let (client, _admin) = setup(&env); + + let first = client.get_operational_limits(); + let second = client.get_operational_limits(); + assert_eq!(first, second); +}