diff --git a/docs/contracts/queries.md b/docs/contracts/queries.md index 71f59fa4..af998141 100644 --- a/docs/contracts/queries.md +++ b/docs/contracts/queries.md @@ -23,6 +23,7 @@ to prevent resource abuse and ensure predictable performance characteristics. **All paginated endpoints enforce a hard cap of `MAX_QUERY_LIMIT = 100` records per query.** This limit cannot be bypassed by: + - Passing `limit > MAX_QUERY_LIMIT` (automatically capped) - Using overflow attacks with large offset values (validated and rejected) - Combining parameters to exceed resource bounds (comprehensive validation) @@ -60,37 +61,48 @@ fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError> ## Paginated Endpoints with Hard Cap Enforcement -| Endpoint | Hard Cap Applied | Validation | -|---|---|---| -| `get_business_invoices_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | -| `get_investor_investments_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | -| `get_available_invoices_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | -| `get_bid_history_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | -| `get_investor_bids_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +| Endpoint | Hard Cap Applied | Validation | +| ---------------------------------- | ------------------ | ---------------------- | +| `get_business_invoices_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +| `get_investor_investments_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +| `get_available_invoices_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +| `get_bid_history_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +| `get_investor_bids_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | | `get_whitelisted_currencies_paged` | ✅ MAX_QUERY_LIMIT | ✅ Overflow protection | +### Business Invoice Query Ordering + +`get_business_invoices_paged` applies status filtering first, then deterministically orders +results by `(created_at ASC, invoice_id ASC)` before pagination. + +Security and consistency implications: + +- Repeated calls with identical contract state return identical page boundaries. +- Status-filtered pagination remains deterministic and does not leak cross-status entries. +- Tie-breaking by `invoice_id` avoids validator-dependent ordering when timestamps match. + --- ## Resilience Guarantees by Endpoint -| Endpoint | Missing record behaviour | -|---|---| -| `get_invoice(id)` | Returns `Err(InvoiceNotFound)` | -| `get_bid(id)` | Returns `None` | -| `get_investment(id)` | Returns `Err(StorageKeyNotFound)` | -| `get_invoice_investment(id)` | Returns `Err(StorageKeyNotFound)` | -| `get_bids_for_invoice(id)` | Returns empty `Vec` | -| `get_best_bid(id)` | Returns `None` | -| `get_ranked_bids(id)` | Returns empty `Vec` | -| `get_bids_by_status(id, status)` | Returns empty `Vec` | -| `get_bids_by_investor(id, investor)` | Returns empty `Vec` | -| `get_all_bids_by_investor(investor)` | Returns empty `Vec` | -| `get_business_invoices(business)` | Returns empty `Vec` | -| `get_investments_by_investor(investor)` | Returns empty `Vec` | -| `get_escrow_details(id)` | Returns `Err(StorageKeyNotFound)` | -| `get_bid_history_paged(id, ...)` | Returns empty `Vec` (capped) | -| `get_investor_bids_paged(investor, ...)` | Returns empty `Vec` (capped) | -| `cleanup_expired_bids(id)` | Returns `0` | +| Endpoint | Missing record behaviour | +| ---------------------------------------- | --------------------------------- | +| `get_invoice(id)` | Returns `Err(InvoiceNotFound)` | +| `get_bid(id)` | Returns `None` | +| `get_investment(id)` | Returns `Err(StorageKeyNotFound)` | +| `get_invoice_investment(id)` | Returns `Err(StorageKeyNotFound)` | +| `get_bids_for_invoice(id)` | Returns empty `Vec` | +| `get_best_bid(id)` | Returns `None` | +| `get_ranked_bids(id)` | Returns empty `Vec` | +| `get_bids_by_status(id, status)` | Returns empty `Vec` | +| `get_bids_by_investor(id, investor)` | Returns empty `Vec` | +| `get_all_bids_by_investor(investor)` | Returns empty `Vec` | +| `get_business_invoices(business)` | Returns empty `Vec` | +| `get_investments_by_investor(investor)` | Returns empty `Vec` | +| `get_escrow_details(id)` | Returns `Err(StorageKeyNotFound)` | +| `get_bid_history_paged(id, ...)` | Returns empty `Vec` (capped) | +| `get_investor_bids_paged(investor, ...)` | Returns empty `Vec` (capped) | +| `cleanup_expired_bids(id)` | Returns `0` | --- @@ -127,6 +139,7 @@ fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError> --- ## Running Tests + ```bash cd quicklendx-contracts cargo test test_queries diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index c040c1b8..31a66a8c 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -2006,7 +2006,9 @@ impl QuickLendXContract { /// @param offset Starting index for pagination (0-based) /// @param limit Maximum number of results to return (capped at MAX_QUERY_LIMIT) /// @return Vector of invoice IDs matching the criteria - /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance + /// @dev Enforces MAX_QUERY_LIMIT hard cap for security and performance. + /// Results are deterministically ordered by (created_at ASC, invoice_id ASC) + /// so repeated calls with identical state always produce the same pages. pub fn get_business_invoices_paged( env: Env, business: Address, @@ -2028,10 +2030,26 @@ impl QuickLendXContract { if let Some(invoice) = InvoiceStorage::get_invoice(&env, &invoice_id) { if let Some(status) = &status_filter { if invoice.status == *status { - filtered.push_back(invoice_id); + filtered.push_back((invoice.created_at, invoice_id)); } } else { - filtered.push_back(invoice_id); + filtered.push_back((invoice.created_at, invoice_id)); + } + } + } + + // Deterministic in-place ordering to guarantee stable pagination across repeated calls. + // Primary key: created_at ascending, tie-breaker: invoice_id byte order ascending. + let len = filtered.len(); + for i in 0..len { + for j in 0..len - i - 1 { + let left = filtered.get(j).unwrap(); + let right = filtered.get(j + 1).unwrap(); + let should_swap = left.0 > right.0 + || (left.0 == right.0 && left.1.to_array() > right.1.to_array()); + if should_swap { + filtered.set(j, right); + filtered.set(j + 1, left); } } } @@ -2043,7 +2061,7 @@ impl QuickLendXContract { let end = start.saturating_add(capped_limit).min(len_u32); let mut idx = start; while idx < end { - if let Some(invoice_id) = filtered.get(idx) { + if let Some((_, invoice_id)) = filtered.get(idx) { result.push_back(invoice_id); } idx += 1; diff --git a/quicklendx-contracts/src/test_queries.rs b/quicklendx-contracts/src/test_queries.rs index 4f134d77..d05465b1 100644 --- a/quicklendx-contracts/src/test_queries.rs +++ b/quicklendx-contracts/src/test_queries.rs @@ -129,6 +129,84 @@ fn test_get_business_invoices_paged_limit_is_capped_to_max_query_limit() { ); } +#[test] +fn test_get_business_invoices_paged_status_filter_and_deterministic_ordering() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let _ = client.set_admin(&admin); + + let business = Address::generate(&env); + + let pending_1 = create_invoice( + &env, + &client, + &business, + 1_000, + InvoiceCategory::Services, + false, + ); + let verified_1 = create_invoice( + &env, + &client, + &business, + 2_000, + InvoiceCategory::Services, + true, + ); + let pending_2 = create_invoice( + &env, + &client, + &business, + 3_000, + InvoiceCategory::Products, + false, + ); + + let verified_only = client.get_business_invoices_paged( + &business, + &Some(InvoiceStatus::Verified), + &0u32, + &10u32, + ); + assert_eq!(verified_only.len(), 1, "Expected only verified invoices"); + assert!(verified_only.contains(&verified_1)); + assert!(!verified_only.contains(&pending_1)); + assert!(!verified_only.contains(&pending_2)); + + let first_call = client.get_business_invoices_paged( + &business, + &Option::::None, + &0u32, + &10u32, + ); + let second_call = client.get_business_invoices_paged( + &business, + &Option::::None, + &0u32, + &10u32, + ); + assert_eq!(first_call, second_call, "Ordering should be deterministic"); + + let page_0 = client.get_business_invoices_paged( + &business, + &Option::::None, + &0u32, + &2u32, + ); + let page_1 = client.get_business_invoices_paged( + &business, + &Option::::None, + &2u32, + &2u32, + ); + + assert_eq!(page_0.len(), 2); + assert_eq!(page_1.len(), 1); + assert!(!page_0.contains(&page_1.get(0).unwrap())); +} + #[test] fn test_get_available_invoices_paged_filters_and_bounds() { let (env, client) = setup();