diff --git a/docs/contracts/defaults.md b/docs/contracts/defaults.md index ddfb0c00..ca7409df 100644 --- a/docs/contracts/defaults.md +++ b/docs/contracts/defaults.md @@ -1,316 +1,316 @@ -# Default Handling and Grace Period - -## Overview - -The QuickLendX protocol implements strict access control and status validation for manual invoice default marking. A configurable grace period mechanism gives businesses additional time before an invoice is formally marked as defaulted, protecting all parties while maintaining accountability. - -For the full default handling lifecycle and frontend integration guide, see [default-handling.md](./default-handling.md). - -## Security Model - -### Access Control - -Manual default marking is **admin-only**: -- Requires `require_auth` on the configured admin address -- Non-admin callers receive `NotAdmin` error -- Authorization is enforced at the contract entry point in `lib.rs` - -### Status Validation - -Only invoices in **Funded** status can be manually defaulted: -- Prevents premature default marking on unbacked invoices -- Ensures investment relationship exists before default processing -- Returns `InvoiceNotAvailableForFunding` for non-Funded invoices - -### Validation Order - -Manual default marking validates in the following strict order: - -1. **Invoice existence** - Must exist in storage -2. **Already defaulted** - Prevents double-default (returns `InvoiceAlreadyDefaulted`) -3. **Funded status** - Only `Funded` invoices eligible (returns `InvoiceNotAvailableForFunding`) -4. **Grace period expiry** - Current time must exceed deadline (returns `OperationNotAllowed`) - -## Core Functions - -### `mark_invoice_defaulted(invoice_id, grace_period)` - -Public contract entry point for marking an invoice as defaulted. - -**Authorization:** Admin only (`require_auth` on the configured admin address). - -**Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `invoice_id` | `BytesN<32>` | The invoice to mark as defaulted | -| `grace_period` | `Option` | Grace period in seconds. Defaults to 7 days (604,800s) if `None` | - -**Validation order:** - -1. Admin authentication check -2. Invoice existence check -3. Already-defaulted check (prevents double default) -4. Funded status check (only funded invoices can default) -5. Grace period expiry check (`current_timestamp > due_date + grace_period`) - -**Errors:** - -| Error | Code | Condition | -|-------|------|-----------| -| `NotAdmin` | 1103 | Caller is not the configured admin | -| `InvoiceNotFound` | 1000 | Invoice ID does not exist | -| `InvoiceAlreadyDefaulted` | 1006 | Invoice has already been defaulted | -| `InvoiceNotAvailableForFunding` | 1001 | Invoice is not in `Funded` status | -| `OperationNotAllowed` | 1402 | Grace period has not yet expired | - -### `handle_default(invoice_id)` - -Lower-level contract entry point that performs the default without grace period checks. Also requires admin authorization. - -**Authorization:** Admin only. - -**Behavior:** - -1. Validates invoice exists and is in `Funded` status -2. Removes invoice from the `Funded` status list -3. Sets invoice status to `Defaulted` -4. Adds invoice to the `Defaulted` status list -5. Emits `invoice_expired` and `invoice_defaulted` events -6. Updates linked investment status to `Defaulted` -7. Processes insurance claims if coverage exists -8. Sends default notification -9. Updates investor analytics (failed investment) - -### Validation Helper Functions - -The module provides granular validation functions for external use: - -#### `validate_invoice_for_default(env, invoice_id)` - -Validates that an invoice exists and is eligible for default marking. - -**Returns:** -- `Ok(())` if invoice is eligible -- `Err(InvoiceNotFound)` if not found -- `Err(InvoiceAlreadyDefaulted)` if already defaulted -- `Err(InvoiceNotAvailableForFunding)` if not in Funded status - -#### `validate_grace_period_expired(env, invoice_id, grace_period)` - -Validates that the grace period has expired for an invoice. - -**Returns:** -- `Ok(())` if grace period has expired -- `Err(OperationNotAllowed)` if grace period has not expired - -#### `can_mark_as_defaulted(env, invoice_id, grace_period)` - -Read-only helper for UI pre-validation. Combines all checks. - -**Returns:** -- `Ok(true)` if invoice can be defaulted -- `Err(QuickLendXError)` with specific reason if cannot be defaulted - -## Grace Period - -### Configuration - -The default grace period is defined in `src/defaults.rs`: - -```rust -pub const DEFAULT_GRACE_PERIOD: u64 = 7 * 24 * 60 * 60; // 7 days -``` - -Grace period resolution order: -1. `grace_period` argument (per-call override) -2. Protocol config (`ProtocolInitializer::get_protocol_config`) -3. `DEFAULT_GRACE_PERIOD` (7 days) - -### Calculation - -``` -grace_deadline = invoice.due_date + grace_period -can_default = current_timestamp > grace_deadline -``` - -The check uses strict greater-than (`>`), meaning the invoice cannot be defaulted at exactly the deadline timestamp — only after it. - -### Examples - -| Scenario | Due Date | Grace Period | Deadline | Current Time | Can Default? | -|----------|----------|-------------|----------|-------------|-------------| -| Default 7-day grace | Day 0 | 7 days | Day 7 | Day 8 | Yes | -| Before grace expires | Day 0 | 7 days | Day 7 | Day 3 | No | -| Exactly at deadline | Day 0 | 7 days | Day 7 | Day 7 | No | -| Custom 3-day grace | Day 0 | 3 days | Day 3 | Day 4 | Yes | -| Zero grace period | Day 0 | 0 seconds | Day 0 | Day 0 + 1s | Yes | - -## State Transitions - -``` -Invoice: Funded ──→ Defaulted -Investment: Active ──→ Defaulted -``` - -When an invoice is defaulted: - -- **Status lists** are updated (removed from `Funded`, added to `Defaulted`) -- **Investment status** is set to `Defaulted` -- **Insurance claims** are processed automatically if coverage exists -- **Investor analytics** are updated to reflect the failed investment -- **Events emitted:** `invoice_expired`, `invoice_defaulted`, and optionally `insurance_claimed` -- **Notifications** are sent to relevant parties - -## Security Features - -### Admin-Only Access - -Both `mark_invoice_defaulted` and `handle_default` require `require_auth` from the configured admin address. This prevents unauthorized parties from triggering defaults. - -### No Double Default - -Attempting to default an already-defaulted invoice returns `InvoiceAlreadyDefaulted` (1006). This prevents duplicate default processing and ensures idempotent behavior. - -### Check Ordering - -The defaulted-status check runs before the funded-status check so that double-default attempts receive the correct, specific error. - -### Grace Period Enforcement - -Invoices cannot be defaulted before `due_date + grace_period` has elapsed. This protects borrowers during the grace period. - -### Overflow Protection - -`grace_deadline` uses `saturating_add` to prevent timestamp overflow when adding grace period to due date. - -### Idempotent Operations - -The validation functions can be safely called multiple times without side effects. Only `handle_default` performs state mutations. - -## Test Coverage - -Tests are in `src/test_default.rs` and `src/test_errors.rs`: - -### Default Tests (`test_default.rs`) - -| Test | Description | -|------|-------------| -| `test_default_after_grace_period` | Default succeeds after grace period expires | -| `test_no_default_before_grace_period` | Default rejected during grace period | -| `test_cannot_default_unfunded_invoice` | Verified-only invoice cannot be defaulted | -| `test_cannot_default_pending_invoice` | Pending invoice cannot be defaulted | -| `test_cannot_default_already_defaulted_invoice` | Double default returns `InvoiceAlreadyDefaulted` | -| `test_custom_grace_period` | Custom 3-day grace period works correctly | -| `test_default_uses_default_grace_period_when_none_provided` | `None` grace period uses 7-day default | -| `test_default_status_transition` | Status lists updated correctly | -| `test_default_investment_status_update` | Investment status changes to `Defaulted` | -| `test_default_exactly_at_grace_deadline` | Boundary: cannot default at exact deadline, can at deadline+1 | -| `test_multiple_invoices_default_handling` | Independent invoices default independently | -| `test_zero_grace_period_defaults_immediately_after_due_date` | Zero grace allows immediate default after due date | -| `test_cannot_default_paid_invoice` | Paid invoices cannot be defaulted | -| `test_mark_default_requires_admin_auth` | Admin authorization is enforced | -| `test_validate_invoice_for_default_rejects_not_found` | Invalid invoice ID rejected | -| `test_validate_invoice_for_default_rejects_cancelled` | Cancelled invoices rejected | -| `test_validate_invoice_for_default_rejects_refunded` | Refunded invoices rejected | -| `test_grace_period_exactly_one_second_before_deadline` | One second before deadline rejected | -| `test_grace_period_one_second_after_deadline` | One second after deadline accepted | -| `test_very_long_grace_period` | Very long grace periods work correctly | -| `test_double_default_returns_same_error` | Idempotent error for double default | -| `test_investment_status_transitions_on_default` | Investment status updates correctly | -| `test_status_lists_updated_atomically` | Status lists transition correctly | -| `test_grace_period_uses_protocol_config` | Protocol config is honored | -| `test_per_call_grace_overrides_protocol_config` | Per-call override works | - -### Error Tests (`test_errors.rs`) - -| Test | Description | -|------|-------------| -| `test_manual_default_not_admin_error` | Non-admin returns NotAdmin error | -| `test_manual_default_invoice_not_found_error` | Invalid ID returns InvoiceNotFound | -| `test_manual_default_already_defaulted_error` | Double default returns InvoiceAlreadyDefaulted | -| `test_manual_default_not_funded_error` | Non-funded returns InvoiceNotAvailableForFunding | -| `test_manual_default_grace_period_not_expired_error` | Early default returns OperationNotAllowed | -| `test_default_cannot_mark_pending_invoice` | Pending invoices rejected | -| `test_default_cannot_mark_cancelled_invoice` | Cancelled invoices rejected | -| `test_default_cannot_mark_paid_invoice` | Paid invoices rejected | -| `test_default_cannot_mark_refunded_invoice` | Refunded invoices rejected | -| `test_default_error_codes_are_correct` | Error codes match expected values | -| `test_no_panic_on_invalid_invoice_in_default` | Invalid IDs return errors, no panics | - -Run tests: - -```bash -cd quicklendx-contracts -cargo test test_default -- --nocapture -cargo test test_errors -- --nocapture -``` - -Run with coverage: - -```bash -cargo test -- --nocapture -``` - -## Frontend Integration - -### Checking if Invoice Can Be Defaulted - -```typescript -async function canMarkAsDefaulted(invoiceId: string): Promise { - try { - const gracePeriod = 7 * 24 * 60 * 60; // 7 days - await contract.mark_invoiceDefaulted(invoiceId, gracePeriod); - return true; - } catch (error) { - if (error.code === 1006) { // InvoiceAlreadyDefaulted - return false; // Already defaulted - } - if (error.code === 1402) { // OperationNotAllowed - return false; // Grace period not expired - } - if (error.code === 1001) { // InvoiceNotAvailableForFunding - return false; // Not in Funded status - } - throw error; // Unexpected error - } -} -``` - -### Error Handling - -```typescript -try { - await contract.markInvoiceDefaulted(invoiceId, gracePeriod); -} catch (error) { - switch (error.code) { - case 1103: // NotAdmin - console.error("Only admin can mark invoices as defaulted"); - break; - case 1000: // InvoiceNotFound - console.error("Invoice does not exist"); - break; - case 1006: // InvoiceAlreadyDefaulted - console.error("Invoice is already defaulted"); - break; - case 1001: // InvoiceNotAvailableForFunding - console.error("Invoice must be in Funded status"); - break; - case 1402: // OperationNotAllowed - console.error("Grace period has not expired"); - break; - } -} -``` - -## Audit Trail - -The following events are emitted during default operations: - -| Event | Description | -|-------|-------------| -| `invoice_expired` | Invoice has passed its due date + grace period | -| `invoice_defaulted` | Invoice has been marked as defaulted | -| `insurance_claimed` | Insurance claim processed for defaulted invoice | - -These events provide a complete audit trail for compliance and dispute resolution. +# Default Handling and Grace Period + +## Overview + +The QuickLendX protocol implements strict access control and status validation for manual invoice default marking. A configurable grace period mechanism gives businesses additional time before an invoice is formally marked as defaulted, protecting all parties while maintaining accountability. + +For the full default handling lifecycle and frontend integration guide, see [default-handling.md](./default-handling.md). + +## Security Model + +### Access Control + +Manual default marking is **admin-only**: +- Requires `require_auth` on the configured admin address +- Non-admin callers receive `NotAdmin` error +- Authorization is enforced at the contract entry point in `lib.rs` + +### Status Validation + +Only invoices in **Funded** status can be manually defaulted: +- Prevents premature default marking on unbacked invoices +- Ensures investment relationship exists before default processing +- Returns `InvoiceNotAvailableForFunding` for non-Funded invoices + +### Validation Order + +Manual default marking validates in the following strict order: + +1. **Invoice existence** - Must exist in storage +2. **Already defaulted** - Prevents double-default (returns `InvoiceAlreadyDefaulted`) +3. **Funded status** - Only `Funded` invoices eligible (returns `InvoiceNotAvailableForFunding`) +4. **Grace period expiry** - Current time must exceed deadline (returns `OperationNotAllowed`) + +## Core Functions + +### `mark_invoice_defaulted(invoice_id, grace_period)` + +Public contract entry point for marking an invoice as defaulted. + +**Authorization:** Admin only (`require_auth` on the configured admin address). + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `invoice_id` | `BytesN<32>` | The invoice to mark as defaulted | +| `grace_period` | `Option` | Grace period in seconds. Defaults to 7 days (604,800s) if `None` | + +**Validation order:** + +1. Admin authentication check +2. Invoice existence check +3. Already-defaulted check (prevents double default) +4. Funded status check (only funded invoices can default) +5. Grace period expiry check (`current_timestamp > due_date + grace_period`) + +**Errors:** + +| Error | Code | Condition | +|-------|------|-----------| +| `NotAdmin` | 1103 | Caller is not the configured admin | +| `InvoiceNotFound` | 1000 | Invoice ID does not exist | +| `InvoiceAlreadyDefaulted` | 1006 | Invoice has already been defaulted | +| `InvoiceNotAvailableForFunding` | 1001 | Invoice is not in `Funded` status | +| `OperationNotAllowed` | 1402 | Grace period has not yet expired | + +### `handle_default(invoice_id)` + +Lower-level contract entry point that performs the default without grace period checks. Also requires admin authorization. + +**Authorization:** Admin only. + +**Behavior:** + +1. Validates invoice exists and is in `Funded` status +2. Removes invoice from the `Funded` status list +3. Sets invoice status to `Defaulted` +4. Adds invoice to the `Defaulted` status list +5. Emits `invoice_expired` and `invoice_defaulted` events +6. Updates linked investment status to `Defaulted` +7. Processes insurance claims if coverage exists +8. Sends default notification +9. Updates investor analytics (failed investment) + +### Validation Helper Functions + +The module provides granular validation functions for external use: + +#### `validate_invoice_for_default(env, invoice_id)` + +Validates that an invoice exists and is eligible for default marking. + +**Returns:** +- `Ok(())` if invoice is eligible +- `Err(InvoiceNotFound)` if not found +- `Err(InvoiceAlreadyDefaulted)` if already defaulted +- `Err(InvoiceNotAvailableForFunding)` if not in Funded status + +#### `validate_grace_period_expired(env, invoice_id, grace_period)` + +Validates that the grace period has expired for an invoice. + +**Returns:** +- `Ok(())` if grace period has expired +- `Err(OperationNotAllowed)` if grace period has not expired + +#### `can_mark_as_defaulted(env, invoice_id, grace_period)` + +Read-only helper for UI pre-validation. Combines all checks. + +**Returns:** +- `Ok(true)` if invoice can be defaulted +- `Err(QuickLendXError)` with specific reason if cannot be defaulted + +## Grace Period + +### Configuration + +The default grace period is defined in `src/defaults.rs`: + +```rust +pub const DEFAULT_GRACE_PERIOD: u64 = 7 * 24 * 60 * 60; // 7 days +``` + +Grace period resolution order: +1. `grace_period` argument (per-call override) +2. Protocol config (`ProtocolInitializer::get_protocol_config`) +3. `DEFAULT_GRACE_PERIOD` (7 days) + +### Calculation + +``` +grace_deadline = invoice.due_date + grace_period +can_default = current_timestamp > grace_deadline +``` + +The check uses strict greater-than (`>`), meaning the invoice cannot be defaulted at exactly the deadline timestamp — only after it. + +### Examples + +| Scenario | Due Date | Grace Period | Deadline | Current Time | Can Default? | +|----------|----------|-------------|----------|-------------|-------------| +| Default 7-day grace | Day 0 | 7 days | Day 7 | Day 8 | Yes | +| Before grace expires | Day 0 | 7 days | Day 7 | Day 3 | No | +| Exactly at deadline | Day 0 | 7 days | Day 7 | Day 7 | No | +| Custom 3-day grace | Day 0 | 3 days | Day 3 | Day 4 | Yes | +| Zero grace period | Day 0 | 0 seconds | Day 0 | Day 0 + 1s | Yes | + +## State Transitions + +``` +Invoice: Funded ──→ Defaulted +Investment: Active ──→ Defaulted +``` + +When an invoice is defaulted: + +- **Status lists** are updated (removed from `Funded`, added to `Defaulted`) +- **Investment status** is set to `Defaulted` +- **Insurance claims** are processed automatically if coverage exists +- **Investor analytics** are updated to reflect the failed investment +- **Events emitted:** `invoice_expired`, `invoice_defaulted`, and optionally `insurance_claimed` +- **Notifications** are sent to relevant parties + +## Security Features + +### Admin-Only Access + +Both `mark_invoice_defaulted` and `handle_default` require `require_auth` from the configured admin address. This prevents unauthorized parties from triggering defaults. + +### No Double Default + +Attempting to default an already-defaulted invoice returns `InvoiceAlreadyDefaulted` (1006). This prevents duplicate default processing and ensures idempotent behavior. + +### Check Ordering + +The defaulted-status check runs before the funded-status check so that double-default attempts receive the correct, specific error. + +### Grace Period Enforcement + +Invoices cannot be defaulted before `due_date + grace_period` has elapsed. This protects borrowers during the grace period. + +### Overflow Protection + +`grace_deadline` uses `saturating_add` to prevent timestamp overflow when adding grace period to due date. + +### Idempotent Operations + +The validation functions can be safely called multiple times without side effects. Only `handle_default` performs state mutations. + +## Test Coverage + +Tests are in `src/test_default.rs` and `src/test_errors.rs`: + +### Default Tests (`test_default.rs`) + +| Test | Description | +|------|-------------| +| `test_default_after_grace_period` | Default succeeds after grace period expires | +| `test_no_default_before_grace_period` | Default rejected during grace period | +| `test_cannot_default_unfunded_invoice` | Verified-only invoice cannot be defaulted | +| `test_cannot_default_pending_invoice` | Pending invoice cannot be defaulted | +| `test_cannot_default_already_defaulted_invoice` | Double default returns `InvoiceAlreadyDefaulted` | +| `test_custom_grace_period` | Custom 3-day grace period works correctly | +| `test_default_uses_default_grace_period_when_none_provided` | `None` grace period uses 7-day default | +| `test_default_status_transition` | Status lists updated correctly | +| `test_default_investment_status_update` | Investment status changes to `Defaulted` | +| `test_default_exactly_at_grace_deadline` | Boundary: cannot default at exact deadline, can at deadline+1 | +| `test_multiple_invoices_default_handling` | Independent invoices default independently | +| `test_zero_grace_period_defaults_immediately_after_due_date` | Zero grace allows immediate default after due date | +| `test_cannot_default_paid_invoice` | Paid invoices cannot be defaulted | +| `test_mark_default_requires_admin_auth` | Admin authorization is enforced | +| `test_validate_invoice_for_default_rejects_not_found` | Invalid invoice ID rejected | +| `test_validate_invoice_for_default_rejects_cancelled` | Cancelled invoices rejected | +| `test_validate_invoice_for_default_rejects_refunded` | Refunded invoices rejected | +| `test_grace_period_exactly_one_second_before_deadline` | One second before deadline rejected | +| `test_grace_period_one_second_after_deadline` | One second after deadline accepted | +| `test_very_long_grace_period` | Very long grace periods work correctly | +| `test_double_default_returns_same_error` | Idempotent error for double default | +| `test_investment_status_transitions_on_default` | Investment status updates correctly | +| `test_status_lists_updated_atomically` | Status lists transition correctly | +| `test_grace_period_uses_protocol_config` | Protocol config is honored | +| `test_per_call_grace_overrides_protocol_config` | Per-call override works | + +### Error Tests (`test_errors.rs`) + +| Test | Description | +|------|-------------| +| `test_manual_default_not_admin_error` | Non-admin returns NotAdmin error | +| `test_manual_default_invoice_not_found_error` | Invalid ID returns InvoiceNotFound | +| `test_manual_default_already_defaulted_error` | Double default returns InvoiceAlreadyDefaulted | +| `test_manual_default_not_funded_error` | Non-funded returns InvoiceNotAvailableForFunding | +| `test_manual_default_grace_period_not_expired_error` | Early default returns OperationNotAllowed | +| `test_default_cannot_mark_pending_invoice` | Pending invoices rejected | +| `test_default_cannot_mark_cancelled_invoice` | Cancelled invoices rejected | +| `test_default_cannot_mark_paid_invoice` | Paid invoices rejected | +| `test_default_cannot_mark_refunded_invoice` | Refunded invoices rejected | +| `test_default_error_codes_are_correct` | Error codes match expected values | +| `test_no_panic_on_invalid_invoice_in_default` | Invalid IDs return errors, no panics | + +Run tests: + +```bash +cd quicklendx-contracts +cargo test test_default -- --nocapture +cargo test test_errors -- --nocapture +``` + +Run with coverage: + +```bash +cargo test -- --nocapture +``` + +## Frontend Integration + +### Checking if Invoice Can Be Defaulted + +```typescript +async function canMarkAsDefaulted(invoiceId: string): Promise { + try { + const gracePeriod = 7 * 24 * 60 * 60; // 7 days + await contract.mark_invoiceDefaulted(invoiceId, gracePeriod); + return true; + } catch (error) { + if (error.code === 1006) { // InvoiceAlreadyDefaulted + return false; // Already defaulted + } + if (error.code === 1402) { // OperationNotAllowed + return false; // Grace period not expired + } + if (error.code === 1001) { // InvoiceNotAvailableForFunding + return false; // Not in Funded status + } + throw error; // Unexpected error + } +} +``` + +### Error Handling + +```typescript +try { + await contract.markInvoiceDefaulted(invoiceId, gracePeriod); +} catch (error) { + switch (error.code) { + case 1103: // NotAdmin + console.error("Only admin can mark invoices as defaulted"); + break; + case 1000: // InvoiceNotFound + console.error("Invoice does not exist"); + break; + case 1006: // InvoiceAlreadyDefaulted + console.error("Invoice is already defaulted"); + break; + case 1001: // InvoiceNotAvailableForFunding + console.error("Invoice must be in Funded status"); + break; + case 1402: // OperationNotAllowed + console.error("Grace period has not expired"); + break; + } +} +``` + +## Audit Trail + +The following events are emitted during default operations: + +| Event | Description | +|-------|-------------| +| `invoice_expired` | Invoice has passed its due date + grace period | +| `invoice_defaulted` | Invoice has been marked as defaulted | +| `insurance_claimed` | Insurance claim processed for defaulted invoice | + +These events provide a complete audit trail for compliance and dispute resolution. diff --git a/quicklendx-contracts/src/admin.rs b/quicklendx-contracts/src/admin.rs index 97f5b59b..49748b7d 100644 --- a/quicklendx-contracts/src/admin.rs +++ b/quicklendx-contracts/src/admin.rs @@ -27,6 +27,8 @@ //! - `ADMIN_INITIALIZED_KEY`: Initialization flag (prevents re-initialization) //! - `ADMIN_TRANSFER_LOCK_KEY`: Transfer lock (prevents concurrent transfers) +#![allow(dead_code)] + use crate::errors::QuickLendXError; use soroban_sdk::{symbol_short, Address, Env, Symbol}; diff --git a/quicklendx-contracts/src/analytics.rs b/quicklendx-contracts/src/analytics.rs index d4670007..cb405487 100644 --- a/quicklendx-contracts/src/analytics.rs +++ b/quicklendx-contracts/src/analytics.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::errors::QuickLendXError; use crate::invoice::{InvoiceCategory, InvoiceStatus}; use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec}; diff --git a/quicklendx-contracts/src/audit.rs b/quicklendx-contracts/src/audit.rs index 3994347b..962fd14a 100644 --- a/quicklendx-contracts/src/audit.rs +++ b/quicklendx-contracts/src/audit.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::errors::QuickLendXError; use crate::invoice::{Invoice, InvoiceStatus}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index 31521bb7..57e60a23 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -4,6 +4,8 @@ use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Symbol, Vec} use crate::admin::AdminStorage; use crate::errors::QuickLendXError; use crate::events::{emit_bid_expired, emit_bid_ttl_updated}; +// Re-export from crate::types so other modules can continue to import from crate::bid. +pub use crate::types::{Bid, BidStatus}; // ─── Bid TTL configuration ──────────────────────────────────────────────────── // diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index e8bef943..ac1d88b4 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -14,6 +14,50 @@ pub const MAX_OVERDUE_SCAN_BATCH_LIMIT: u32 = 100; const OVERDUE_SCAN_CURSOR_KEY: soroban_sdk::Symbol = symbol_short!("ovd_scan"); +/// Storage key for default transition guards. +/// Format: (symbol_short!("def_guard"), invoice_id) -> bool +const DEFAULT_TRANSITION_GUARD_KEY: soroban_sdk::Symbol = symbol_short!("def_guard"); + +/// Transition guard to ensure default transitions are atomic and idempotent. +/// Tracks whether a default transition has been initiated for a specific invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransitionGuard { + /// Whether the default transition has been triggered + pub triggered: bool, +} + +/// @notice Checks if a default transition guard exists for the given invoice. +/// @dev Returns true if the guard is set (transition already attempted), false otherwise. +/// @param env The contract environment. +/// @param invoice_id The invoice ID to check. +/// @return true if default transition has been guarded, false otherwise. +fn is_default_transition_guarded(env: &Env, invoice_id: &BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&(DEFAULT_TRANSITION_GUARD_KEY, invoice_id)) +} + +/// @notice Atomically checks and sets the default transition guard. +/// @dev This ensures that only one default transition can be initiated per invoice. +/// If the guard is already set, returns DuplicateDefaultTransition error. +/// Otherwise, sets the guard and returns Ok(()). +/// @param env The contract environment. +/// @param invoice_id The invoice ID to guard. +/// @return Ok(()) if guard was successfully set, Err(DuplicateDefaultTransition) if already guarded. +fn check_and_set_default_guard(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { + let key = (DEFAULT_TRANSITION_GUARD_KEY, invoice_id); + + // Check if guard is already set + if env.storage().persistent().has(&key) { + return Err(QuickLendXError::DuplicateDefaultTransition); + } + + // Set the guard atomically + env.storage().persistent().set(&key, &true); + Ok(()) +} + /// Result metadata returned by the bounded overdue invoice scanner. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -227,7 +271,12 @@ pub fn scan_funded_invoice_expirations( /// @notice Applies the default transition after all time and status checks have passed. /// @dev This helper does not re-check the grace-period cutoff and must only be reached from /// validated call sites such as `mark_invoice_defaulted` or `check_and_handle_expiration`. +/// The transition guard ensures atomicity and idempotency of default operations. +/// @security The guard prevents race conditions and duplicate side effects (analytics, state initialization). pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { + // Atomically check and set the transition guard to prevent duplicate defaults + check_and_set_default_guard(env, invoice_id)?; + let mut invoice = InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; diff --git a/quicklendx-contracts/src/events.rs b/quicklendx-contracts/src/events.rs index e6542beb..6f988cc6 100644 --- a/quicklendx-contracts/src/events.rs +++ b/quicklendx-contracts/src/events.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use crate::bid::Bid; use crate::fees::FeeType; use crate::invoice::{Invoice, InvoiceMetadata}; diff --git a/quicklendx-contracts/src/fees.rs b/quicklendx-contracts/src/fees.rs index bc4e9f16..10f30be3 100644 --- a/quicklendx-contracts/src/fees.rs +++ b/quicklendx-contracts/src/fees.rs @@ -1,3 +1,7 @@ +//! Fee management module for the QuickLendX protocol. +//! +//! Handles platform fee configuration, revenue tracking, volume-tier discounts, +//! and treasury routing for all fee types supported by the protocol. use crate::errors::QuickLendXError; use crate::events; use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, Map, Symbol, Vec}; @@ -6,6 +10,7 @@ use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, Map, Symbol, Ve const MAX_FEE_BPS: u32 = 1000; // 10% hard cap for all fees #[allow(dead_code)] const MIN_FEE_BPS: u32 = 0; +/// Basis-point denominator for percentage calculations (100% = 10,000 bps). const BPS_DENOMINATOR: i128 = 10_000; const DEFAULT_PLATFORM_FEE_BPS: u32 = 200; // 2% const MAX_PLATFORM_FEE_BPS: u32 = 1000; // 10% @@ -419,7 +424,7 @@ impl FeeManager { env: &Env, fee_type: &FeeType, min_fee: i128, - max_fee: i128, + _max_fee: i128, ) -> Result<(), QuickLendXError> { let fee_structures: Vec = match env.storage().instance().get(&FEE_CONFIG_KEY) { diff --git a/quicklendx-contracts/src/init.rs b/quicklendx-contracts/src/init.rs index 5b754da5..8923d501 100644 --- a/quicklendx-contracts/src/init.rs +++ b/quicklendx-contracts/src/init.rs @@ -31,7 +31,7 @@ //! - `set_treasury()` - Update treasury address //! - Currency whitelist management functions -use crate::admin::{AdminStorage, ADMIN_INITIALIZED_KEY, ADMIN_KEY}; +use crate::admin::AdminStorage; use crate::errors::QuickLendXError; use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; @@ -298,7 +298,7 @@ impl ProtocolInitializer { /// * `Ok(())` if all parameters are valid /// * `Err(QuickLendXError)` with specific error for invalid parameters fn validate_initialization_params( - env: &Env, + _env: &Env, params: &InitializationParams, ) -> Result<(), QuickLendXError> { // VALIDATION: Fee basis points (0% to 10%) diff --git a/quicklendx-contracts/src/investment.rs b/quicklendx-contracts/src/investment.rs index 1f31ac76..d36cbb40 100644 --- a/quicklendx-contracts/src/investment.rs +++ b/quicklendx-contracts/src/investment.rs @@ -1,5 +1,7 @@ use crate::errors::QuickLendXError; -use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Symbol, Vec}; +// Re-export from crate::types so other modules can continue to import from crate::investment. +pub use crate::types::{InsuranceCoverage, Investment, InvestmentStatus}; +use soroban_sdk::{symbol_short, Address, BytesN, Env, Symbol, Vec}; // ─── Storage key for the global active-investment index ─────────────────────── const ACTIVE_INDEX_KEY: Symbol = symbol_short!("act_inv"); @@ -27,25 +29,8 @@ pub const MAX_TOTAL_COVERAGE_PERCENTAGE: u32 = 100; /// with no economic cost to the insured party. pub const MIN_PREMIUM_AMOUNT: i128 = 1; -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InsuranceCoverage { - pub provider: Address, - pub coverage_amount: i128, - pub premium_amount: i128, - pub coverage_percentage: u32, - pub active: bool, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum InvestmentStatus { - Active, - Withdrawn, - Completed, - Defaulted, - Refunded, -} +// Local type definitions removed — InsuranceCoverage, InvestmentStatus, and +// Investment are now imported from crate::types (the single source of truth). impl InvestmentStatus { /// Validate that a status transition is legal. @@ -86,18 +71,6 @@ impl InvestmentStatus { } } -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Investment { - pub investment_id: BytesN<32>, - pub invoice_id: BytesN<32>, - pub investor: Address, - pub amount: i128, - pub funded_at: u64, - pub status: InvestmentStatus, - pub insurance: Vec, -} - impl Investment { /// Compute the insurance premium for a given investment amount and coverage /// percentage. diff --git a/quicklendx-contracts/src/investment_queries.rs b/quicklendx-contracts/src/investment_queries.rs index 84191e1b..18c9fdfb 100644 --- a/quicklendx-contracts/src/investment_queries.rs +++ b/quicklendx-contracts/src/investment_queries.rs @@ -1,4 +1,4 @@ -use crate::investment::{Investment, InvestmentStatus, InvestmentStorage}; +use crate::investment::{InvestmentStatus, InvestmentStorage}; use soroban_sdk::{symbol_short, Address, BytesN, Env, Vec}; /// Maximum number of records returned by paginated query endpoints. diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index dbe256d2..a66fbfa6 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -123,6 +123,7 @@ pub fn process_partial_payment( /// - Rejects if payment count has reached MAX_PAYMENT_COUNT /// /// # Security +/// /// - The payer must be the verified invoice business and must authorize the call. /// - Stored payment records always reflect the applied amount, never the requested excess. pub fn record_payment( diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index 5afcc940..2c37a3e4 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -29,8 +29,7 @@ use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; // Removed ToString import; not needed in Soroban environment. -use crate::bid::{Bid, BidStatus}; -use crate::investment::{Investment, InvestmentStatus}; +use crate::types::{Bid, BidStatus, Investment, InvestmentStatus}; use crate::invoice::{Invoice, InvoiceStatus}; use crate::profits::PlatformFeeConfig; @@ -278,7 +277,7 @@ impl InvoiceStorage { pub fn remove_from_customer_index(env: &Env, customer_name: &String, invoice_id: &BytesN<32>) { let key = Indexes::invoices_by_customer(customer_name); - let mut ids: Vec> = env + let ids: Vec> = env .storage() .persistent() .get(&key) @@ -307,7 +306,7 @@ impl InvoiceStorage { pub fn remove_from_tax_id_index(env: &Env, tax_id: &String, invoice_id: &BytesN<32>) { let key = Indexes::invoices_by_tax_id(tax_id); - let mut ids: Vec> = env + let ids: Vec> = env .storage() .persistent() .get(&key) diff --git a/quicklendx-contracts/src/test_default.rs b/quicklendx-contracts/src/test_default.rs index dd061b3e..578cb88c 100644 --- a/quicklendx-contracts/src/test_default.rs +++ b/quicklendx-contracts/src/test_default.rs @@ -877,3 +877,153 @@ fn test_check_overdue_invoices_propagates_grace_period_error() { // Should succeed with default protocol config (returns count) assert!(result >= 0); // Just verify it returns a value without error } + +#[test] +fn test_transition_guard_prevents_duplicate_default() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let investor = create_verified_investor(&env, &client, &admin, 10000); + + let amount = 1000; + let due_date = env.ledger().timestamp() + 86400; + let invoice_id = create_and_fund_invoice( + &env, &client, &admin, &business, &investor, amount, due_date, + ); + + let invoice = client.get_invoice(&invoice_id); + let grace_period = 7 * 24 * 60 * 60; + + // Move time past grace period + let default_time = invoice.due_date + grace_period + 1; + env.ledger().set_timestamp(default_time); + + // First attempt should succeed + client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); + + // Second attempt should fail with DuplicateDefaultTransition + let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + assert!(result.is_err()); + let err = result.err().unwrap(); + let contract_err = err.expect("expected contract error"); + assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); +} + +#[test] +fn test_transition_guard_persists_across_calls() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let investor = create_verified_investor(&env, &client, &admin, 10000); + + let amount = 1000; + let due_date = env.ledger().timestamp() + 86400; + let invoice_id = create_and_fund_invoice( + &env, &client, &admin, &business, &investor, amount, due_date, + ); + + let invoice = client.get_invoice(&invoice_id); + let grace_period = 7 * 24 * 60 * 60; + + // Move time past grace period + let default_time = invoice.due_date + grace_period + 1; + env.ledger().set_timestamp(default_time); + + // First default should succeed + client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); + + // Simulate multiple calls - all should fail + for _ in 0..3 { + let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + assert!(result.is_err()); + let err = result.err().unwrap(); + let contract_err = err.expect("expected contract error"); + assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); + } +} + +#[test] +fn test_transition_guard_atomicity_during_partial_failure() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let investor = create_verified_investor(&env, &client, &admin, 10000); + + let amount = 1000; + let due_date = env.ledger().timestamp() + 86400; + let invoice_id = create_and_fund_invoice( + &env, &client, &admin, &business, &investor, amount, due_date, + ); + + let invoice = client.get_invoice(&invoice_id); + let grace_period = 7 * 24 * 60 * 60; + + // Move time past grace period + let default_time = invoice.due_date + grace_period + 1; + env.ledger().set_timestamp(default_time); + + // First attempt should succeed and set the guard + client.mark_invoice_defaulted(&invoice_id, &Some(grace_period)); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); + + // Even if we try to call handle_default directly, it should fail due to guard + let result = env.as_contract(&client.address, || { + crate::defaults::handle_default(&env, &invoice_id) + }); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + QuickLendXError::DuplicateDefaultTransition + ); +} + +#[test] +fn test_transition_guard_different_invoices_independent() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let investor = create_verified_investor(&env, &client, &admin, 20000); + + let amount = 1000; + let due_date = env.ledger().timestamp() + 86400; + + // Create two invoices + let invoice1_id = create_and_fund_invoice( + &env, &client, &admin, &business, &investor, amount, due_date, + ); + let invoice2_id = create_and_fund_invoice( + &env, &client, &admin, &business, &investor, amount, due_date, + ); + + let grace_period = 7 * 24 * 60 * 60; + let default_time = due_date + grace_period + 1; + env.ledger().set_timestamp(default_time); + + // Default first invoice + client.mark_invoice_defaulted(&invoice1_id, &Some(grace_period)); + assert_eq!( + client.get_invoice(&invoice1_id).status, + InvoiceStatus::Defaulted + ); + + // Second invoice should still be defaultable + client.mark_invoice_defaulted(&invoice2_id, &Some(grace_period)); + assert_eq!( + client.get_invoice(&invoice2_id).status, + InvoiceStatus::Defaulted + ); + + // But first invoice still guarded + let result = client.try_mark_invoice_defaulted(&invoice1_id, &Some(grace_period)); + assert!(result.is_err()); + let err = result.err().unwrap(); + let contract_err = err.expect("expected contract error"); + assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition); +} diff --git a/quicklendx-contracts/src/types.rs b/quicklendx-contracts/src/types.rs index 9a65b592..2edf30cb 100644 --- a/quicklendx-contracts/src/types.rs +++ b/quicklendx-contracts/src/types.rs @@ -42,6 +42,7 @@ pub enum InvestmentStatus { Withdrawn, Completed, Defaulted, + Refunded, } /// Dispute status enumeration diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index 294b6f8f..8d4f0b64 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -621,7 +621,7 @@ pub fn normalize_tag(env: &Env, tag: &String) -> Result return Err(QuickLendXError::InvalidTag); // Code 1035/1800 } - // Convert to bytes for processing + // Stack-allocated buffer — no heap allocation needed in no_std context. let mut buf = [0u8; 50]; tag.copy_into_slice(&mut buf[..tag.len() as usize]); let mut normalized_bytes = [0u8; 50]; @@ -638,12 +638,11 @@ pub fn normalize_tag(env: &Env, tag: &String) -> Result core::str::from_utf8(&normalized_bytes[..tag.len() as usize]) .map_err(|_| QuickLendXError::InvalidTag)?, ); - let trimmed = normalized_str; // Simplification: in a full implementation, we'd handle leading/trailing whitespace bytes - if trimmed.len() == 0 { + if normalized_str.len() == 0 { return Err(QuickLendXError::InvalidTag); } - Ok(trimmed) + Ok(normalized_str) } /// @notice Validate a bid against protocol rules and business constraints @@ -933,6 +932,7 @@ pub fn verify_invoice_data( // Enhanced event emission functions for comprehensive audit trail fn emit_kyc_submitted(env: &Env, business: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("kyc_sub"),), ( @@ -944,6 +944,7 @@ fn emit_kyc_submitted(env: &Env, business: &Address) { } fn emit_business_verified(env: &Env, business: &Address, admin: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("bus_ver"),), ( @@ -956,6 +957,7 @@ fn emit_business_verified(env: &Env, business: &Address, admin: &Address) { } fn emit_business_rejected(env: &Env, business: &Address, admin: &Address, reason: &String) { + #[allow(deprecated)] env.events().publish( (symbol_short!("bus_rej"),), ( @@ -968,6 +970,7 @@ fn emit_business_rejected(env: &Env, business: &Address, admin: &Address, reason } fn emit_kyc_resubmitted(env: &Env, business: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("kyc_resub"),), ( diff --git a/src/fees.rs b/src/fees.rs index ba05f0f4..0c894e43 100644 --- a/src/fees.rs +++ b/src/fees.rs @@ -1,27 +1,27 @@ -/// # Fees Module -/// -/// Computes all protocol fees in the QuickLendX invoice-financing platform: -/// origination, servicing, default, and early-repayment fees. -/// -/// ## Design Principles -/// -/// 1. **Checked arithmetic only** — every multiply/divide uses the `checked_*` -/// family; overflow returns `None` rather than wrapping or panicking. -/// 2. **Unsigned integers** — `u128` eliminates signed-integer edge cases -/// (e.g., `i128::MIN.abs()` overflow). -/// 3. **Basis-point precision** — rates are expressed in bps (1/100 of a -/// percent, denominator 10_000) to avoid floating-point imprecision. -/// 4. **Division last** — multiplications are completed before dividing to -/// maximise precision and minimise intermediate rounding. -/// -/// ## Fee Taxonomy -/// -/// | Fee | Applied to | Max rate | -/// |------------------|---------------|----------| -/// | Origination | face_value | 500 bps | -/// | Servicing | face_value | 300 bps | -/// | Default penalty | outstanding | 2 000 bps| -/// | Early repayment | outstanding | 500 bps | +//! # Fees Module +//! +//! Computes all protocol fees in the QuickLendX invoice-financing platform: +//! origination, servicing, default, and early-repayment fees. +//! +//! ## Design Principles +//! +//! 1. **Checked arithmetic only** — every multiply/divide uses the `checked_*` +//! family; overflow returns `None` rather than wrapping or panicking. +//! 2. **Unsigned integers** — `u128` eliminates signed-integer edge cases +//! (e.g., `i128::MIN.abs()` overflow). +//! 3. **Basis-point precision** — rates are expressed in bps (1/100 of a +//! percent, denominator 10_000) to avoid floating-point imprecision. +//! 4. **Division last** — multiplications are completed before dividing to +//! maximise precision and minimise intermediate rounding. +//! +//! ## Fee Taxonomy +//! +//! | Fee | Applied to | Max rate | +//! |------------------|---------------|----------| +//! | Origination | face_value | 500 bps | +//! | Servicing | face_value | 300 bps | +//! | Default penalty | outstanding | 2 000 bps| +//! | Early repayment | outstanding | 500 bps | /// Basis-point denominator. pub const BPS_DENOMINATOR: u128 = 10_000; @@ -54,9 +54,7 @@ fn bps_fee(amount: u128, rate_bps: u128) -> Option { if rate_bps > BPS_DENOMINATOR { return None; } - amount - .checked_mul(rate_bps)? - .checked_div(BPS_DENOMINATOR) + amount.checked_mul(rate_bps)?.checked_div(BPS_DENOMINATOR) } // ───────────────────────────────────────────────────────────────────────────── @@ -125,7 +123,10 @@ pub fn default_penalty(outstanding_amount: u128, default_penalty_bps: u128) -> O /// /// # Returns /// `Some(fee)` or `None` on invalid input / overflow. -pub fn early_repayment_fee(outstanding_amount: u128, early_repayment_fee_bps: u128) -> Option { +pub fn early_repayment_fee( + outstanding_amount: u128, + early_repayment_fee_bps: u128, +) -> Option { if outstanding_amount == 0 || outstanding_amount > MAX_AMOUNT { return None; } @@ -188,7 +189,10 @@ mod tests { #[test] fn test_origination_max_rate() { // 5% of 2_000_000 = 100_000 - assert_eq!(origination_fee(2_000_000, MAX_ORIGINATION_BPS), Some(100_000)); + assert_eq!( + origination_fee(2_000_000, MAX_ORIGINATION_BPS), + Some(100_000) + ); } #[test] @@ -251,7 +255,10 @@ mod tests { #[test] fn test_default_penalty_max_rate() { // 20% of 1_000_000 = 200_000 - assert_eq!(default_penalty(1_000_000, MAX_DEFAULT_PENALTY_BPS), Some(200_000)); + assert_eq!( + default_penalty(1_000_000, MAX_DEFAULT_PENALTY_BPS), + Some(200_000) + ); } #[test] @@ -336,4 +343,4 @@ mod tests { // BPS_DENOMINATOR = 10_000 > MAX_ORIGINATION_BPS = 500 assert!(origination_fee(1_000_000, BPS_DENOMINATOR).is_none()); } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 9ab14a6b..f2269fab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,4 +33,4 @@ mod test_fuzz; mod test_business_kyc; #[cfg(test)] -mod test_investor_kyc; \ No newline at end of file +mod test_investor_kyc; diff --git a/src/profits.rs b/src/profits.rs index 0e9f6373..5bedbe01 100644 --- a/src/profits.rs +++ b/src/profits.rs @@ -1,23 +1,23 @@ -/// # Profits Module -/// -/// Computes investor return metrics and platform revenue in the QuickLendX -/// protocol. -/// -/// ## Return Metrics -/// -/// | Metric | Formula | -/// |---------------------|-----------------------------------------------------------| -/// | Gross Profit | `payout − funded_amount` | -/// | Net Profit | `gross_profit − investor_fees` | -/// | Return on Investment| `net_profit * BPS_DENOMINATOR / funded_amount` (in bps) | -/// | Platform Revenue | `sum(protocol_fees)` | -/// -/// ## Safety -/// -/// All arithmetic is checked. Division is guarded against zero divisors. -/// ROI is expressed in basis points to avoid floating-point; callers can -/// convert: `roi_bps / 100` gives percent with two-decimal precision. - +//! # Profits Module +//! +//! Computes investor return metrics and platform revenue in the QuickLendX +//! protocol. +//! +//! ## Return Metrics +//! +//! | Metric | Formula | +//! |---------------------|-----------------------------------------------------------| +//! | Gross Profit | `payout − funded_amount` | +//! | Net Profit | `gross_profit − investor_fees` | +//! | Return on Investment| `net_profit * BPS_DENOMINATOR / funded_amount` (in bps) | +//! | Platform Revenue | `sum(protocol_fees)` | +//! +//! ## Safety +//! +//! All arithmetic is checked. Division is guarded against zero divisors. +//! ROI is expressed in basis points to avoid floating-point; callers can +//! convert: `roi_bps / 100` gives percent with two-decimal precision. +//! /// Basis-point denominator (10_000 = 100%). pub const BPS_DENOMINATOR: u128 = 10_000; @@ -82,8 +82,7 @@ pub fn return_on_investment_bps( return None; } let np = net_profit(investor_payout, funded_amount, investor_fees)?; - np.checked_mul(BPS_DENOMINATOR)? - .checked_div(funded_amount) + np.checked_mul(BPS_DENOMINATOR)?.checked_div(funded_amount) } // ───────────────────────────────────────────────────────────────────────────── @@ -319,4 +318,4 @@ mod tests { // contribution * revenue overflows before division assert!(investor_revenue_share(u128::MAX, 1, u128::MAX).is_none()); } -} \ No newline at end of file +} diff --git a/src/settlement.rs b/src/settlement.rs index 8f01c162..b9c51145 100644 --- a/src/settlement.rs +++ b/src/settlement.rs @@ -1,26 +1,26 @@ -/// # Settlement Module -/// -/// Handles the core arithmetic for invoice settlement in the QuickLendX protocol. -/// -/// ## Security Model -/// -/// All arithmetic operations use checked math (`checked_add`, `checked_sub`, -/// `checked_mul`, `checked_div`) to prevent silent overflow/underflow. Any -/// operation that would overflow returns `None`, which callers must handle as -/// an error. Amounts are represented as `u128` (unsigned 128-bit integers) to -/// support large invoice values while eliminating sign-related edge cases. -/// -/// ## Precision -/// -/// Internal computations use basis-point (bps) scaling (1 bps = 0.01%). -/// All division is performed last to minimize rounding error. -/// -/// ## Invariants -/// -/// - `face_value` ≥ `funded_amount` (discount never exceeds face value) -/// - `funded_amount` > 0 for any active invoice -/// - Fee percentages are expressed in basis points: 0–10_000 (0%–100%) - +//! # Settlement Module +//! +//! Handles the core arithmetic for invoice settlement in the QuickLendX protocol. +//! +//! ## Security Model +//! +//! All arithmetic operations use checked math (`checked_add`, `checked_sub`, +//! `checked_mul`, `checked_div`) to prevent silent overflow/underflow. Any +//! operation that would overflow returns `None`, which callers must handle as +//! an error. Amounts are represented as `u128` (unsigned 128-bit integers) to +//! support large invoice values while eliminating sign-related edge cases. +//! +//! ## Precision +//! +//! Internal computations use basis-point (bps) scaling (1 bps = 0.01%). +//! All division is performed last to minimize rounding error. +//! +//! ## Invariants +//! +//! - `face_value` ≥ `funded_amount` (discount never exceeds face value) +//! - `funded_amount` > 0 for any active invoice +//! - Fee percentages are expressed in basis points: 0–10_000 (0%–100%) +//! /// Basis-point denominator (10_000 = 100%). pub const BPS_DENOMINATOR: u128 = 10_000; @@ -52,7 +52,7 @@ pub struct SettlementResult { /// - `funded_amount` — Amount the investor disbursed (≤ face_value). /// - `protocol_fee_bps`— Protocol fee in basis points (0–10_000). /// - `late_penalty_bps`— Late-payment penalty in basis points (0–5_000); pass -/// `0` for on-time payments. +/// `0` for on-time payments. /// /// # Returns /// `Some(SettlementResult)` on success, `None` on arithmetic overflow/underflow @@ -153,8 +153,7 @@ mod tests { fee_bps: u128, penalty_bps: u128, ) -> SettlementResult { - compute_settlement(face, funded, fee_bps, penalty_bps) - .expect("expected valid settlement") + compute_settlement(face, funded, fee_bps, penalty_bps).expect("expected valid settlement") } // ── Basic happy-path tests ──────────────────────────────────────────────── @@ -317,4 +316,4 @@ mod tests { // payout < funded_amount → underflow → None assert_eq!(investor_profit(800_000, 900_000), None); } -} \ No newline at end of file +} diff --git a/src/test_fuzz.rs b/src/test_fuzz.rs index c1e5ce51..27beeeed 100644 --- a/src/test_fuzz.rs +++ b/src/test_fuzz.rs @@ -1,3 +1,11 @@ +use crate::fees::{ + default_penalty, early_repayment_fee, origination_fee, servicing_fee, total_fees, MAX_AMOUNT, + MAX_DEFAULT_PENALTY_BPS, MAX_EARLY_REPAYMENT_BPS, MAX_ORIGINATION_BPS, MAX_SERVICING_BPS, +}; +use crate::profits::{ + aggregate_platform_revenue, gross_profit, investor_revenue_share, net_profit, + return_on_investment_bps, MAX_INVESTMENT, +}; /// # Arithmetic Fuzz Tests — QuickLendX Protocol /// /// This module implements fuzz-style tests for all critical arithmetic in the @@ -19,19 +27,9 @@ /// 4. Fee caps are enforced: rate > max → `None`. /// 5. Zero inputs are rejected where specified. /// 6. ROI is non-negative iff net_profit is non-negative. - use crate::settlement::{ - compute_settlement, verify_conservation, BPS_DENOMINATOR as S_BPS, - MAX_FACE_VALUE, MAX_PENALTY_BPS, -}; -use crate::fees::{ - default_penalty, early_repayment_fee, origination_fee, servicing_fee, total_fees, - MAX_AMOUNT, MAX_DEFAULT_PENALTY_BPS, MAX_EARLY_REPAYMENT_BPS, - MAX_ORIGINATION_BPS, MAX_SERVICING_BPS, -}; -use crate::profits::{ - aggregate_platform_revenue, gross_profit, investor_revenue_share, net_profit, - return_on_investment_bps, MAX_INVESTMENT, + compute_settlement, verify_conservation, BPS_DENOMINATOR as S_BPS, MAX_FACE_VALUE, + MAX_PENALTY_BPS, }; // ───────────────────────────────────────────────────────────────────────────── @@ -259,10 +257,7 @@ fn fuzz_fees_never_exceed_principal() { } // early_repayment if let Some(fee) = early_repayment_fee(amount, rate) { - assert!( - fee <= amount, - "early_repayment_fee {fee} > amount {amount}" - ); + assert!(fee <= amount, "early_repayment_fee {fee} > amount {amount}"); } } } @@ -422,10 +417,10 @@ fn fuzz_net_profit_le_gross_profit() { #[test] fn fuzz_roi_sign_matches_net_profit() { let cases = [ - (1_100_000u128, 1_000_000u128, 0u128), // profit - (1_000_000, 1_000_000, 0), // break-even - (1_100_000, 1_000_000, 100_000), // break-even after fees - (1_200_000, 1_000_000, 50_000), // profit after fees + (1_100_000u128, 1_000_000u128, 0u128), // profit + (1_000_000, 1_000_000, 0), // break-even + (1_100_000, 1_000_000, 100_000), // break-even after fees + (1_200_000, 1_000_000, 50_000), // profit after fees ]; for (payout, funded, fees) in cases { @@ -481,7 +476,11 @@ fn fuzz_revenue_share_full_ownership() { for (pool, revenue) in pool_and_revenue { let share = investor_revenue_share(pool, pool, revenue); - assert_eq!(share, Some(revenue), "Full owner should receive full revenue"); + assert_eq!( + share, + Some(revenue), + "Full owner should receive full revenue" + ); } } @@ -554,4 +553,4 @@ fn fuzz_fees_and_settlement_arithmetic_compatibility() { settlement.protocol_fee, direct_fee, "settlement.protocol_fee should equal origination_fee at same rate" ); -} \ No newline at end of file +}