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
23 changes: 23 additions & 0 deletions docs/contracts/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CONTRACT_ID> \
--source <READONLY_ACCOUNT> \
--network <NETWORK> \
-- get_operational_limits
```

## Test Coverage (Issue #826)

`src/test_protocol_limits_boundary.rs` — 35 tests across 10 groups:
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"))]
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions quicklendx-contracts/src/operational_limits.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
49 changes: 49 additions & 0 deletions quicklendx-contracts/src/test_operational_limits.rs
Original file line number Diff line number Diff line change
@@ -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);
}