diff --git a/quicklendx-contracts/Cargo.toml b/quicklendx-contracts/Cargo.toml index b5aaae86..376f209e 100644 --- a/quicklendx-contracts/Cargo.toml +++ b/quicklendx-contracts/Cargo.toml @@ -16,7 +16,6 @@ proptest = "1.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" -ed25519-dalek = "2" soroban-sdk = { version = "25.1.1", features = ["testutils"] } quicklendx-contracts = { path = ".", features = ["testutils"] } ed25519-dalek = "2.2" diff --git a/quicklendx-contracts/src/contract.rs b/quicklendx-contracts/src/contract.rs index 5c779057..8ab97f78 100644 --- a/quicklendx-contracts/src/contract.rs +++ b/quicklendx-contracts/src/contract.rs @@ -1,7 +1,6 @@ use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec, Bytes, xdr::ToXdr}; use crate::admin::AdminStorage; use crate::errors::QuickLendXError; -use crate::protocol_limits::MAX_INVOICE_AMOUNT; use crate::protocol_limits::{ check_and_record_mutation, require_batch_size_bound, require_description_bound, require_kyc_data_bound, require_status_batch_bound, require_tags_bound, @@ -148,9 +147,10 @@ impl QuickLendXContract { crate::verification::require_business_not_pending(&env, &business)?; crate::regulatory::require_regulatory_ok(&env, &business)?; - if amount <= 0 || amount > MAX_INVOICE_AMOUNT { - return Err(QuickLendXError::InvalidAmount); - } + // #2432 — exact sign/ceiling validation before any state is written. + // Semantically identical to the historical inline predicate + // (`amount <= 0 || amount > MAX_INVOICE_AMOUNT`); see `invoice_amount`. + crate::invoice_amount::validate_invoice_amount_ceiling(amount)?; ProtocolLimitsContract::check_invoice_limit(&env, &business)?; diff --git a/quicklendx-contracts/src/invoice.rs b/quicklendx-contracts/src/invoice.rs index 71a9eec1..5feb9415 100644 --- a/quicklendx-contracts/src/invoice.rs +++ b/quicklendx-contracts/src/invoice.rs @@ -1,5 +1,5 @@ use crate::errors::QuickLendXError; -use crate::protocol_limits::{check_string_length, MAX_FEEDBACK_LENGTH, MAX_INVOICE_AMOUNT}; +use crate::protocol_limits::{check_string_length, MAX_FEEDBACK_LENGTH}; use crate::storage::DataKey; use crate::verification::normalize_tag; use soroban_sdk::{Address, BytesN, Env, String, Vec}; @@ -52,9 +52,10 @@ impl Invoice { late_payment_penalty_bps: Option, early_payment_discount_bps: Option, ) -> Result { - if amount <= 0 || amount > MAX_INVOICE_AMOUNT { - return Err(QuickLendXError::InvalidAmount); - } + // #2432 — exact sign/ceiling validation before any state is written. + // Semantically identical to the historical inline predicate + // (`amount <= 0 || amount > MAX_INVOICE_AMOUNT`); see `invoice_amount`. + crate::invoice_amount::validate_invoice_amount_ceiling(amount)?; if due_date <= env.ledger().timestamp() { return Err(QuickLendXError::InvoiceDueDateInvalid); diff --git a/quicklendx-contracts/src/invoice_amount.rs b/quicklendx-contracts/src/invoice_amount.rs new file mode 100644 index 00000000..682dc4e2 --- /dev/null +++ b/quicklendx-contracts/src/invoice_amount.rs @@ -0,0 +1,200 @@ +//! Invoice amount precision and overflow validation (Issue #2432). +//! +//! # Scope +//! +//! This module is the single, documented enforcement point for the **exact +//! integer rules** that every invoice amount must satisfy before any state +//! change in the invoice lifecycle (creation, amendment, cancellation, +//! completion). Amounts are denominated in the smallest unit of the invoice +//! currency (integers only — there is no fractional representation on-chain), +//! so "precision" here means: +//! +//! 1. **Sign** — zero and negative amounts are invalid (`InvalidAmount`). +//! 2. **Ceiling (overflow safety)** — amounts above `MAX_INVOICE_AMOUNT` are +//! invalid (`InvalidAmount`). The ceiling is deliberately +//! `i128::MAX / 10_000` so that every downstream bps computation +//! (`amount * bps / 10_000`, `amount * bps / BPS_DENOMINATOR`) is +//! overflow-free for any `bps <= 10_000`. See [`MAX_INVOICE_AMOUNT`]. +//! 3. **Minimum** — amounts below the configured `min_invoice_amount` are +//! invalid (`InvalidAmount`); the boundary is inclusive (`amount >= min`). +//! 4. **Scale** — the invoice currency must not report more than +//! [`MAX_CURRENCY_DECIMALS`] (18) decimals, otherwise the helper rejects +//! with `InvalidCurrency` so that amounts can never be silently +//! re-scaled/truncated against the token's internal precision. +//! +//! Every helper in this module is a **pure, side-effect-free function**: it +//! performs no storage access and no arithmetic beyond the documented checks. +//! Callers (contract entrypoints) invoke these helpers **before** writing any +//! state, so a rejected call can never leave a partial or unauthorized +//! invoice record behind. +//! +//! # Relationship to the legacy tree +//! +//! The pre-existing checks in the invoice lifecycle entrypoints +//! (`contract.rs::store_invoice` and `invoice.rs::Invoice::new`) applied the +//! sign/ceiling rule inline: +//! +//! ```text +//! if amount <= 0 || amount > MAX_INVOICE_AMOUNT { return Err(InvalidAmount); } +//! ``` +//! +//! [`validate_invoice_amount_ceiling`] reproduces that exact predicate (same +//! error, same boundaries — inclusive at `MAX_INVOICE_AMOUNT`) so public +//! behavior is preserved byte-for-byte; the entrypoints now route through this +//! module instead of duplicating the predicate. The min-bound and scale rules +//! mirror the semantics of `protocol_limits::validate_invoice` (min +//! `min_invoice_amount`, inclusive) and `payments::require_matching_currency_precision` +//! (`decimals <= 18`) respectively, so the rules are also consistent with the +//! funding/payment paths that consume invoice amounts later in the lifecycle. +//! +//! # Compatibility, migration, and rollback +//! +//! * **Public behavior**: unchanged. `store_invoice` / `Invoice::new` return +//! the same `QuickLendXError::InvalidAmount` for the same inputs; no error +//! codes, response shapes, or ABI types are added or removed. +//! * **Migration**: none required. No stored state is touched; existing +//! invoices are unaffected. +//! * **Rollback**: reverting this change restores the inline predicates; the +//! validation semantics are identical in both forms. +//! +//! # Operational limitations and security assumptions +//! +//! * Amounts are `i128` smallest-units. "Fractional" values (e.g. `1.5` tokens +//! at 6 decimals) are represented exactly as integers (`1_500_000`); the +//! integer division in bps math floors toward zero, matching the existing +//! `floor(amount * bps / 10_000)` fee formula (see `fees.rs` / +//! `profits.rs`). [`checked_fee_amount`] pins that formula with checked +//! arithmetic. +//! * The scale rule trusts the currency contract's `decimals()` report, +//! exactly as the existing funding-path guard does; it is a ceiling on +//! precision, not a registry check. Token allowlisting, if desired, remains +//! a separate compliance layer. +//! * `MAX_INVOICE_AMOUNT` assumes `BPS_DENOMINATOR == 10_000` (basis points). +//! If the fee denominator ever changes, this ceiling must be re-derived. +//! +//! # Invariants (enforced by this module) +//! +//! * `0 < amount <= MAX_INVOICE_AMOUNT` for every accepted invoice amount. +//! * `amount >= min_invoice_amount` when a positive floor is configured. +//! * `decimals(currency) <= 18` for every accepted currency. +//! * For any accepted `amount` and any `bps <= 10_000`: +//! `amount * bps` never overflows `i128`; the fee is +//! `floor(amount * bps / 10_000)`. +//! * Validation is deterministic and pure: the same rejected input yields the +//! same error on every call, and no call mutates state. + +use crate::errors::QuickLendXError; + +/// Hard upper bound for invoice amounts (smallest units). +/// +/// `i128::MAX / 10_000` guarantees that `amount * bps` cannot overflow `i128` +/// for any `bps <= BPS_DENOMINATOR` (10_000), which covers every fee, +/// discount, penalty, and split computation in the protocol. This mirrors the +/// legacy `protocol_limits::MAX_INVOICE_AMOUNT` constant and is locked to it +/// by [`test_max_invoice_amount_matches_documented_formula`] in the test +/// module. +pub const MAX_INVOICE_AMOUNT: i128 = i128::MAX / 10_000; + +/// Basis-point denominator used by every fee/split formula. +/// +/// 100 % == 10_000 bps. Mirrors `profits::BPS_DENOMINATOR`. +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Maximum number of decimals the invoice currency may report. +/// +/// Mirrors the ceiling enforced by `payments::require_matching_currency_precision` +/// on the funding path. Tokens reporting more than 18 decimals are rejected at +/// every amount boundary so that internal math can never silently truncate or +/// re-scale a stored amount. +pub const MAX_CURRENCY_DECIMALS: u32 = 18; + +/// Validate an invoice amount against the sign and overflow-ceiling rules. +/// +/// This is the exact predicate historically applied inline by +/// `contract.rs::store_invoice` and `invoice.rs::Invoice::new`: +/// +/// ```text +/// amount <= 0 || amount > MAX_INVOICE_AMOUNT → Err(InvalidAmount) +/// ``` +/// +/// The boundary is **inclusive at the top**: `amount == MAX_INVOICE_AMOUNT` +/// is accepted (and is exactly the largest value for which every downstream +/// bps computation remains overflow-free). +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — `amount <= 0` or +/// `amount > MAX_INVOICE_AMOUNT`. +pub fn validate_invoice_amount_ceiling(amount: i128) -> Result<(), QuickLendXError> { + if amount <= 0 || amount > MAX_INVOICE_AMOUNT { + return Err(QuickLendXError::InvalidAmount); + } + Ok(()) +} + +/// Validate an invoice amount against the sign, overflow-ceiling, and +/// configured minimum rules. +/// +/// Equivalent to [`validate_invoice_amount_ceiling`] plus the minimum floor: +/// `amount < min_amount` (with `min_amount > 0`) is rejected with +/// [`QuickLendXError::InvalidAmount`]. The minimum boundary is **inclusive**: +/// `amount == min_amount` is accepted, matching +/// `protocol_limits::validate_invoice`. +/// +/// A `min_amount <= 0` disables the floor (protocol configuration rejects +/// non-positive minimums at `set_protocol_limits` time, so this only guards +/// against direct misuse of the helper). +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — `amount <= 0`, +/// `amount > MAX_INVOICE_AMOUNT`, or (`min_amount > 0` and +/// `amount < min_amount`). +pub fn validate_invoice_amount(amount: i128, min_amount: i128) -> Result<(), QuickLendXError> { + validate_invoice_amount_ceiling(amount)?; + if min_amount > 0 && amount < min_amount { + return Err(QuickLendXError::InvalidAmount); + } + Ok(()) +} + +/// Validate the scale of an invoice currency's `decimals()` report. +/// +/// Accepts `decimals` in `[0, MAX_CURRENCY_DECIMALS]` (both inclusive) and +/// rejects anything larger so the protocol never accepts a token whose +/// internal scaling could overflow or truncate internal math. +/// +/// # Errors +/// * [`QuickLendXError::InvalidCurrency`] — `decimals > MAX_CURRENCY_DECIMALS`. +pub fn check_currency_scale(decimals: u32) -> Result<(), QuickLendXError> { + if decimals > MAX_CURRENCY_DECIMALS { + return Err(QuickLendXError::InvalidCurrency); + } + Ok(()) +} + +/// Compute `floor(amount * fee_bps / 10_000)` with strict, checked arithmetic. +/// +/// This pins the exact bps formula used by the fee/settlement pipeline +/// (`fees.rs`, `profits.rs`, `payments.rs::allocate_repayment`) so that the +/// "no overflow downstream of an accepted invoice amount" invariant is +/// directly testable. For any amount accepted by +/// [`validate_invoice_amount_ceiling`] and any `fee_bps <= 10_000` the +/// multiplication cannot overflow — the ceiling guarantees it. +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — `amount <= 0`. +/// * [`QuickLendXError::InvalidFeeBasisPoints`] — `fee_bps > 10_000`. +/// * [`QuickLendXError::ArithmeticOverflow`] — the intermediate +/// `amount * fee_bps` would overflow `i128` (only reachable when `amount` +/// exceeds the ceiling, which the entrypoints reject before this runs). +pub fn checked_fee_amount(amount: i128, fee_bps: u32) -> Result { + if amount <= 0 { + return Err(QuickLendXError::InvalidAmount); + } + if fee_bps > BPS_DENOMINATOR as u32 { + return Err(QuickLendXError::InvalidFeeBasisPoints); + } + amount + .checked_mul(fee_bps as i128) + .and_then(|product| product.checked_div(BPS_DENOMINATOR)) + .ok_or(QuickLendXError::ArithmeticOverflow) +} diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 8ab7ada2..e223cbd5 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -2,6 +2,18 @@ use soroban_sdk::{contract, contractimpl, Env, Symbol, symbol_short}; +pub mod errors; +/// Invoice amount precision and overflow validation (Issue #2432). +/// +/// See the module docs for the exact integer rules, invariants, compatibility +/// impact, and security assumptions. The invoice lifecycle entrypoints +/// (`contract.rs::store_invoice`, `invoice.rs::Invoice::new`) route their +/// amount checks through this module. +pub mod invoice_amount; + +#[cfg(test)] +mod test_invoice_amount_precision; + #[contract] pub struct QuickLendXContract; diff --git a/quicklendx-contracts/src/test_invoice_amount_precision.rs b/quicklendx-contracts/src/test_invoice_amount_precision.rs new file mode 100644 index 00000000..706e1294 --- /dev/null +++ b/quicklendx-contracts/src/test_invoice_amount_precision.rs @@ -0,0 +1,403 @@ +//! Tests for `invoice_amount` — exact integer rules, scale, sign, and +//! overflow rejection for invoice amounts (Issue #2432). +//! +//! These tests lock in the acceptance-criteria boundaries: +//! +//! | Bucket | Values exercised | Expectation | +//! |---|---|---| +//! | Success | `1`, `7` (dust), `1_000_000` (1 token @ 6dp), `MAX_INVOICE_AMOUNT` | `Ok` | +//! | Zero / sign | `0`, `-1`, `i128::MIN` | `InvalidAmount` | +//! | Ceiling | `MAX_INVOICE_AMOUNT + 1`, `i128::MAX` | `InvalidAmount` | +//! | Minimum | `min-1` / `min` / `min+1` | `Err` / `Ok` / `Ok` (inclusive) | +//! | Scale | `0`, `18` vs `19`, `u32::MAX` | `Ok` vs `InvalidCurrency` | +//! | Near-overflow | `MAX * 10_000 / 10_000`, `(MAX+1) * 10_000` | `Ok(MAX)` vs `ArithmeticOverflow` | +//! | Fractional (floor) | `(100, 333)`, `(1, 5_000)`, `(1_234_567, 1)` | `floor(amount * bps / 10_000)` | +//! | Conversion boundary | `MAX_INVOICE_AMOUNT` vs `i128::MAX` in bps math | exact boundary proven | +//! +//! The boundary sweep tests compare the helpers against an **independent +//! oracle** written directly from the specification (plain integer comparison +//! and `u128` arithmetic), not by reusing the code under test. +//! +//! All helpers under test are pure and side-effect free; the repeated-invocation +//! tests below additionally pin that rejected operations are deterministic and +//! leave no state behind. + +#![cfg(test)] + +use crate::errors::QuickLendXError; +use crate::invoice_amount::{ + check_currency_scale, checked_fee_amount, validate_invoice_amount, + validate_invoice_amount_ceiling, BPS_DENOMINATOR, MAX_CURRENCY_DECIMALS, + MAX_INVOICE_AMOUNT, +}; + +// ============================================================================ +// Independent oracles (written from the spec, not from the code under test) +// ============================================================================ + +/// Reference rule for the sign/ceiling predicate. +/// +/// `amount` is valid iff `amount > 0` and `amount <= i128::MAX / 10_000`. +fn oracle_accepts(amount: i128) -> bool { + amount > 0 && amount <= i128::MAX / 10_000 +} + +/// Reference implementation of `floor(amount * bps / 10_000)` using `u128` +/// arithmetic so the oracle itself can never overflow for the fed inputs. +/// +/// Returns `None` for inputs the validation rules reject (non-positive amount, +/// bps above the denominator), mirroring the error contract. +fn oracle_fee(amount: i128, bps: u32) -> Option { + if amount <= 0 || bps > BPS_DENOMINATOR as u32 { + return None; + } + let product = (amount as u128).checked_mul(bps as u128)?; + Some((product / BPS_DENOMINATOR as u128) as i128) +} + +// ============================================================================ +// Success path +// ============================================================================ + +/// Any positive integer in the smallest currency unit is accepted, from a +/// single unit (dust) through the documented ceiling. "Fractional" values are +/// represented exactly as integers (e.g. `1.5` tokens at 6 decimals is +/// `1_500_000`) and must be accepted. +#[test] +fn test_accepts_valid_amounts_across_scale() { + for amount in [1i128, 7, 10, 1_000, 1_000_000, 1_500_000, 123_456_789, MAX_INVOICE_AMOUNT] { + assert_eq!( + validate_invoice_amount_ceiling(amount), + Ok(()), + "amount {amount} must be accepted" + ); + } +} + +/// `MAX_INVOICE_AMOUNT` is defined as `i128::MAX / 10_000` — the largest value +/// for which every downstream bps computation stays overflow-free. Lock the +/// constant to that documented formula so a silent re-derivation is caught. +#[test] +fn test_max_invoice_amount_matches_documented_formula() { + assert_eq!(MAX_INVOICE_AMOUNT, i128::MAX / 10_000); + assert!( + MAX_INVOICE_AMOUNT + .checked_mul(BPS_DENOMINATOR) + .is_some(), + "MAX_INVOICE_AMOUNT * 10_000 must fit in i128" + ); + assert!( + (MAX_INVOICE_AMOUNT + 1) + .checked_mul(BPS_DENOMINATOR) + .is_none(), + "(MAX_INVOICE_AMOUNT + 1) * 10_000 must overflow i128 — this is why the ceiling exists" + ); +} + +// ============================================================================ +// Sign and zero rejection +// ============================================================================ + +#[test] +fn test_rejects_zero() { + assert_eq!( + validate_invoice_amount_ceiling(0), + Err(QuickLendXError::InvalidAmount) + ); +} + +#[test] +fn test_rejects_negative_amounts() { + for amount in [-1i128, -10_000, i128::MIN] { + assert_eq!( + validate_invoice_amount_ceiling(amount), + Err(QuickLendXError::InvalidAmount), + "negative amount {amount} must be rejected" + ); + } +} + +// ============================================================================ +// Overflow ceiling rejection +// ============================================================================ + +#[test] +fn test_rejects_amount_above_ceiling() { + for amount in [MAX_INVOICE_AMOUNT + 1, i128::MAX - 1, i128::MAX] { + assert_eq!( + validate_invoice_amount_ceiling(amount), + Err(QuickLendXError::InvalidAmount), + "amount {amount} above the ceiling must be rejected" + ); + } +} + +// ============================================================================ +// Minimum boundary (inclusive) +// ============================================================================ + +#[test] +fn test_minimum_boundary_is_inclusive() { + let min = 1_000i128; + // One below the floor. + assert_eq!( + validate_invoice_amount(999, min), + Err(QuickLendXError::InvalidAmount) + ); + // Exactly at the floor (inclusive). + assert_eq!(validate_invoice_amount(1_000, min), Ok(())); + // One above the floor. + assert_eq!(validate_invoice_amount(1_001, min), Ok(())); + // A non-positive floor disables the minimum rule (configuration rejects + // such floors at set_protocol_limits time). + assert_eq!(validate_invoice_amount(5, 0), Ok(())); + assert_eq!(validate_invoice_amount(5, -1), Ok(())); +} + +/// The ceiling rule still applies when only the minimum is checked — a value +/// that passes the floor but exceeds the ceiling is still rejected. +#[test] +fn test_minimum_does_not_bypass_ceiling() { + assert_eq!( + validate_invoice_amount(i128::MAX, 1), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_invoice_amount(MAX_INVOICE_AMOUNT + 1, 1), + Err(QuickLendXError::InvalidAmount) + ); +} + +// ============================================================================ +// Currency scale (decimals) boundaries +// ============================================================================ + +#[test] +fn test_scale_accepts_boundary_decimals() { + // 0 = atomic units; 18 = the documented maximum — both inclusive. + assert_eq!(check_currency_scale(0), Ok(())); + assert_eq!(check_currency_scale(MAX_CURRENCY_DECIMALS), Ok(())); + assert_eq!(check_currency_scale(7), Ok(())); // Stellar SAC default +} + +#[test] +fn test_scale_rejects_overprecision() { + for decimals in [MAX_CURRENCY_DECIMALS + 1, 19, 20, u32::MAX] { + assert_eq!( + check_currency_scale(decimals), + Err(QuickLendXError::InvalidCurrency), + "decimals={decimals} must be rejected as over-precision" + ); + } +} + +// ============================================================================ +// Near-overflow / bps math +// ============================================================================ + +/// At the ceiling with a 100 % bps rate the fee equals the amount exactly: +/// `MAX * 10_000 / 10_000 == MAX`. +#[test] +fn test_fee_math_at_max_amount_max_bps_is_exact() { + assert_eq!( + checked_fee_amount(MAX_INVOICE_AMOUNT, BPS_DENOMINATOR as u32), + Ok(MAX_INVOICE_AMOUNT) + ); +} + +/// One unit above the ceiling makes the very same computation overflow `i128`. +/// Together with the previous test this proves the ceiling is exactly the +/// conversion boundary: entrypoints reject `MAX + 1` with `InvalidAmount` +/// *before* any fee math runs, so overflow is unreachable in the lifecycle. +#[test] +fn test_fee_math_overflow_boundary_is_exactly_ceiling_plus_one() { + assert_eq!( + checked_fee_amount(MAX_INVOICE_AMOUNT + 1, BPS_DENOMINATOR as u32), + Err(QuickLendXError::ArithmeticOverflow) + ); + assert_eq!( + checked_fee_amount(i128::MAX, BPS_DENOMINATOR as u32), + Err(QuickLendXError::ArithmeticOverflow) + ); + // And the validation layer rejects these inputs before they can reach math. + assert_eq!( + validate_invoice_amount_ceiling(MAX_INVOICE_AMOUNT + 1), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_invoice_amount_ceiling(i128::MAX), + Err(QuickLendXError::InvalidAmount) + ); +} + +/// The bps formula floors toward zero, matching the existing fee pipeline +/// (`floor(amount * bps / 10_000)`). Fractional remainders never round up and +/// never produce dust above the amount. +#[test] +fn test_fee_math_truncates_like_existing_formula() { + assert_eq!(checked_fee_amount(100, 333), Ok(3)); // 100*333/10000 = 3.33 → 3 + assert_eq!(checked_fee_amount(1, 5_000), Ok(0)); // 0.5 → 0 (floor) + assert_eq!(checked_fee_amount(7, 10_000), Ok(7)); // 7.0 → 7 + assert_eq!(checked_fee_amount(10_000, 2_500), Ok(2_500)); // exact quarter + assert_eq!(checked_fee_amount(1_234_567, 1), Ok(123)); // 123.4567 → 123 + assert_eq!(checked_fee_amount(1_000_000, 0), Ok(0)); // 0 bps → 0 fee +} + +#[test] +fn test_fee_math_rejects_invalid_inputs() { + assert_eq!( + checked_fee_amount(0, 1_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + checked_fee_amount(-1, 1_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + checked_fee_amount(1_000, BPS_DENOMINATOR as u32 + 1), + Err(QuickLendXError::InvalidFeeBasisPoints) + ); + assert_eq!( + checked_fee_amount(1_000, u32::MAX), + Err(QuickLendXError::InvalidFeeBasisPoints) + ); +} + +// ============================================================================ +// Independent-oracle boundary sweeps +// ============================================================================ + +/// Sweep every interesting boundary value of `i128` through the validation +/// helper and compare against the independent oracle. +#[test] +fn test_boundary_sweep_against_oracle() { + let interesting = [ + i128::MIN, + i128::MIN + 1, + -10_000, + -1, + 0, + 1, + 10, + 1_000, + 10_000, + 1_000_000, + MAX_INVOICE_AMOUNT - 1, + MAX_INVOICE_AMOUNT, + MAX_INVOICE_AMOUNT + 1, + i128::MAX - 1, + i128::MAX, + ]; + for amount in interesting { + let expected = if oracle_accepts(amount) { + Ok(()) + } else { + Err(QuickLendXError::InvalidAmount) + }; + assert_eq!( + validate_invoice_amount_ceiling(amount), + expected, + "oracle mismatch for amount {amount}" + ); + } +} + +/// Deterministic pseudo-random sweep (simple LCG) so the comparison is not +/// limited to hand-picked constants. +#[test] +fn test_random_sweep_against_oracle() { + let mut state: u64 = 0x2432_2026_0801; + for _ in 0..2_000 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let amount = (state as i128) ^ ((state >> 32) as i128).wrapping_shl(96); + let expected = if oracle_accepts(amount) { + Ok(()) + } else { + Err(QuickLendXError::InvalidAmount) + }; + assert_eq!( + validate_invoice_amount_ceiling(amount), + expected, + "oracle mismatch for random amount {amount}" + ); + } +} + +/// For every accepted amount in the sweep, the fee math must agree exactly +/// with the independent `u128`-based oracle; rejected amounts must never +/// produce an `Ok` fee (they are caught upstream by the validation layer). +#[test] +fn test_fee_math_sweep_against_oracle() { + let interesting = [ + -1i128, + 0, + 1, + 7, + 100, + 1_000, + 1_000_000, + MAX_INVOICE_AMOUNT - 1, + MAX_INVOICE_AMOUNT, + MAX_INVOICE_AMOUNT + 1, + i128::MAX, + ]; + for amount in interesting { + for bps in [0u32, 1, 333, 2_500, 5_000, 10_000] { + match oracle_fee(amount, bps) { + Some(expected) if oracle_accepts(amount) => { + assert_eq!( + checked_fee_amount(amount, bps), + Ok(expected), + "fee oracle mismatch for amount {amount}, bps {bps}" + ); + } + Some(_) => { + // Out-of-bounds amount (above the ceiling): the fee helper + // may still compute a value when the multiplication does + // not overflow, but it must never silently wrap. When it + // does return a value it must match the oracle. + if let Ok(fee) = checked_fee_amount(amount, bps) { + assert_eq!(fee, oracle_fee(amount, bps).unwrap()); + } + } + None => { + assert!( + checked_fee_amount(amount, bps).is_err(), + "fee math must reject invalid amount {amount} / bps {bps}" + ); + } + } + } + } +} + +// ============================================================================ +// Determinism / no partial state +// ============================================================================ + +/// Validation is pure and deterministic: the same rejected input yields the +/// same error on every invocation, with no hidden counters or storage writes. +/// In the lifecycle entrypoints these checks run *before* any storage +/// mutation, so rejected, stale, and repeated operations cannot leave a +/// partial invoice record. +#[test] +fn test_validation_is_pure_and_repeatable() { + let invalid = [0i128, -1, MAX_INVOICE_AMOUNT + 1, i128::MAX]; + for amount in invalid { + let first = validate_invoice_amount_ceiling(amount); + for _ in 0..5 { + assert_eq!( + validate_invoice_amount_ceiling(amount), + first, + "rejected amount {amount} must fail identically on every call" + ); + } + assert!(first.is_err(), "amount {amount} must be rejected"); + } + // Accepted amounts are equally stable. + for amount in [1i128, 1_000_000, MAX_INVOICE_AMOUNT] { + for _ in 0..5 { + assert_eq!(validate_invoice_amount_ceiling(amount), Ok(())); + } + } +}