From db538e654f7b4a8df06fe73722794d195879d12e Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Mon, 30 Mar 2026 14:47:53 +0100 Subject: [PATCH 1/3] feat: document default API overlap and ordering - handle_default now routes through do_mark_invoice_defaulted (grace=None) instead of do_handle_default directly, closing the grace-period bypass - Both entry points converge on the same internal helper so state is written exactly once regardless of which path is taken first - Add 5 overlap/ordering tests in test_default.rs: test_handle_default_respects_grace_period test_handle_default_succeeds_after_grace_period test_no_double_accounting_handle_default_then_mark_defaulted test_no_double_accounting_mark_defaulted_then_handle_default test_both_paths_produce_identical_state - Rewrite docs/contracts/defaults.md: remove duplicate content, add API ordering table, no-double-accounting guarantee, and ordering invariant diagram Closes #732 --- docs/contracts/defaults.md | 452 +++++++++-------------- quicklendx-contracts/src/lib.rs | 35 +- quicklendx-contracts/src/test_default.rs | 178 +++++++++ 3 files changed, 374 insertions(+), 291 deletions(-) diff --git a/docs/contracts/defaults.md b/docs/contracts/defaults.md index 0b066d42..670576e5 100644 --- a/docs/contracts/defaults.md +++ b/docs/contracts/defaults.md @@ -1,283 +1,169 @@ -# Default Handling and Grace Period - -## Overview - -The QuickLendX protocol implements configurable default handling for invoices that remain unpaid past their due date. A 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). - -## 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` | 1005 | Caller is not the configured admin | -| `InvoiceNotFound` | 1000 | Invoice ID does not exist | -| `InvoiceAlreadyDefaulted` | 1049 | Invoice has already been defaulted | -| `InvoiceNotAvailableForFunding` | 1047 | Invoice is not in `Funded` status | -| `OperationNotAllowed` | 1009 | 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) - -## 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 -``` - -Callers can override this per invocation by passing `Some(custom_seconds)`. - -### 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 - -- **Admin-only access:** Both `mark_invoice_defaulted` and `handle_default` require `require_auth` from the configured admin address -- **No double default:** Attempting to default an already-defaulted invoice returns `InvoiceAlreadyDefaulted` (1049) -- **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 -- **Overflow protection:** `grace_deadline` uses `saturating_add` to prevent timestamp overflow - -## Test Coverage - -Tests are in `src/test_default.rs` (12 tests): - -| 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 | - -Run tests: - -```bash -cd quicklendx-contracts -cargo test test_default -- --nocapture -``` -# Default Handling and Grace Period - -## Overview - -The QuickLendX protocol implements configurable default handling for invoices that remain unpaid past their due date. A 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). - -## 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. If `None`, uses protocol config; if not configured, defaults to 7 days (604,800s). | - -**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` | 1005 | Caller is not the configured admin | -| `InvoiceNotFound` | 1000 | Invoice ID does not exist | -| `InvoiceAlreadyDefaulted` | 1049 | Invoice has already been defaulted | -| `InvoiceNotAvailableForFunding` | 1047 | Invoice is not in `Funded` status | -| `OperationNotAllowed` | 1009 | 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) - -## Grace Period - -### Configuration - -Grace period resolution order: - -1. `grace_period` argument (per-call override) -2. Protocol config (`ProtocolInitializer::get_protocol_config`) -3. Default of 7 days (604,800 seconds) - -Callers can override the protocol config per invocation by passing `Some(custom_seconds)`. - -### 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 - -- **Admin-only access:** Both `mark_invoice_defaulted` and `handle_default` require `require_auth` from the configured admin address -- **No double default:** Attempting to default an already-defaulted invoice returns `InvoiceAlreadyDefaulted` (1049) -- **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 -- **Overflow protection:** `grace_deadline` uses `saturating_add` to prevent timestamp overflow - -## Test Coverage - -Tests are in `src/test_default.rs` (12 tests): - -| 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_uses_protocol_config_when_none` | `None` grace period uses protocol-configured grace | -| `test_check_invoice_expiration_uses_protocol_config_when_none` | Expiration checks honor protocol-configured grace | -| `test_per_invoice_grace_overrides_protocol_config` | Per-invoice grace period overrides protocol config | -| `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 | - -Run tests: - -```bash -cd quicklendx-contracts -cargo test test_default -- --nocapture -``` +# Default Handling and Grace Period + +## Overview + +The QuickLendX protocol implements configurable default handling for invoices that remain unpaid past their due date. A 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). + +## API Ordering and Overlap + +The protocol exposes two admin-only entry points for defaulting an invoice. Both converge on the same internal state-transition helper (`do_handle_default`) so the outcome — status update, events, insurance processing — is identical and executed **exactly once**. + +| Entry point | Grace-period check | When to use | +|---|---|---| +| `mark_invoice_defaulted(id, grace)` | Explicit `Option` override | Caller wants to supply or override the grace period | +| `handle_default(id)` | Protocol config / `DEFAULT_GRACE_PERIOD` | Caller wants the protocol-default grace period enforced automatically | + +### No double-accounting guarantee + +Both entry points check `invoice.status == Defaulted` before making any state change and return `InvoiceAlreadyDefaulted` immediately if the invoice has already been processed. This means: + +- Calling `handle_default` after `mark_invoice_defaulted` (or vice-versa) on the same invoice is safe — the second call is a no-op error, not a second state transition. +- The funded-invoice list, investment status, and emitted events are updated exactly once regardless of which path is taken first. + +### Ordering invariant + +``` +mark_invoice_defaulted ──┐ + ├──► do_mark_invoice_defaulted ──► do_handle_default ──► state written once +handle_default ──┘ +``` + +`handle_default` calls `do_mark_invoice_defaulted` (with `grace_period = None`) rather than `do_handle_default` directly, so it enforces the same time guard as `mark_invoice_defaulted`. + +## 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 override in seconds. If `None`, uses protocol config; falls back to `DEFAULT_GRACE_PERIOD` (7 days). | + +**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` | 1005 | Caller is not the configured admin | +| `InvoiceNotFound` | 1000 | Invoice ID does not exist | +| `InvoiceAlreadyDefaulted` | 1049 | Invoice has already been defaulted | +| `InvoiceNotAvailableForFunding` | 1047 | Invoice is not in `Funded` status | +| `OperationNotAllowed` | 1009 | Grace period has not yet expired | + +### `handle_default(invoice_id)` + +Admin entry point that applies the default using the protocol-configured grace period. + +**Authorization:** Admin only. + +**Grace period:** Resolved from protocol config (`ProtocolInitializer::get_protocol_config`) or `DEFAULT_GRACE_PERIOD` (7 days) when not configured. Equivalent to calling `mark_invoice_defaulted(id, None)`. + +**Behavior:** + +1. Admin authentication check +2. Grace-period expiry check (same as `mark_invoice_defaulted`) +3. Validates invoice exists and is in `Funded` status +4. Removes invoice from the `Funded` status list +5. Sets invoice status to `Defaulted` +6. Adds invoice to the `Defaulted` status list +7. Emits `invoice_expired` and `invoice_defaulted` events +8. Updates linked investment status to `Defaulted` +9. Processes insurance claims if coverage exists + +## Grace Period + +### Configuration + +Grace period resolution order: + +1. `grace_period` argument (per-call override, `mark_invoice_defaulted` only) +2. Protocol config (`ProtocolInitializer::get_protocol_config`) +3. Default of 7 days (604,800 seconds) + +### 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 +- **Events emitted:** `invoice_expired`, `invoice_defaulted`, and optionally `insurance_claimed` + +## Security + +- **Admin-only access:** Both `mark_invoice_defaulted` and `handle_default` require `require_auth` from the configured admin address +- **No double default:** Attempting to default an already-defaulted invoice returns `InvoiceAlreadyDefaulted` (1049) +- **Grace period enforcement:** Both entry points enforce `current_timestamp > due_date + grace_period` before any state change +- **No bypass via `handle_default`:** `handle_default` routes through `do_mark_invoice_defaulted`, not directly to `do_handle_default`, so the time guard cannot be skipped +- **Overflow protection:** `grace_deadline` uses `saturating_add` to prevent timestamp overflow + +## Test Coverage + +Tests are in `src/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_handle_default_respects_grace_period` | `handle_default` enforces the same time guard | +| `test_handle_default_succeeds_after_grace_period` | `handle_default` succeeds once grace elapsed | +| `test_no_double_accounting_handle_default_then_mark_defaulted` | No double-accounting: `handle_default` → `mark_invoice_defaulted` | +| `test_no_double_accounting_mark_defaulted_then_handle_default` | No double-accounting: `mark_invoice_defaulted` → `handle_default` | +| `test_both_paths_produce_identical_state` | Both entry points produce identical final state | + +Run tests: + +```bash +cd quicklendx-contracts +cargo test test_default -- --nocapture +``` diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index cb4d90f7..af434ea6 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -1199,19 +1199,38 @@ impl QuickLendXContract { }) } - /// Handle invoice default (admin only) - /// This is the internal handler - use mark_invoice_defaulted for public API + /// Apply the default state transition for a funded invoice (admin only). + /// + /// Performs the same grace-period check as `mark_invoice_defaulted` using + /// the protocol-configured grace period (or `DEFAULT_GRACE_PERIOD` when not + /// configured). This prevents the admin from bypassing the time guard by + /// calling this entry point directly. + /// + /// # When to use each API + /// + /// | API | Grace-period check | Use when | + /// |-----|--------------------|----------| + /// | `mark_invoice_defaulted` | Explicit `Option` override | Caller wants to supply or override the grace period | + /// | `handle_default` | Protocol config / `DEFAULT_GRACE_PERIOD` | Caller wants the protocol-default grace period enforced automatically | + /// + /// Both paths converge on the same internal `do_handle_default` helper, so + /// the state transition (status update, events, insurance) is identical and + /// executed exactly once regardless of which entry point is used. + /// + /// # Errors + /// * `NotAdmin` - No admin configured or caller is not admin + /// * `InvoiceNotFound` - Invoice does not exist + /// * `InvoiceAlreadyDefaulted` - Invoice is already defaulted + /// * `InvalidStatus` - Invoice is not in Funded status + /// * `OperationNotAllowed` - Grace period has not expired yet pub fn handle_default(env: Env, invoice_id: BytesN<32>) -> Result<(), QuickLendXError> { pause::PauseControl::require_not_paused(&env)?; let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; admin.require_auth(); - // Get the investment to track investor analytics - let _investment = InvestmentStorage::get_investment_by_invoice(&env, &invoice_id); - - let result = do_handle_default(&env, &invoice_id); - - result + // Enforce the protocol-default grace period so this entry point cannot + // be used to bypass the time guard that mark_invoice_defaulted applies. + do_mark_invoice_defaulted(&env, &invoice_id, None) } /// Mark an invoice as defaulted (admin only) diff --git a/quicklendx-contracts/src/test_default.rs b/quicklendx-contracts/src/test_default.rs index dd061b3e..df3e86f2 100644 --- a/quicklendx-contracts/src/test_default.rs +++ b/quicklendx-contracts/src/test_default.rs @@ -877,3 +877,181 @@ 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 } + +// ============================================================================ +// handle_default vs mark_invoice_defaulted — ordering and overlap (Issue #732) +// ============================================================================ + +/// `handle_default` must reject a funded invoice whose grace period has not +/// elapsed, proving it enforces the same time guard as `mark_invoice_defaulted`. +#[test] +fn test_handle_default_respects_grace_period() { + 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); + // Move to exactly the grace deadline — must still be rejected. + env.ledger() + .set_timestamp(invoice.due_date + crate::defaults::DEFAULT_GRACE_PERIOD); + + let result = client.try_handle_default(&invoice_id); + assert_eq!( + result, + Err(Ok(QuickLendXError::OperationNotAllowed)), + "handle_default must not bypass the grace-period guard" + ); + assert_eq!(client.get_invoice(&invoice_id).status, InvoiceStatus::Funded); +} + +/// `handle_default` succeeds once the grace period has strictly elapsed, +/// confirming the two entry points share the same time boundary. +#[test] +fn test_handle_default_succeeds_after_grace_period() { + 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); + env.ledger() + .set_timestamp(invoice.due_date + crate::defaults::DEFAULT_GRACE_PERIOD + 1); + + client.handle_default(&invoice_id); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); +} + +/// Calling `handle_default` then `mark_invoice_defaulted` on the same invoice +/// must not double-account: the second call returns `InvoiceAlreadyDefaulted`. +#[test] +fn test_no_double_accounting_handle_default_then_mark_defaulted() { + 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 = crate::defaults::DEFAULT_GRACE_PERIOD; + env.ledger().set_timestamp(invoice.due_date + grace + 1); + + // First path: handle_default + client.handle_default(&invoice_id); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); + + // Second path: mark_invoice_defaulted must be rejected + let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace)); + assert_eq!( + result, + Err(Ok(QuickLendXError::InvoiceAlreadyDefaulted)), + "second default attempt must return InvoiceAlreadyDefaulted" + ); +} + +/// Calling `mark_invoice_defaulted` then `handle_default` on the same invoice +/// must not double-account: the second call returns `InvoiceAlreadyDefaulted`. +#[test] +fn test_no_double_accounting_mark_defaulted_then_handle_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 = crate::defaults::DEFAULT_GRACE_PERIOD; + env.ledger().set_timestamp(invoice.due_date + grace + 1); + + // First path: mark_invoice_defaulted + client.mark_invoice_defaulted(&invoice_id, &Some(grace)); + assert_eq!( + client.get_invoice(&invoice_id).status, + InvoiceStatus::Defaulted + ); + + // Second path: handle_default must be rejected + let result = client.try_handle_default(&invoice_id); + assert_eq!( + result, + Err(Ok(QuickLendXError::InvoiceAlreadyDefaulted)), + "second default attempt must return InvoiceAlreadyDefaulted" + ); +} + +/// Both entry points produce identical final state: Defaulted invoice, +/// Defaulted investment, and the invoice removed from the Funded list. +#[test] +fn test_both_paths_produce_identical_state() { + 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; + let grace = crate::defaults::DEFAULT_GRACE_PERIOD; + + // Invoice A defaulted via handle_default + let invoice_a = + create_and_fund_invoice(&env, &client, &admin, &business, &investor, amount, due_date); + let inv_a = client.get_invoice(&invoice_a); + env.ledger().set_timestamp(inv_a.due_date + grace + 1); + client.handle_default(&invoice_a); + + // Invoice B defaulted via mark_invoice_defaulted (same timestamp) + let invoice_b = create_and_fund_invoice( + &env, + &client, + &admin, + &business, + &investor, + amount, + due_date, + ); + client.mark_invoice_defaulted(&invoice_b, &Some(grace)); + + // Both invoices must be Defaulted + assert_eq!( + client.get_invoice(&invoice_a).status, + InvoiceStatus::Defaulted + ); + assert_eq!( + client.get_invoice(&invoice_b).status, + InvoiceStatus::Defaulted + ); + + // Neither should appear in the Funded list + let funded = client.get_invoices_by_status(&InvoiceStatus::Funded); + assert!(!funded.iter().any(|id| id == invoice_a)); + assert!(!funded.iter().any(|id| id == invoice_b)); + + // Both investments must be Defaulted + assert_eq!( + client.get_invoice_investment(&invoice_a).status, + crate::investment::InvestmentStatus::Defaulted + ); + assert_eq!( + client.get_invoice_investment(&invoice_b).status, + crate::investment::InvestmentStatus::Defaulted + ); +} From 4a05c3a8e314dda60378d5f4f4defa7b2aa9e331 Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Mon, 30 Mar 2026 14:40:23 +0100 Subject: [PATCH 2/3] fix: replace std::vec/str with core/fixed-array in normalize_tag for no_std WASM build --- quicklendx-contracts/src/verification.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index f3c36e65..4f66b6ab 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -623,17 +623,18 @@ pub fn normalize_tag(env: &Env, tag: &String) -> Result let mut buf = [0u8; 50]; tag.copy_into_slice(&mut buf[..tag.len() as usize]); - let mut normalized_bytes = std::vec::Vec::new(); + let mut normalized_bytes = [0u8; 50]; let raw_slice = &buf[..tag.len() as usize]; - for &b in raw_slice.iter() { + for (i, &b) in raw_slice.iter().enumerate() { let lower = if b >= b'A' && b <= b'Z' { b + 32 } else { b }; - normalized_bytes.push(lower); + normalized_bytes[i] = lower; } + let normalized_slice = &normalized_bytes[..tag.len() as usize]; let normalized_str = String::from_str( env, - std::str::from_utf8(&normalized_bytes).map_err(|_| QuickLendXError::InvalidTag)?, + core::str::from_utf8(normalized_slice).map_err(|_| QuickLendXError::InvalidTag)?, ); let trimmed = normalized_str; // Simplification: in a full implementation, we'd handle leading/trailing whitespace bytes From 257d36357a1605625ff56bd1f73753f951f2503e Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Mon, 30 Mar 2026 15:01:04 +0100 Subject: [PATCH 3/3] chore: update WASM size baseline to 244588 B --- quicklendx-contracts/scripts/check-wasm-size.sh | 2 +- quicklendx-contracts/scripts/wasm-size-baseline.toml | 4 ++-- quicklendx-contracts/tests/wasm_build_size_budget.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/quicklendx-contracts/scripts/check-wasm-size.sh b/quicklendx-contracts/scripts/check-wasm-size.sh index e91ca613..8b8a93d5 100755 --- a/quicklendx-contracts/scripts/check-wasm-size.sh +++ b/quicklendx-contracts/scripts/check-wasm-size.sh @@ -31,7 +31,7 @@ cd "$CONTRACTS_DIR" # ── Budget constants ─────────────────────────────────────────────────────────── MAX_BYTES="$((256 * 1024))" # 262 144 B – hard limit (network deployment ceiling) WARN_BYTES="$((MAX_BYTES * 9 / 10))" # 235 929 B – 90 % warning zone -BASELINE_BYTES=217668 # last recorded optimised size +BASELINE_BYTES=244588 # last recorded optimised size REGRESSION_MARGIN_PCT=5 # 5 % growth allowed vs baseline REGRESSION_LIMIT=$(( BASELINE_BYTES + BASELINE_BYTES * REGRESSION_MARGIN_PCT / 100 )) WASM_NAME="quicklendx_contracts.wasm" diff --git a/quicklendx-contracts/scripts/wasm-size-baseline.toml b/quicklendx-contracts/scripts/wasm-size-baseline.toml index 9f7d4c9c..1f5e5c3e 100644 --- a/quicklendx-contracts/scripts/wasm-size-baseline.toml +++ b/quicklendx-contracts/scripts/wasm-size-baseline.toml @@ -23,10 +23,10 @@ # Optimised WASM size in bytes at the last recorded state. # Must match WASM_SIZE_BASELINE_BYTES in tests/wasm_build_size_budget.rs # and BASELINE_BYTES in scripts/check-wasm-size.sh. -bytes = 217668 +bytes = 244588 # ISO-8601 date when this baseline was last recorded (informational only). -recorded = "2026-03-25" +recorded = "2026-03-30" # Maximum fractional growth allowed relative to `bytes` before CI fails. # Must match WASM_REGRESSION_MARGIN in tests/wasm_build_size_budget.rs. diff --git a/quicklendx-contracts/tests/wasm_build_size_budget.rs b/quicklendx-contracts/tests/wasm_build_size_budget.rs index d28af004..5695d42f 100644 --- a/quicklendx-contracts/tests/wasm_build_size_budget.rs +++ b/quicklendx-contracts/tests/wasm_build_size_budget.rs @@ -34,7 +34,7 @@ //! |----------------------------|----------------|---------------------------------------------| //! | `WASM_SIZE_BUDGET_BYTES` | 262 144 B (256 KiB) | Hard failure threshold | //! | `WASM_SIZE_WARNING_BYTES` | ~235 929 B (90 %) | Warning zone upper edge | -//! | `WASM_SIZE_BASELINE_BYTES` | 217 668 B | Last recorded optimised size | +//! | `WASM_SIZE_BASELINE_BYTES` | 244 588 B | Last recorded optimised size | //! | `WASM_REGRESSION_MARGIN` | 0.05 (5 %) | Max allowed growth vs baseline | use std::path::PathBuf; @@ -73,7 +73,7 @@ const WASM_SIZE_WARNING_BYTES: u64 = (WASM_SIZE_BUDGET_BYTES as f64 * 0.90) as u /// Keep this up-to-date so the regression window stays tight. When a PR /// legitimately increases the contract size, the author must update this /// constant and `scripts/wasm-size-baseline.toml` in the same commit. -const WASM_SIZE_BASELINE_BYTES: u64 = 217_668; +const WASM_SIZE_BASELINE_BYTES: u64 = 244_588; /// Maximum fractional growth allowed relative to `WASM_SIZE_BASELINE_BYTES` /// before the regression test fails (5 %).