This document describes the settlement module's payment record storage architecture, bounded storage design, and security guarantees.
The settlement module handles invoice payment processing with support for partial payments, durable per-payment storage records, and finalization safety guards. A critical design goal is preventing unbounded storage growth that could lead to denial-of-service (DoS) attacks or gas limit issues.
Payment records are stored in Soroban persistent storage using the following key structure:
enum SettlementDataKey {
PaymentCount(BytesN<32>), // Current count of payments for an invoice
Payment(BytesN<32>, u32), // Individual payment record by index
PaymentNonce(BytesN<32>, String), // Nonce tracking for replay protection
Finalized(BytesN<32>), // Settlement finalization flag
}Each payment record contains:
pub struct SettlementPaymentRecord {
pub payer: Address, // Address that made the payment
pub amount: i128, // Amount applied (may be capped from requested)
pub timestamp: u64, // Ledger timestamp when recorded
pub nonce: String, // Transaction identifier for deduplication
}To prevent unbounded storage growth and protect against payment-count overflow, the contract enforces a hard cap on the number of payment records per invoice:
| Constant | Value | Purpose |
|---|---|---|
MAX_PAYMENT_COUNT |
1,000 | Maximum discrete payment records per invoice |
MAX_INLINE_PAYMENT_HISTORY |
32 | Maximum payments stored inline in the Invoice struct |
-
DoS Prevention: Without a cap, an attacker could force the contract to store unlimited payment records, exhausting storage quotas and increasing costs for all users.
-
Gas Limit Protection: Query operations that iterate over payment records (e.g.,
get_payment_records) must complete within Soroban's compute limits. A bounded count ensures predictable gas costs. -
Overflow Prevention: The payment count is stored as
u32. Whileu32::MAX(4.2 billion) is theoretically safe, practical limits prevent edge cases and ensure reasonable usage patterns. -
Storage Cost predictability: Users and the protocol can predict maximum storage costs per invoice.
test_partial_payment_rejected_after_explicit_settlementensures explicit settlement blocks further partial payments and produces no side effects.test_settlement_idempotency_no_side_effectsverifies repeated settlement attempts are rejected and cause no additional balance, event, or accounting changes.
Before disbursing funds, the settlement engine asserts:
The cap is enforced in record_payment():
let payment_count = get_payment_count_internal(env, invoice_id);
// Guard against unbounded payment record growth.
if payment_count >= MAX_PAYMENT_COUNT {
return Err(QuickLendXError::OperationNotAllowed);
}| Scenario | Behavior |
|---|---|
| Payment count < 1000 | Payment accepted and recorded |
| Payment count = 1000 | Payment rejected with OperationNotAllowed |
| Payment count > 1000 | Impossible (cannot exceed cap) |
In addition to the payment count cap, the contract includes secondary defenses:
-
Status Guard: Once an invoice is marked as
Paid, further payment attempts are rejected withInvalidStatus. -
Finalization Flag: The
Finalizedstorage key marks an invoice as settled, preventing double-settlement. -
Overpayment Capping: If a payment would exceed the remaining due amount, only the remaining amount is applied.
All query functions remain stable and efficient near the cap boundary:
Returns the total number of recorded payments for an invoice. O(1) operation.
Returns a single payment record by index. O(1) operation. Returns StorageKeyNotFound if index is out of bounds.
Returns a paginated slice of payment records. Records are returned in chronological order (index 0 = first payment).
Parameters:
from: Starting index (inclusive)limit: Maximum number of records to return (capped atMAX_QUERY_LIMITfor gas safety)
Behavior near cap:
- Querying beyond available records returns an empty vector
- Partial pages at the end return only available records
- Full range query (0, 1000) at cap returns all 1000 records
Returns aggregate payment progress including:
total_due: Original invoice amounttotal_paid: Sum of all applied paymentsremaining_due: Amount still owedprogress_percent: Percentage paid (0-100)payment_count: Number of recorded paymentsstatus: Current invoice status
| Error | Code | When Returned |
|---|---|---|
OperationNotAllowed |
2001 | Payment count has reached MAX_PAYMENT_COUNT |
InvalidStatus |
1003 | Invoice is already Paid, Cancelled, or not in Funded state |
InvalidAmount |
1200 | Payment amount ≤ 0, or would cause accounting invariant violation |
InvoiceNotFound |
1001 | Invoice ID does not exist |
StorageKeyNotFound |
3001 | Payment record at index does not exist |
NotBusinessOwner |
1401 | Payer is not the invoice business |
-
Storage DoS: An attacker attempts to exhaust contract storage by creating many small payments.
- Mitigation:
MAX_PAYMENT_COUNTcap prevents unbounded growth.
- Mitigation:
-
Gas Exhaustion: An attacker creates enough payments to make queries exceed gas limits.
- Mitigation: Cap ensures maximum iteration count;
get_payment_recordsenforcesMAX_QUERY_LIMIT.
- Mitigation: Cap ensures maximum iteration count;
-
Replay Attacks: An attacker replays a valid payment transaction.
- Mitigation: Nonce-based deduplication tracks seen transaction IDs per invoice.
-
Double Settlement: An attacker attempts to settle an already-settled invoice.
- Mitigation:
Finalizedflag and status checks prevent re-entry.
- Mitigation:
-
total_paid <= total_due: Enforced at every payment recording step. Amounts are capped to prevent overpayment. -
Payment count bounded:
payment_count <= MAX_PAYMENT_COUNTfor all invoices. -
Idempotent settlement: Once
status == Paid, further settlement attempts are rejected. -
Accounting identity:
investor_return + platform_fee == total_paidis asserted before fund disbursement.
- Payment recording requires authorization from the invoice business address.
- The business must explicitly approve each payment via Soroban's
require_auth().
The payment count cap enforcement is validated by comprehensive tests in test_partial_payments.rs:
| Test | Purpose |
|---|---|
test_payment_count_cap_is_enforced |
Verifies 1001st payment is rejected |
test_payment_just_before_cap_succeeds |
Verifies 1000th payment succeeds |
test_queries_stable_near_cap |
Validates query correctness with 999 payments |
test_pagination_at_cap_boundary |
Tests pagination at exactly 1000 records |
test_cap_is_per_invoice_not_global |
Confirms cap is per-invoice, not global |
test_settled_invoice_rejects_additional_payments |
Status guard validation |
test_finalization_flag_after_settlement |
Finalization flag correctness |
test_payment_record_fields_are_complete |
Record structure validation |
test_duplicate_nonce_does_not_increment_count |
Replay protection |
test_empty_nonce_each_creates_separate_record |
Empty nonce handling |
// Record a partial payment
let result = record_payment(
&env,
&invoice_id,
&business_address,
500_000, // Payment amount
"tx-hash-123".to_string(), // Unique transaction ID
);// Get current payment progress
let progress = get_invoice_progress(&env, &invoice_id)?;
println!("Paid: {}/{} ({}%)",
progress.total_paid,
progress.total_due,
progress.progress_percent
);
println!("Payment count: {}", progress.payment_count);// Get first 50 payment records
let records = get_payment_records(&env, &invoice_id, 0, 50)?;
for record in records.iter() {
println!("Payment: {} from {} at {}",
record.amount,
record.payer,
record.timestamp
);
}- Implementation:
quicklendx-contracts/src/settlement.rs - Tests:
quicklendx-contracts/src/test_partial_payments.rs - Error definitions:
quicklendx-contracts/src/errors.rs - Related:
docs/contracts/limits.md,docs/contracts/invoice.md