From b2334f8b76769e8e3e815d5b4ca4ea4de428bbfa Mon Sep 17 00:00:00 2001 From: dumbdev Date: Mon, 31 Aug 2026 09:28:50 +0100 Subject: [PATCH] fix(bid): enforce exact bid amount precision and overflow bounds (QE-2026-08) Centralise bid amount validation - sign, the i128::MAX / 10_000 overflow ceiling, the documented "bid amount <= invoice amount" rule, and the "expected_return >= bid_amount" return floor - in a documented, pure, tested module (bid_amount.rs) and route the bid-submission and auction-selection paths (contract.rs::place_bid, bid.rs::verify_bid_match) through it. Rejected amounts fail before any state change. checked_bid_profit pins the auction ranking key that compare_bids computes with a lossy saturating_sub: for any pair accepted by validate_bid the exact subtraction is non-negative and cannot overflow i128, so ranking is deterministic; adversarial pairs outside that contract surface as ArithmeticOverflow instead of a clamped, attacker-chosen rank. checked_bid_amount_sum and checked_bid_fee_amount do the same for per-investor exposure aggregation and downstream bps fee math. One explicit, documented tightening: bids financing more than the invoice face value, or offering a return below principal (previously accepted then ranked with a saturating_sub-clamped profit of 0) are now rejected with the existing QuickLendXError::InvalidAmount. No new error codes, no stored state touched, no migration. Tests: 20 focused cases in test_bid_amount_precision.rs covering zero, min, max, near-overflow, fractional, and conversion-boundary values, inclusive boundaries, a 5,000-iteration deterministic random sweep against an independent oracle, overflow-vs-clamp, and purity/repeatability. cargo test --lib: 37 passed. cargo build (dev + release WASM profile with overflow-checks) and cargo clippy --lib: clean. Closes #QE-2026-08 Co-Authored-By: Claude Sonnet 5 --- quicklendx-contracts/src/bid.rs | 20 +- quicklendx-contracts/src/bid_amount.rs | 288 ++++++++++ quicklendx-contracts/src/contract.rs | 8 + quicklendx-contracts/src/lib.rs | 12 + .../src/test_bid_amount_precision.rs | 544 ++++++++++++++++++ 5 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 quicklendx-contracts/src/bid_amount.rs create mode 100644 quicklendx-contracts/src/test_bid_amount_precision.rs diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index c98b20a5..3bfd5cdb 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -1086,6 +1086,16 @@ impl BidStorage { /// (4) timestamp with newer bids first, (5) bid_id as final stable tiebreaker. /// This guarantees reproducible ranking across validators even when all economic /// values match. + /// + /// # Amount precision (QE-2026-08) + /// The profit key is `expected_return - bid_amount`. Every bid that reaches + /// ranking has passed `verify_bid_match` / `place_bid`, which route through + /// `crate::bid_amount::validate_bid`: both amounts are in + /// `(0, MAX_BID_AMOUNT]` and `expected_return >= bid_amount`, so the exact + /// subtraction is a non-negative value that never overflows `i128`. The + /// `saturating_sub` below therefore equals `bid_amount::checked_bid_profit` + /// for every in-contract input and only differs — by clamping instead of + /// erroring — on values the entrypoints already reject. pub fn compare_bids(bid1: &Bid, bid2: &Bid) -> Ordering { let profit1 = bid1.expected_return.saturating_sub(bid1.bid_amount); let profit2 = bid2.expected_return.saturating_sub(bid2.bid_amount); @@ -1485,9 +1495,13 @@ pub fn verify_bid_match( return Err(QuickLendXError::BidStale); } - if bid.bid_amount <= 0 { - return Err(QuickLendXError::InvalidAmount); - } + // QE-2026-08 — exact sign / overflow-ceiling / invoice-ceiling / return-floor + // validation. Supersedes the historical inline `bid.bid_amount <= 0` + // predicate (same `InvalidAmount` error) and additionally enforces the + // "bid amount > invoice amount" rule this function's own doc table has + // always specified but never checked, plus the overflow ceiling and the + // `expected_return >= bid_amount` floor; see `crate::bid_amount`. + crate::bid_amount::validate_bid(bid.bid_amount, bid.expected_return, invoice.amount)?; Ok(()) } diff --git a/quicklendx-contracts/src/bid_amount.rs b/quicklendx-contracts/src/bid_amount.rs new file mode 100644 index 00000000..ceec6d3b --- /dev/null +++ b/quicklendx-contracts/src/bid_amount.rs @@ -0,0 +1,288 @@ +//! Bid amount precision and overflow validation (Issue QE-2026-08). +//! +//! # Scope +//! +//! This module is the single, documented enforcement point for the **exact +//! integer rules** that every bid amount pair (`bid_amount`, `expected_return`) +//! must satisfy before any state change on the bid-submission and +//! auction-selection paths. 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 `bid_amount` / `expected_return` values are +//! invalid (`InvalidAmount`). This reproduces the historical +//! `bid.bid_amount <= 0` guard in `bid::verify_bid_match`. +//! 2. **Ceiling (overflow safety)** — amounts above [`MAX_BID_AMOUNT`] +//! (`i128::MAX / 10_000`) are invalid (`InvalidAmount`). The ceiling is +//! chosen so that every downstream bps computation on an accepted bid +//! (`amount * bps / 10_000` in `fees.rs` / `profits.rs`) is overflow-free +//! for any `bps <= 10_000`, and so that the auction-selection profit key +//! `expected_return - bid_amount` can never overflow `i128`. +//! 3. **Invoice ceiling** — a bid may not finance more than the invoice's face +//! value: `bid_amount > invoice_amount` is invalid (`InvalidAmount`). This +//! makes explicit the rule that `bid::verify_bid_match` documents in its +//! error table ("bid amount > invoice amount → `InvalidAmount`") but never +//! actually enforced in code — see *Compatibility* below. +//! 4. **Return floor** — `expected_return < bid_amount` is invalid +//! (`InvalidAmount`). An investor whose expected return does not cover the +//! principal is offering a negative-profit bid; rejecting it at the boundary +//! keeps the auction-selection profit key non-negative and its ranking +//! deterministic even under adversarial input. +//! +//! 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 and the bid-matching helpers) invoke these +//! helpers **before** writing any state, so a rejected, stale, repeated, or +//! failed call can never leave a partial or unauthorized bid record behind. +//! +//! # Relationship to the legacy tree +//! +//! * `bid::verify_bid_match` applied the sign rule inline as +//! `if bid.bid_amount <= 0 { return Err(InvalidAmount); }`. +//! [`validate_bid_amount_ceiling`] reproduces that exact predicate (same +//! error) and adds the overflow ceiling. +//! * `bid::compare_bids` ranks bids by +//! `profit = expected_return.saturating_sub(bid_amount)`. `saturating_sub` +//! silently clamps adversarial inputs (a huge `expected_return`, a negative +//! `bid_amount`) to `i128::MAX` / `0`, which can make ranking +//! non-deterministic or unfair. [`checked_bid_profit`] pins the exact +//! subtraction; for any pair accepted by [`validate_bid`] the result is a +//! non-negative value that never overflows, so the saturating and checked +//! forms agree and the ranking is deterministic. +//! * `bid::BidStorage::get_active_bid_amount_sum_for_investor` aggregates +//! exposure with `saturating_add`. [`checked_bid_amount_sum`] pins the exact +//! addition so an overflowing aggregate surfaces as `ArithmeticOverflow` +//! instead of a clamped total. +//! * The `i128::MAX / 10_000` ceiling mirrors +//! `invoice_amount::MAX_INVOICE_AMOUNT`, so a bid that satisfies the invoice +//! ceiling automatically satisfies the bid ceiling. +//! +//! # Compatibility, migration, and rollback +//! +//! * **`bid_amount <= 0` → `InvalidAmount`**: unchanged. +//! * **Overflow ceiling**: new, but unreachable in practice for any bid on an +//! invoice whose amount already passed `invoice_amount::validate_invoice_amount_ceiling` +//! (the invoice ceiling is the same value), because rule 3 caps `bid_amount` +//! at `invoice_amount`. It is a defence-in-depth guard for direct callers. +//! * **Invoice ceiling (`bid_amount <= invoice_amount`)**: this is a +//! *documented* rule in `verify_bid_match` that the code never enforced. It +//! is now enforced. A bid that offered to finance *more* than the invoice's +//! face value — economically nonsensical, and previously accepted — is now +//! rejected with `InvalidAmount`. This is the one explicit behavioural +//! tightening; it aligns code with the long-standing documented contract. +//! * **Return floor (`expected_return >= bid_amount`)**: new explicit rule. +//! Negative-profit bids were previously accepted and then ranked with a +//! `saturating_sub`-clamped profit of `0`. They are now rejected at +//! submission. +//! * **Error codes / response shapes**: no codes are added or removed; every +//! rejection uses the existing `QuickLendXError::InvalidAmount` / +//! `ArithmeticOverflow`. +//! * **Migration**: none. No stored state is touched; existing bids are +//! unaffected (the new rules gate *new* submissions only). +//! * **Rollback**: reverting this module and its call sites restores the inline +//! `bid_amount <= 0` predicate and the `saturating_*` arithmetic. No data +//! migration is needed in either direction. +//! +//! # Operational limitations and security assumptions +//! +//! * `expected_return` is bounded above only by [`MAX_BID_AMOUNT`], not by the +//! invoice amount: an optimistic return expectation is a matter of investor +//! risk appetite, not a protocol-integrity concern, and the settlement path +//! pays out against real repayments regardless of the recorded expectation. +//! * Amounts are `i128` smallest-units. "Fractional" token values (e.g. `1.5` +//! tokens at 6 decimals) are represented exactly as integers (`1_500_000`) +//! and are accepted like any other positive integer. +//! * The bps helper floors toward zero (`floor(amount * bps / 10_000)`), +//! matching the existing fee pipeline in `fees.rs` / `profits.rs`. +//! * `MAX_BID_AMOUNT` assumes `BPS_DENOMINATOR == 10_000`. If the fee +//! denominator ever changes, the ceiling must be re-derived. +//! +//! # Invariants (enforced by this module) +//! +//! * `0 < bid_amount <= MAX_BID_AMOUNT` for every accepted bid. +//! * `bid_amount <= invoice_amount` when a positive invoice amount is supplied. +//! * `0 < expected_return <= MAX_BID_AMOUNT` and `expected_return >= bid_amount` +//! for every accepted bid. +//! * For any accepted `(bid_amount, expected_return)` pair: +//! `expected_return - bid_amount` is in `[0, MAX_BID_AMOUNT)` and never +//! overflows `i128` — so the auction-selection ranking key is exact. +//! * For any accepted `bid_amount` and any `bps <= 10_000`: +//! `bid_amount * bps` never overflows `i128`; the fee is +//! `floor(bid_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 bid amounts (smallest units). +/// +/// `i128::MAX / 10_000` guarantees that `amount * bps` cannot overflow `i128` +/// for any `bps <= BPS_DENOMINATOR` (10_000), and that the auction-selection +/// profit key `expected_return - bid_amount` cannot overflow for any pair of +/// accepted amounts. Deliberately identical to +/// `invoice_amount::MAX_INVOICE_AMOUNT`; the equality is locked by +/// [`test_max_bid_amount_matches_invoice_ceiling`] in the test module. +pub const MAX_BID_AMOUNT: i128 = i128::MAX / 10_000; + +/// Basis-point denominator used by every fee/split formula. +/// +/// 100 % == 10_000 bps. Mirrors `profits::BPS_DENOMINATOR` and +/// `invoice_amount::BPS_DENOMINATOR`. +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Validate a single bid amount against the sign and overflow-ceiling rules. +/// +/// This is the historical inline predicate from `bid::verify_bid_match` +/// (`bid.bid_amount <= 0 → Err(InvalidAmount)`) extended with the overflow +/// ceiling: +/// +/// ```text +/// amount <= 0 || amount > MAX_BID_AMOUNT → Err(InvalidAmount) +/// ``` +/// +/// The boundary is **inclusive at the top**: `amount == MAX_BID_AMOUNT` is +/// accepted. +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — `amount <= 0` or +/// `amount > MAX_BID_AMOUNT`. +pub fn validate_bid_amount_ceiling(amount: i128) -> Result<(), QuickLendXError> { + if amount <= 0 || amount > MAX_BID_AMOUNT { + return Err(QuickLendXError::InvalidAmount); + } + Ok(()) +} + +/// Validate an `expected_return` against the sign, overflow-ceiling, and +/// return-floor rules, given the bid's principal `bid_amount`. +/// +/// Equivalent to [`validate_bid_amount_ceiling`] applied to `expected_return`, +/// plus the floor `expected_return >= bid_amount` (a bid must at least return +/// its principal). The floor is **inclusive**: `expected_return == bid_amount` +/// (a zero-profit bid) is accepted. +/// +/// `bid_amount` is assumed to have already passed +/// [`validate_bid_amount_ceiling`]; callers that use [`validate_bid`] get that +/// ordering for free. +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — `expected_return <= 0`, +/// `expected_return > MAX_BID_AMOUNT`, or `expected_return < bid_amount`. +pub fn validate_expected_return( + expected_return: i128, + bid_amount: i128, +) -> Result<(), QuickLendXError> { + validate_bid_amount_ceiling(expected_return)?; + if expected_return < bid_amount { + return Err(QuickLendXError::InvalidAmount); + } + Ok(()) +} + +/// Full bid-submission boundary check. +/// +/// Validates the complete `(bid_amount, expected_return)` pair for a bid on an +/// invoice of face value `invoice_amount`, in this order: +/// +/// 1. `bid_amount` — sign + overflow ceiling ([`validate_bid_amount_ceiling`]). +/// 2. `bid_amount <= invoice_amount` when `invoice_amount > 0` (the invoice +/// ceiling). A non-positive `invoice_amount` disables this check so the +/// helper stays usable by callers that do not have an invoice in hand; the +/// invoice lifecycle rejects non-positive amounts long before a bid is +/// placed. +/// 3. `expected_return` — sign + overflow ceiling + return floor +/// ([`validate_expected_return`]). +/// +/// On success the caller has the guarantee that +/// `0 < bid_amount <= min(invoice_amount, MAX_BID_AMOUNT)` and +/// `bid_amount <= expected_return <= MAX_BID_AMOUNT`, which is exactly the +/// precondition [`checked_bid_profit`] needs to be overflow-free and +/// non-negative. +/// +/// # Errors +/// * [`QuickLendXError::InvalidAmount`] — any rule above is violated. +pub fn validate_bid( + bid_amount: i128, + expected_return: i128, + invoice_amount: i128, +) -> Result<(), QuickLendXError> { + validate_bid_amount_ceiling(bid_amount)?; + if invoice_amount > 0 && bid_amount > invoice_amount { + return Err(QuickLendXError::InvalidAmount); + } + validate_expected_return(expected_return, bid_amount)?; + Ok(()) +} + +/// Compute the auction-selection profit key `expected_return - bid_amount` with +/// strict, checked arithmetic. +/// +/// `bid::compare_bids` ranks bids by this value using `saturating_sub`, which +/// silently clamps on overflow. This helper pins the exact subtraction so that: +/// +/// * for any pair accepted by [`validate_bid`] the result is in +/// `[0, MAX_BID_AMOUNT)` and equal to the `saturating_sub` result (proving +/// the ranking is deterministic for valid inputs), and +/// * an out-of-contract pair that would overflow `i128` surfaces as +/// `ArithmeticOverflow` instead of a clamped, attacker-chosen rank. +/// +/// # Errors +/// * [`QuickLendXError::ArithmeticOverflow`] — `expected_return - bid_amount` +/// does not fit in `i128` (only reachable for inputs outside the +/// [`validate_bid`] contract). +pub fn checked_bid_profit( + expected_return: i128, + bid_amount: i128, +) -> Result { + expected_return + .checked_sub(bid_amount) + .ok_or(QuickLendXError::ArithmeticOverflow) +} + +/// Add one bid amount to a running per-investor exposure total with strict, +/// checked arithmetic. +/// +/// `bid::BidStorage::get_active_bid_amount_sum_for_investor` accumulates with +/// `saturating_add`; this helper pins the exact addition so an overflowing +/// aggregate is reported (`ArithmeticOverflow`) rather than silently clamped to +/// `i128::MAX` — a clamped total would understate exposure relative to reality +/// and could let an investor exceed a configured cap. +/// +/// # Errors +/// * [`QuickLendXError::ArithmeticOverflow`] — `running_total + bid_amount` +/// does not fit in `i128`. +pub fn checked_bid_amount_sum( + running_total: i128, + bid_amount: i128, +) -> Result { + running_total + .checked_add(bid_amount) + .ok_or(QuickLendXError::ArithmeticOverflow) +} + +/// Compute `floor(amount * fee_bps / 10_000)` with strict, checked arithmetic. +/// +/// Pins the exact bps formula the fee/settlement pipeline applies to an +/// accepted bid amount (`fees.rs`, `profits.rs`) so the "no overflow downstream +/// of an accepted bid amount" invariant is directly testable. For any `amount` +/// accepted by [`validate_bid_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 first). +pub fn checked_bid_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/contract.rs b/quicklendx-contracts/src/contract.rs index 8ab97f78..51bc5f72 100644 --- a/quicklendx-contracts/src/contract.rs +++ b/quicklendx-contracts/src/contract.rs @@ -231,6 +231,14 @@ impl QuickLendXContract { InvoiceStorage::require_lock_within_time_limit(&env, &invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } + // QE-2026-08 — exact bid amount precision / overflow validation before + // any state (idempotency marker, bid record) is written. Enforces sign, + // the `i128::MAX / 10_000` overflow ceiling, the documented + // "bid amount <= invoice amount" rule, and the return floor + // (`expected_return >= bid_amount`) so the auction-selection profit key + // is exact and non-negative; see `bid_amount`. + let invoice = InvoiceStorage::get(&env, &invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; + crate::bid_amount::validate_bid(bid_amount, expected_return, invoice.amount)?; // Store idempotency marker store_idempotency(&env, &idem_key); let bid_id = BidStorage::generate_unique_bid_id(&env); diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index e223cbd5..f3771795 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -11,9 +11,21 @@ pub mod errors; /// amount checks through this module. pub mod invoice_amount; +/// Bid amount precision and overflow validation (Issue QE-2026-08). +/// +/// See the module docs for the exact integer rules, invariants, compatibility +/// impact, and security assumptions. The bid-submission and auction-selection +/// paths (`contract.rs::place_bid`, `bid.rs::verify_bid_match`, +/// `bid.rs::compare_bids`) route their amount checks and ranking-key +/// arithmetic through this module. +pub mod bid_amount; + #[cfg(test)] mod test_invoice_amount_precision; +#[cfg(test)] +mod test_bid_amount_precision; + #[contract] pub struct QuickLendXContract; diff --git a/quicklendx-contracts/src/test_bid_amount_precision.rs b/quicklendx-contracts/src/test_bid_amount_precision.rs new file mode 100644 index 00000000..31e1e79e --- /dev/null +++ b/quicklendx-contracts/src/test_bid_amount_precision.rs @@ -0,0 +1,544 @@ +//! Tests for `bid_amount` — exact integer rules, sign, overflow, and +//! auction-selection ranking-key precision for bid amounts (Issue QE-2026-08). +//! +//! These tests lock in the acceptance-criteria boundaries: +//! +//! | Bucket | Values exercised | Expectation | +//! |---|---|---| +//! | Success | `1`, `7` (dust), `1_000_000`, `MAX_BID_AMOUNT` | `Ok` | +//! | Zero / sign | `0`, `-1`, `i128::MIN` | `InvalidAmount` | +//! | Ceiling | `MAX_BID_AMOUNT + 1`, `i128::MAX` | `InvalidAmount` | +//! | Invoice ceiling | `bid == invoice` / `bid == invoice + 1` | `Ok` / `InvalidAmount` | +//! | Return floor | `ret == amount - 1` / `== amount` / `== amount + 1` | `Err` / `Ok` / `Ok` | +//! | Profit key | `checked_bid_profit` vs independent `i128` oracle | exact match, no clamp | +//! | Exposure sum | near-`i128::MAX` running totals | `Ok` / `ArithmeticOverflow` | +//! | Near-overflow | `MAX * 10_000 / 10_000`, `(MAX+1) * 10_000` | `Ok(MAX)` / `ArithmeticOverflow` | +//! | Fractional (floor) | `(100, 333)`, `(1, 5_000)`, `(1_234_567, 1)` | `floor(amount * bps / 10_000)` | +//! | Conversion boundary | `MAX_BID_AMOUNT` vs `i128::MAX` in bps + profit math | exact boundary proven | +//! +//! The boundary sweep tests compare the helpers against an **independent +//! oracle** written directly from the specification (plain integer comparison +//! and `i128` / `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::bid_amount::{ + checked_bid_amount_sum, checked_bid_fee_amount, checked_bid_profit, validate_bid, + validate_bid_amount_ceiling, validate_expected_return, BPS_DENOMINATOR, MAX_BID_AMOUNT, +}; +use crate::errors::QuickLendXError; +use crate::invoice_amount::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_amount_ok(amount: i128) -> bool { + amount > 0 && amount <= i128::MAX / 10_000 +} + +/// Reference rule for the full bid-submission predicate. +fn oracle_bid_ok(bid_amount: i128, expected_return: i128, invoice_amount: i128) -> bool { + if !oracle_amount_ok(bid_amount) { + return false; + } + if invoice_amount > 0 && bid_amount > invoice_amount { + return false; + } + if !oracle_amount_ok(expected_return) { + return false; + } + expected_return >= bid_amount +} + +/// Reference implementation of `expected_return - bid_amount` using `i128` +/// checked arithmetic — the auction-selection ranking key. +fn oracle_profit(expected_return: i128, bid_amount: i128) -> Option { + expected_return.checked_sub(bid_amount) +} + +/// Reference implementation of `floor(amount * bps / 10_000)` using `u128` +/// arithmetic so the oracle itself can never overflow for the fed inputs. +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 +// ============================================================================ + +#[test] +fn test_accepts_valid_bid_amounts_across_scale() { + for amount in [ + 1i128, + 7, + 10, + 1_000, + 1_000_000, + 1_500_000, + 123_456_789, + MAX_BID_AMOUNT, + ] { + assert_eq!( + validate_bid_amount_ceiling(amount), + Ok(()), + "bid amount {amount} must be accepted" + ); + } +} + +/// A representative full submission: principal below the invoice face value, +/// expected return above principal, both within the ceiling. +#[test] +fn test_accepts_representative_full_submission() { + assert_eq!(validate_bid(900_000, 1_000_000, 1_000_000), Ok(())); + // Zero-profit bid (return floor is inclusive). + assert_eq!(validate_bid(1_000_000, 1_000_000, 1_000_000), Ok(())); + // Bid exactly equal to the invoice amount (invoice ceiling is inclusive). + assert_eq!(validate_bid(1_000_000, 1_200_000, 1_000_000), Ok(())); +} + +/// The bid ceiling is defined to equal the invoice ceiling so a bid that fits +/// under an accepted invoice amount automatically fits under the bid ceiling. +#[test] +fn test_max_bid_amount_matches_invoice_ceiling() { + assert_eq!(MAX_BID_AMOUNT, MAX_INVOICE_AMOUNT); + assert_eq!(MAX_BID_AMOUNT, i128::MAX / 10_000); + assert!( + MAX_BID_AMOUNT.checked_mul(BPS_DENOMINATOR).is_some(), + "MAX_BID_AMOUNT * 10_000 must fit in i128" + ); + assert!( + (MAX_BID_AMOUNT + 1).checked_mul(BPS_DENOMINATOR).is_none(), + "(MAX_BID_AMOUNT + 1) * 10_000 must overflow i128 — this is why the ceiling exists" + ); +} + +// ============================================================================ +// Sign and zero rejection +// ============================================================================ + +#[test] +fn test_rejects_zero_bid_amount() { + assert_eq!( + validate_bid_amount_ceiling(0), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_bid(0, 1_000, 1_000), + Err(QuickLendXError::InvalidAmount) + ); +} + +#[test] +fn test_rejects_negative_amounts() { + for amount in [-1i128, -10_000, i128::MIN] { + assert_eq!( + validate_bid_amount_ceiling(amount), + Err(QuickLendXError::InvalidAmount), + "negative bid amount {amount} must be rejected" + ); + } + // Negative expected_return is rejected regardless of principal. + assert_eq!( + validate_expected_return(-1, 1_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_bid(1_000, -1, 10_000), + Err(QuickLendXError::InvalidAmount) + ); +} + +// ============================================================================ +// Overflow ceiling rejection +// ============================================================================ + +#[test] +fn test_rejects_amount_above_ceiling() { + for amount in [MAX_BID_AMOUNT + 1, i128::MAX - 1, i128::MAX] { + assert_eq!( + validate_bid_amount_ceiling(amount), + Err(QuickLendXError::InvalidAmount), + "bid amount {amount} above the ceiling must be rejected" + ); + } + // The ceiling still bites through the full-submission entrypoint, even when + // the (nonsensical) invoice amount is also huge. + assert_eq!( + validate_bid(MAX_BID_AMOUNT + 1, MAX_BID_AMOUNT + 1, i128::MAX), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_bid(1_000, i128::MAX, 10_000), + Err(QuickLendXError::InvalidAmount) + ); +} + +// ============================================================================ +// Invoice ceiling (inclusive) +// ============================================================================ + +#[test] +fn test_invoice_ceiling_is_inclusive() { + let invoice = 1_000_000i128; + // One below / exactly at / one above the invoice face value. + assert_eq!(validate_bid(999_999, 1_000_000, invoice), Ok(())); + assert_eq!(validate_bid(1_000_000, 1_000_000, invoice), Ok(())); + assert_eq!( + validate_bid(1_000_001, 1_100_000, invoice), + Err(QuickLendXError::InvalidAmount) + ); +} + +#[test] +fn test_non_positive_invoice_amount_disables_invoice_ceiling() { + // With no invoice reference the bid ceiling still applies, but the + // invoice-relative check is skipped. + assert_eq!(validate_bid(5_000, 5_000, 0), Ok(())); + assert_eq!(validate_bid(5_000, 5_000, -1), Ok(())); +} + +// ============================================================================ +// Return floor (inclusive) +// ============================================================================ + +#[test] +fn test_return_floor_is_inclusive() { + let amount = 1_000i128; + assert_eq!( + validate_expected_return(amount - 1, amount), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!(validate_expected_return(amount, amount), Ok(())); + assert_eq!(validate_expected_return(amount + 1, amount), Ok(())); + // Same three cases through the full entrypoint. + assert_eq!( + validate_bid(amount, amount - 1, 10_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!(validate_bid(amount, amount, 10_000), Ok(())); + assert_eq!(validate_bid(amount, amount + 1, 10_000), Ok(())); +} + +// ============================================================================ +// Auction-selection profit key +// ============================================================================ + +/// For every pair accepted by `validate_bid`, the checked profit key is +/// non-negative, within `[0, MAX_BID_AMOUNT)`, and identical to the legacy +/// `saturating_sub` result — so the auction ranking is deterministic. +#[test] +fn test_profit_key_matches_saturating_for_valid_pairs() { + let pairs = [ + (1i128, 1i128), + (1, 2), + (1_000, 1_000), + (1_000, 5_000), + (1, MAX_BID_AMOUNT), + (MAX_BID_AMOUNT, MAX_BID_AMOUNT), + (MAX_BID_AMOUNT - 1, MAX_BID_AMOUNT), + ]; + for (bid_amount, expected_return) in pairs { + assert_eq!( + validate_bid(bid_amount, expected_return, MAX_BID_AMOUNT), + Ok(()), + "pair ({bid_amount}, {expected_return}) should be valid" + ); + let checked = checked_bid_profit(expected_return, bid_amount).unwrap(); + assert_eq!(checked, expected_return - bid_amount); + assert_eq!(checked, expected_return.saturating_sub(bid_amount)); + assert!( + (0..MAX_BID_AMOUNT).contains(&checked), + "profit {checked} out of [0, MAX_BID_AMOUNT) for ({bid_amount}, {expected_return})" + ); + } +} + +/// An adversarial pair outside the `validate_bid` contract that would overflow +/// `i128` under subtraction surfaces as `ArithmeticOverflow` instead of a +/// silently clamped rank. +#[test] +fn test_profit_key_reports_overflow_instead_of_clamping() { + assert_eq!( + checked_bid_profit(i128::MAX, -1), + Err(QuickLendXError::ArithmeticOverflow) + ); + assert_eq!( + checked_bid_profit(i128::MAX, i128::MIN), + Err(QuickLendXError::ArithmeticOverflow) + ); + assert_eq!( + checked_bid_profit(i128::MIN, 1), + Err(QuickLendXError::ArithmeticOverflow) + ); + // `saturating_sub` would have clamped all three to `i128::MAX` / `i128::MIN` + // and let an attacker pin a deterministic rank. + assert_eq!(i128::MAX.saturating_sub(-1), i128::MAX); +} + +// ============================================================================ +// Per-investor exposure sum +// ============================================================================ + +#[test] +fn test_exposure_sum_is_exact_and_reports_overflow() { + assert_eq!(checked_bid_amount_sum(0, 0), Ok(0)); + assert_eq!( + checked_bid_amount_sum(MAX_BID_AMOUNT, MAX_BID_AMOUNT), + Ok(2 * MAX_BID_AMOUNT) + ); + // Running total already at the very top: any positive add overflows. + assert_eq!( + checked_bid_amount_sum(i128::MAX, 1), + Err(QuickLendXError::ArithmeticOverflow) + ); + assert_eq!( + checked_bid_amount_sum(i128::MAX - 1, 2), + Err(QuickLendXError::ArithmeticOverflow) + ); + // Exact boundary: total + add == i128::MAX is fine. + assert_eq!(checked_bid_amount_sum(i128::MAX - 1, 1), Ok(i128::MAX)); +} + +// ============================================================================ +// Near-overflow / bps math +// ============================================================================ + +#[test] +fn test_fee_math_at_max_amount_max_bps_is_exact() { + assert_eq!( + checked_bid_fee_amount(MAX_BID_AMOUNT, BPS_DENOMINATOR as u32), + Ok(MAX_BID_AMOUNT) + ); +} + +#[test] +fn test_fee_math_overflow_boundary_is_exactly_ceiling_plus_one() { + assert_eq!( + checked_bid_fee_amount(MAX_BID_AMOUNT + 1, BPS_DENOMINATOR as u32), + Err(QuickLendXError::ArithmeticOverflow) + ); + assert_eq!( + checked_bid_fee_amount(i128::MAX, BPS_DENOMINATOR as u32), + Err(QuickLendXError::ArithmeticOverflow) + ); + // The validation layer rejects these inputs before they can reach math. + assert_eq!( + validate_bid_amount_ceiling(MAX_BID_AMOUNT + 1), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + validate_bid_amount_ceiling(i128::MAX), + Err(QuickLendXError::InvalidAmount) + ); +} + +#[test] +fn test_fee_math_truncates_like_existing_formula() { + assert_eq!(checked_bid_fee_amount(100, 333), Ok(3)); // 3.33 → 3 + assert_eq!(checked_bid_fee_amount(1, 5_000), Ok(0)); // 0.5 → 0 + assert_eq!(checked_bid_fee_amount(7, 10_000), Ok(7)); // 7.0 → 7 + assert_eq!(checked_bid_fee_amount(10_000, 2_500), Ok(2_500)); // exact quarter + assert_eq!(checked_bid_fee_amount(1_234_567, 1), Ok(123)); // 123.4567 → 123 + assert_eq!(checked_bid_fee_amount(1_000_000, 0), Ok(0)); // 0 bps → 0 fee +} + +#[test] +fn test_fee_math_rejects_invalid_inputs() { + assert_eq!( + checked_bid_fee_amount(0, 1_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + checked_bid_fee_amount(-1, 1_000), + Err(QuickLendXError::InvalidAmount) + ); + assert_eq!( + checked_bid_fee_amount(1_000, BPS_DENOMINATOR as u32 + 1), + Err(QuickLendXError::InvalidFeeBasisPoints) + ); + assert_eq!( + checked_bid_fee_amount(1_000, u32::MAX), + Err(QuickLendXError::InvalidFeeBasisPoints) + ); +} + +// ============================================================================ +// Independent-oracle boundary sweeps +// ============================================================================ + +#[test] +fn test_ceiling_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_BID_AMOUNT - 1, + MAX_BID_AMOUNT, + MAX_BID_AMOUNT + 1, + i128::MAX - 1, + i128::MAX, + ]; + for amount in interesting { + let expected = if oracle_amount_ok(amount) { + Ok(()) + } else { + Err(QuickLendXError::InvalidAmount) + }; + assert_eq!( + validate_bid_amount_ceiling(amount), + expected, + "oracle mismatch for bid amount {amount}" + ); + } +} + +/// Deterministic pseudo-random sweep (simple LCG) over the full +/// `(bid_amount, expected_return, invoice_amount)` space, compared against the +/// independent oracle for both the accept/reject decision and — on accepted +/// pairs — the profit key. +#[test] +fn test_random_sweep_against_oracle() { + let mut state: u64 = 0x0806_2026_0808; + let mut next = || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + state + }; + for _ in 0..5_000 { + let bid_amount = (next() as i128) ^ ((next() >> 33) as i128).wrapping_shl(64); + let expected_return = (next() as i128) ^ ((next() >> 33) as i128).wrapping_shl(64); + let invoice_amount = (next() as i128) ^ ((next() >> 33) as i128).wrapping_shl(64); + + let got = validate_bid(bid_amount, expected_return, invoice_amount); + let want = if oracle_bid_ok(bid_amount, expected_return, invoice_amount) { + Ok(()) + } else { + Err(QuickLendXError::InvalidAmount) + }; + assert_eq!( + got, want, + "oracle mismatch for ({bid_amount}, {expected_return}, {invoice_amount})" + ); + + if got.is_ok() { + // Accepted pair: the profit key must be exact and never clamp. + let checked = checked_bid_profit(expected_return, bid_amount); + assert_eq!( + checked, + Ok(oracle_profit(expected_return, bid_amount).unwrap()) + ); + let p = checked.unwrap(); + assert!( + (0..MAX_BID_AMOUNT).contains(&p), + "profit {p} out of range for accepted ({bid_amount}, {expected_return})" + ); + } + } +} + +#[test] +fn test_fee_math_sweep_against_oracle() { + let interesting = [ + -1i128, + 0, + 1, + 7, + 100, + 1_000, + 1_000_000, + MAX_BID_AMOUNT - 1, + MAX_BID_AMOUNT, + MAX_BID_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_amount_ok(amount) => { + assert_eq!( + checked_bid_fee_amount(amount, bps), + Ok(expected), + "fee oracle mismatch for amount {amount}, bps {bps}" + ); + } + Some(_) => { + if let Ok(fee) = checked_bid_fee_amount(amount, bps) { + assert_eq!(fee, oracle_fee(amount, bps).unwrap()); + } + } + None => { + assert!( + checked_bid_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 entrypoints these checks run *before* any storage mutation, so +/// rejected, stale, and repeated operations cannot leave a partial bid record. +#[test] +fn test_validation_is_pure_and_repeatable() { + let invalid = [ + (0i128, 0i128, 0i128), + (-1, 1_000, 10_000), + (MAX_BID_AMOUNT + 1, MAX_BID_AMOUNT + 1, i128::MAX), + (2_000, 1_000, 10_000), // return below principal + (20_000, 20_000, 10_000), // bid above invoice + ]; + for (bid_amount, expected_return, invoice_amount) in invalid { + let first = validate_bid(bid_amount, expected_return, invoice_amount); + assert!( + first.is_err(), + "({bid_amount}, {expected_return}, {invoice_amount}) must be rejected" + ); + for _ in 0..5 { + assert_eq!( + validate_bid(bid_amount, expected_return, invoice_amount), + first, + "rejected input must fail identically on every call" + ); + } + } + for (bid_amount, expected_return, invoice_amount) in [ + (1i128, 1i128, 1i128), + (900_000, 1_000_000, 1_000_000), + (MAX_BID_AMOUNT, MAX_BID_AMOUNT, MAX_BID_AMOUNT), + ] { + for _ in 0..5 { + assert_eq!( + validate_bid(bid_amount, expected_return, invoice_amount), + Ok(()) + ); + } + } +}