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
63 changes: 38 additions & 25 deletions docs/contracts/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` |

---

Expand Down Expand Up @@ -127,6 +139,7 @@ fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError>
---

## Running Tests

```bash
cd quicklendx-contracts
cargo test test_queries
Expand Down
26 changes: 22 additions & 4 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
}
}
Expand All @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions quicklendx-contracts/src/test_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<InvoiceStatus>::None,
&0u32,
&10u32,
);
let second_call = client.get_business_invoices_paged(
&business,
&Option::<InvoiceStatus>::None,
&0u32,
&10u32,
);
assert_eq!(first_call, second_call, "Ordering should be deterministic");

let page_0 = client.get_business_invoices_paged(
&business,
&Option::<InvoiceStatus>::None,
&0u32,
&2u32,
);
let page_1 = client.get_business_invoices_paged(
&business,
&Option::<InvoiceStatus>::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();
Expand Down
Loading