From 2d12679ef9c1141430d24a8f05f83ffee97d1cc7 Mon Sep 17 00:00:00 2001 From: blessme247 Date: Sun, 29 Mar 2026 03:20:30 +0100 Subject: [PATCH] feat: clarify max active bids per investor semantics --- quicklendx-contracts/Cargo.toml | 2 - quicklendx-contracts/src/bid.rs | 104 +++++++++++++++++++++++++++ quicklendx-contracts/src/invoice.rs | 2 + quicklendx-contracts/src/lib.rs | 3 +- quicklendx-contracts/src/test_bid.rs | 70 ++++++++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) diff --git a/quicklendx-contracts/Cargo.toml b/quicklendx-contracts/Cargo.toml index 937e32e5..41260379 100644 --- a/quicklendx-contracts/Cargo.toml +++ b/quicklendx-contracts/Cargo.toml @@ -8,8 +8,6 @@ edition = "2021" # For WASM contract build use: cargo build --release --target wasm32-unknown-unknown # (add crate-type = ["cdylib"] temporarily or build in WSL/Linux if you need the .so artifact). crate-type = ["rlib", "cdylib"] -# Keep an rlib target for integration tests and a cdylib target for contract/WASM builds. -crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { version = "25.1.1", features = ["alloc"] } diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index b8d0d132..fbda2a40 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -21,6 +21,12 @@ const MAX_ACTIVE_BIDS_PER_INVESTOR_KEY: Symbol = symbol_short!("mx_actbd"); const DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR: u32 = 20; const SECONDS_PER_DAY: u64 = 86400; +/// Sentinel value: when the stored limit equals this, enforcement is disabled. +/// +/// The constant is named explicitly so that callers can compare against it +/// without embedding a magic literal. +pub const INVESTOR_BID_LIMIT_DISABLED: u32 = 0; + /// Maximum number of bids allowed per invoice to prevent unbound storage growth pub const MAX_BIDS_PER_INVOICE: u32 = 50; @@ -44,6 +50,33 @@ pub struct BidTtlConfig { pub is_custom: bool, } +/// Snapshot of the current investor active-bid limit configuration. +/// +/// Returned by [`BidStorage::get_bid_limit_config`] so that off-chain clients, +/// dashboards, and tests can inspect the complete policy in a single call. +/// +/// ### Interpreting `limit` +/// +/// | `limit` value | Meaning | +/// |---------------|------------------------------------------------------------| +/// | `0` | Limit is **disabled** — any number of open bids is allowed | +/// | `n > 0` | At most `n` concurrently `Placed` bids per investor | +/// +/// Use [`BidStorage::is_investor_bid_limit_active`] for a simple boolean check. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BidLimitConfig { + /// Active limit value. `0` means enforcement is disabled. + pub limit: u32, + /// Compile-time default (`DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR` = 20). + pub default_limit: u32, + /// `true` when `limit == 0` (enforcement disabled). + pub is_disabled: bool, + /// `true` when the admin has explicitly set a value (overriding the default). + pub is_custom: bool, +} + + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum BidStatus { @@ -240,6 +273,57 @@ impl BidStorage { .unwrap_or(DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR) } + /// Return a complete snapshot of the investor active-bid limit policy. + /// + /// Analogous to [`BidStorage::get_bid_ttl_config`] for TTL. Intended + /// for off-chain dashboards, admin panels, and test assertions. + /// + pub fn get_bid_limit_config(env: &Env) -> BidLimitConfig { + let stored: Option = env.storage().instance().get(&MAX_ACTIVE_BIDS_PER_INVESTOR_KEY); + let limit = stored.unwrap_or(DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR); + BidLimitConfig { + limit, + default_limit: DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR, + is_disabled: limit == INVESTOR_BID_LIMIT_DISABLED, + is_custom: stored.is_some(), + } + } + + /// Returns `true` when the investor active-bid limit is enforced. + /// + /// Returns `false` when the limit has been set to `0` + /// (`INVESTOR_BID_LIMIT_DISABLED`), meaning bids will **not** be rejected + /// for having too many open positions. + /// + /// ### Usage + /// + /// Prefer this over comparing `get_max_active_bids_per_investor() != 0` + /// directly, to keep the zero-is-disabled semantic in one place. + /// + /// ```ignore + /// if BidStorage::is_investor_bid_limit_active(&env) { + /// // enforcement is on; check count + /// } + /// ``` + pub fn is_investor_bid_limit_active(env: &Env) -> bool { + Self::get_max_active_bids_per_investor(env) != INVESTOR_BID_LIMIT_DISABLED + } + + /// This function is **read-only** with respect to the limit policy itself. + /// Setting or changing the limit requires admin authority and goes through + /// [`BidStorage::set_max_active_bids_per_investor`]. + pub fn investor_has_reached_bid_limit(env: &Env, investor: &Address) -> bool { + let limit = Self::get_max_active_bids_per_investor(env); + + // Limit of 0 means "disabled" — never block a placement. + if limit == INVESTOR_BID_LIMIT_DISABLED { + return false; + } + + let active = Self::count_active_placed_bids_for_investor(env, investor); + active >= limit + } + /// Admin-only: set max number of active (Placed) bids per investor across all invoices. /// A value of 0 disables this limit. pub fn set_max_active_bids_per_investor( @@ -255,6 +339,26 @@ impl BidStorage { Ok(limit) } + /// Admin-only: reset the investor active-bid limit to the compile-time + /// default (`DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR` = 20). + /// + /// Removes the stored override so `get_bid_limit_config` reports + /// `is_custom = false` and `is_disabled = false`. + /// + /// Useful for reverting a previous `set_max_active_bids_per_investor(0)` + /// call when the unrestricted window should end. + pub fn reset_max_active_bids_per_investor( + env: &Env, + admin: &Address, + ) -> Result { + admin.require_auth(); + AdminStorage::require_admin(env, admin)?; + env.storage() + .instance() + .remove(&MAX_ACTIVE_BIDS_PER_INVESTOR_KEY); + Ok(DEFAULT_MAX_ACTIVE_BIDS_PER_INVESTOR) + } + /// @notice Prunes expired bids from the investor's global index. /// @dev This ensures that the investor's bid list doesn't grow unboundedly over time /// with historical expired bids, maintaining O(active_bids) performance for limits. diff --git a/quicklendx-contracts/src/invoice.rs b/quicklendx-contracts/src/invoice.rs index d7880251..0c98ec4e 100644 --- a/quicklendx-contracts/src/invoice.rs +++ b/quicklendx-contracts/src/invoice.rs @@ -1206,6 +1206,7 @@ impl InvoiceStorage { // Add to the new category index InvoiceStorage::add_category_index(env, &self.category, &self.id); + } /// Get total count of active invoices in the system pub fn get_total_invoice_count(env: &Env) -> u32 { @@ -1215,3 +1216,4 @@ impl InvoiceStorage { .unwrap_or(0) } } +} diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 74a0ba79..bb90e9f0 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -2522,7 +2522,7 @@ impl QuickLendXContract { }) business: Address, period: analytics::TimePeriod, - ) -> Result { + -> Result { let report = analytics::AnalyticsCalculator::generate_business_report(&env, &business, period)?; analytics::AnalyticsStorage::store_business_report(&env, &report); @@ -2912,3 +2912,4 @@ impl QuickLendXContract { (platform, performance) } } +} \ No newline at end of file diff --git a/quicklendx-contracts/src/test_bid.rs b/quicklendx-contracts/src/test_bid.rs index 81f9f7ac..497a0485 100644 --- a/quicklendx-contracts/src/test_bid.rs +++ b/quicklendx-contracts/src/test_bid.rs @@ -67,6 +67,55 @@ fn assert_contract_error( assert_eq!(contract_err, expected); } + /// Create a deterministic 32-byte ID from a small integer seed. + /// The first byte is set to the seed so IDs are visually distinguishable + /// in failure output. + fn make_id(env: &Env, seed: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + BytesN::from_array(env, &bytes) + } + + /// Build a minimal `Placed` bid with a future expiration timestamp. + fn make_placed_bid( + env: &Env, + bid_seed: u8, + invoice_seed: u8, + investor: &Address, + now: u64, + ) -> Bid { + Bid { + bid_id: make_id(env, bid_seed), + invoice_id: make_id(env, invoice_seed), + investor: investor.clone(), + bid_amount: 1_000, + expected_return: 1_100, + timestamp: now, + status: BidStatus::Placed, + // Expires 7 days from now (default TTL) + expiration_timestamp: now + 7 * 86_400, + } + } + + /// Store `count` placed bids for `investor` across distinct invoices and + /// return the bid IDs. + fn place_n_bids( + env: &Env, + investor: &Address, + count: u8, + now: u64, + ) -> Vec, soroban_sdk::Vec>> { + // Soroban Vec + let mut ids = soroban_sdk::Vec::new(env); + for i in 0..count { + let bid = make_placed_bid(env, 100 + i, 200 + i, investor, now); + BidStorage::store_bid(env, &bid); + BidStorage::add_bid_to_invoice(env, &bid.invoice_id, &bid.bid_id); + ids.push_back(bid.bid_id.clone()); + } + ids + } + // ============================================================================ // Category 1: Status Gating - Invoice Verification Required // ============================================================================ @@ -1970,3 +2019,24 @@ fn test_cannot_accept_second_bid_after_first_accepted() { assert_eq!(invoice.funded_amount, 10_000); assert_eq!(invoice.investor, Some(investor1)); } + + /// Active count is 0 for a fresh investor even after many unrelated + /// investors' bids have been stored. + #[test] + fn test_active_count_zero_for_fresh_investor() { + let env = setup(); + let now = 1_000_000u64; + + // Other investors place bids + for _ in 0..5 { + let other = Address::generate(&env); + place_n_bids(&env, &other, 3, now); + } + + let target = Address::generate(&env); + assert_eq!( + BidStorage::count_active_placed_bids_for_investor(&env, &target), + 0, + "fresh investor must have 0 active bids" + ); + }