diff --git a/docs/contracts/settlement.md b/docs/contracts/settlement.md index d140ea7f..6b77e187 100644 --- a/docs/contracts/settlement.md +++ b/docs/contracts/settlement.md @@ -1,225 +1,225 @@ -# Settlement Contract Flow - -## Overview -QuickLendX settlement supports full and partial invoice payments with durable on-chain payment records and hardened finalization safety. - -- Partial payments accumulate per invoice. -- Payment progress is queryable at any time. -- Applied payment amount is capped so `total_paid` never exceeds invoice `amount` (total due). -- Every applied payment is persisted as a dedicated payment record with payer, amount, timestamp, and nonce/tx id. -- Settlement finalization is protected against double-execution via a dedicated finalization flag. -- Disbursement invariant (`investor_return + platform_fee == total_paid`) is checked before fund transfer. - -## State Machine -QuickLendX uses existing invoice statuses. For settlement: - -- `Funded`: open for repayment; may have zero or more partial payments. -- `Paid`: terminal settled state after full repayment and distribution. -- `Cancelled`: terminal non-payable state. - -Partial repayment is represented by: - -- `status == Funded` -- `total_paid > 0` -- `progress_percent < 100` - -## Storage Layout -Settlement storage in `src/settlement.rs` uses keyed records (no large single-value payment vector as source of truth): - -- `PaymentCount(invoice_id) -> u32` -- `Payment(invoice_id, idx) -> SettlementPaymentRecord` -- `PaymentNonce(invoice_id, payer, nonce) -> bool` -- `Finalized(invoice_id) -> bool` — double-settlement guard flag - -`SettlementPaymentRecord` fields: - -- `payer: Address` -- `amount: i128` (applied amount) -- `timestamp: u64` (ledger timestamp) -- `nonce: String` (tx id / nonce) - -Invoice fields used for progress: - -- `amount` (total due) -- `total_paid` -- `status` - -## Overpayment Behavior -Settlement and partial-payment paths intentionally behave differently: - -- `process_partial_payment` safely bounds any excess request with `applied_amount = min(requested_amount, remaining_due)`. -- `settle_invoice` rejects explicit overpayment attempts with `InvalidAmount` unless the submitted amount exactly matches the remaining due. -- In both paths, `total_paid` can never exceed `amount`. - -Accounting guarantees: - -- Rejected settlement overpayments do not mutate invoice state, investment state, balances, or settlement events. -- Accepted final settlements emit `pay_rec` for the exact remaining due and `inv_stlf` for the final settled total. - -## Finalization Safety - -### Double-Settlement Protection -A dedicated `Finalized(invoice_id)` storage flag is set atomically during settlement finalization. Any subsequent settlement attempt (via `settle_invoice` or auto-settlement through `process_partial_payment`) is rejected immediately with `InvalidStatus`. - -### Accounting Invariant -Before disbursing funds, the settlement engine asserts: - -``` -investor_return + platform_fee == total_paid -``` - -If this invariant is violated (e.g., due to rounding errors in fee calculation), the settlement is rejected with `InvalidAmount`. This prevents any accounting drift between what the business paid and what gets disbursed. - -### Payment Count Limit -Each invoice is limited to `MAX_PAYMENT_COUNT` (1,000) discrete payment records. This prevents unbounded storage growth and protects against payment-count overflow attacks. - -## Public Query API - -| Function | Signature | Description | -|----------|-----------|-------------| -| `get_invoice_progress` | `(env, invoice_id) -> Progress` | Aggregate settlement progress | -| `get_payment_count` | `(env, invoice_id) -> u32` | Total number of payment records | -| `get_payment_record` | `(env, invoice_id, index) -> SettlementPaymentRecord` | Single record by index | -| `get_payment_records` | `(env, invoice_id, from, limit) -> Vec` | Paginated record slice | -| `is_invoice_finalized` | `(env, invoice_id) -> bool` | Whether settlement is complete | - -## Events -Settlement emits: - -- `pay_rec` (PaymentRecorded): `(invoice_id, payer, applied_amount, total_paid, status)` -- `inv_stlf` (InvoiceSettledFinal): `(invoice_id, final_amount, paid_at)` - -Backward-compatible events still emitted: - -- `inv_pp` (partial payment event) -- `inv_set` (existing settlement event) - -## Security Considerations - -### Replay/Idempotency -- Non-empty nonce is enforced unique per `(invoice, payer, nonce)`. -- Duplicate nonce attempts are rejected with `OperationNotAllowed`. -- Nonces are scoped per invoice — the same nonce can be used on different invoices. - -### Overpayment Integrity -- Final settlement requires an exact remaining-due payment to avoid ambiguous excess-value handling. -- Partial-payment capping protects incremental repayment flows without allowing accounting drift. - -### Arithmetic Safety -- Checked arithmetic (`checked_add`, `checked_sub`, `checked_mul`, `checked_div`) is used for all payment accumulation and progress calculations. -- Invalid/overflowing states reject with contract errors. - -### Authorization -- Payer must be the invoice business owner and must authorize payment. - -### Closed Invoice Protection -- Payments are rejected for `Paid`, `Cancelled`, `Defaulted`, and `Refunded` states. - -### Invariants -- `total_paid <= total_due` is enforced at every payment step. -- `investor_return + platform_fee == total_paid` is enforced at finalization. -- `payment_count <= MAX_PAYMENT_COUNT` (1,000) per invoice. - -## Timestamp Consistency Guarantees -Settlement and adjacent lifecycle entrypoints enforce monotonic ledger-time assumptions to avoid -temporal anomalies when validators, simulation environments, or test harnesses move time backward. - -- Guarded flows: - - Create: invoice due date must remain strictly in the future (`due_date > now`). - - Fund: funding entrypoints reject if `now < created_at`. - - Settle: settlement rejects if `now < created_at` or `now < funded_at`. - - Default: default handlers reject if `now < created_at` or `now < funded_at`. -- Error behavior: - - Non-monotonic transitions fail with `InvalidTimestamp`. -- Data integrity assumptions: - - `created_at` is immutable once written. - - If present, `funded_at` must not precede `created_at`. - - Lifecycle transitions rely only on ledger timestamp (not sequence number) for time checks. - -### Threat Model Notes -- Mitigated: - - Backward-time execution paths that could otherwise settle/default before a valid funding-time - reference. - - Cross-step inconsistencies caused by stale temporal assumptions. - - Double-settlement via finalization flag. - - Accounting drift via disbursement invariant check. - - Unbounded storage via payment count limit. -- Not mitigated: - - Consensus-level manipulation of canonical ledger time beyond protocol tolerance. - - Misconfigured off-chain automation that never advances time far enough to pass grace windows. - -## Running Tests -From `quicklendx-contracts/`: - -```bash -cargo test test_partial_payments -- --nocapture -cargo test test_settlement -- --nocapture -``` - -## Vesting Validation Notes -The vesting flow also relies on ledger-time validation to keep token release schedules sane and reviewable. - -- Schedule creation rejects zero-value vesting amounts. -- The creating caller must authorize and must be the configured protocol admin. -- `start_time` cannot be backdated relative to the current ledger timestamp. -- `end_time` must be strictly after `start_time`. -- `cliff_time = start_time + cliff_seconds` must not overflow and must be strictly before `end_time`. -- Release calculations reject impossible stored states such as `released_amount > total_amount` or timelines where `cliff_time` falls outside `[start_time, end_time)`. - -These checks prevent schedules that would unlock immediately from stale timestamps, collapse into zero-duration timelines, or defer the entire vesting curve to an invalid cliff boundary. - -## Timestamp Consistency Guarantees -Settlement and adjacent lifecycle entrypoints enforce monotonic ledger-time assumptions to avoid -temporal anomalies when validators, simulation environments, or test harnesses move time backward. - -- Guarded flows: - - Create: invoice due date must remain strictly in the future (`due_date > now`). - - Fund: funding entrypoints reject if `now < created_at`. - - Settle: settlement rejects if `now < created_at` or `now < funded_at`. - - Default: default handlers reject if `now < created_at` or `now < funded_at`. -- Error behavior: - - Non-monotonic transitions fail with `InvalidTimestamp`. -- Data integrity assumptions: - - `created_at` is immutable once written. - - If present, `funded_at` must not precede `created_at`. - - Lifecycle transitions rely only on ledger timestamp (not sequence number) for time checks. - -### Threat Model Notes -- Mitigated: - - Backward-time execution paths that could otherwise settle/default before a valid funding-time - reference. - - Cross-step inconsistencies caused by stale temporal assumptions. -- Not mitigated: - - Consensus-level manipulation of canonical ledger time beyond protocol tolerance. - - Misconfigured off-chain automation that never advances time far enough to pass grace windows. - -## Escrow Release Rules - -The escrow release lifecycle follows a strict path to prevent premature or repeated release of funds. - -### Release Conditions -- **Invoice Status**: Must be `Funded`. Release is prohibited for `Pending`, `Verified`, `Refunded`, or `Cancelled` invoices. -- **Escrow Status**: Must be `Held`. This ensures funds are only moved once. -- **Verification**: If an invoice is verified *after* being funded, the protocol can automatically trigger the release to ensure the business receives capital promptly. - -### Idempotency and Retries -- The release operation is idempotent. -- Atomic Transfer: Funds move before the state update. If the transfer fails, the state is NOT updated, allowing for safe retries. -- Success Guard: Once status becomes `Released`, further attempts are rejected with `InvalidStatus`. - -### Lifecycle Transitions -| Action | Invoice Status | Escrow Status | Result | -|--------|----------------|--------------|--------| -| `accept_bid` | `Verified` -> `Funded` | `None` -> `Held` | Funds locked in contract | -| `release_escrow` | `Funded` | `Held` -> `Released` | Funds moved to Business | -| `refund_escrow` | `Funded` -> `Refunded` | `Held` -> `Refunded` | Funds moved to Investor | -| `settle_invoice` | `Funded` -> `Paid` | `Released` | Invoice settled; Investor paid | - -## Running Tests -From `quicklendx-contracts/`: - -```bash -cargo test test_partial_payments -- --nocapture -cargo test test_settlement -- --nocapture -cargo test test_release_escrow_ -- --nocapture -``` +# Settlement Contract Flow + +## Overview +QuickLendX settlement supports full and partial invoice payments with durable on-chain payment records and hardened finalization safety. + +- Partial payments accumulate per invoice. +- Payment progress is queryable at any time. +- Applied payment amount is capped so `total_paid` never exceeds invoice `amount` (total due). +- Every applied payment is persisted as a dedicated payment record with payer, amount, timestamp, and nonce/tx id. +- Settlement finalization is protected against double-execution via a dedicated finalization flag. +- Disbursement invariant (`investor_return + platform_fee == total_paid`) is checked before fund transfer. + +## State Machine +QuickLendX uses existing invoice statuses. For settlement: + +- `Funded`: open for repayment; may have zero or more partial payments. +- `Paid`: terminal settled state after full repayment and distribution. +- `Cancelled`: terminal non-payable state. + +Partial repayment is represented by: + +- `status == Funded` +- `total_paid > 0` +- `progress_percent < 100` + +## Storage Layout +Settlement storage in `src/settlement.rs` uses keyed records (no large single-value payment vector as source of truth): + +- `PaymentCount(invoice_id) -> u32` +- `Payment(invoice_id, idx) -> SettlementPaymentRecord` +- `PaymentNonce(invoice_id, payer, nonce) -> bool` +- `Finalized(invoice_id) -> bool` — double-settlement guard flag + +`SettlementPaymentRecord` fields: + +- `payer: Address` +- `amount: i128` (applied amount) +- `timestamp: u64` (ledger timestamp) +- `nonce: String` (tx id / nonce) + +Invoice fields used for progress: + +- `amount` (total due) +- `total_paid` +- `status` + +## Overpayment Behavior +Settlement and partial-payment paths intentionally behave differently: + +- `process_partial_payment` safely bounds any excess request with `applied_amount = min(requested_amount, remaining_due)`. +- `settle_invoice` rejects explicit overpayment attempts with `InvalidAmount` unless the submitted amount exactly matches the remaining due. +- In both paths, `total_paid` can never exceed `amount`. + +Accounting guarantees: + +- Rejected settlement overpayments do not mutate invoice state, investment state, balances, or settlement events. +- Accepted final settlements emit `pay_rec` for the exact remaining due and `inv_stlf` for the final settled total. + +## Finalization Safety + +### Double-Settlement Protection +A dedicated `Finalized(invoice_id)` storage flag is set atomically during settlement finalization. Any subsequent settlement attempt (via `settle_invoice` or auto-settlement through `process_partial_payment`) is rejected immediately with `InvalidStatus`. + +### Accounting Invariant +Before disbursing funds, the settlement engine asserts: + +``` +investor_return + platform_fee == total_paid +``` + +If this invariant is violated (e.g., due to rounding errors in fee calculation), the settlement is rejected with `InvalidAmount`. This prevents any accounting drift between what the business paid and what gets disbursed. + +### Payment Count Limit +Each invoice is limited to `MAX_PAYMENT_COUNT` (1,000) discrete payment records. This prevents unbounded storage growth and protects against payment-count overflow attacks. + +## Public Query API + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_invoice_progress` | `(env, invoice_id) -> Progress` | Aggregate settlement progress | +| `get_payment_count` | `(env, invoice_id) -> u32` | Total number of payment records | +| `get_payment_record` | `(env, invoice_id, index) -> SettlementPaymentRecord` | Single record by index | +| `get_payment_records` | `(env, invoice_id, from, limit) -> Vec` | Paginated record slice | +| `is_invoice_finalized` | `(env, invoice_id) -> bool` | Whether settlement is complete | + +## Events +Settlement emits: + +- `pay_rec` (PaymentRecorded): `(invoice_id, payer, applied_amount, total_paid, status)` +- `inv_stlf` (InvoiceSettledFinal): `(invoice_id, final_amount, paid_at)` + +Backward-compatible events still emitted: + +- `inv_pp` (partial payment event) +- `inv_set` (existing settlement event) + +## Security Considerations + +### Replay/Idempotency +- Non-empty nonce is enforced unique per `(invoice, payer, nonce)`. +- Duplicate nonce attempts are rejected with `OperationNotAllowed`. +- Nonces are scoped per invoice — the same nonce can be used on different invoices. + +### Overpayment Integrity +- Final settlement requires an exact remaining-due payment to avoid ambiguous excess-value handling. +- Partial-payment capping protects incremental repayment flows without allowing accounting drift. + +### Arithmetic Safety +- Checked arithmetic (`checked_add`, `checked_sub`, `checked_mul`, `checked_div`) is used for all payment accumulation and progress calculations. +- Invalid/overflowing states reject with contract errors. + +### Authorization +- Payer must be the invoice business owner and must authorize payment. + +### Closed Invoice Protection +- Payments are rejected for `Paid`, `Cancelled`, `Defaulted`, and `Refunded` states. + +### Invariants +- `total_paid <= total_due` is enforced at every payment step. +- `investor_return + platform_fee == total_paid` is enforced at finalization. +- `payment_count <= MAX_PAYMENT_COUNT` (1,000) per invoice. + +## Timestamp Consistency Guarantees +Settlement and adjacent lifecycle entrypoints enforce monotonic ledger-time assumptions to avoid +temporal anomalies when validators, simulation environments, or test harnesses move time backward. + +- Guarded flows: + - Create: invoice due date must remain strictly in the future (`due_date > now`). + - Fund: funding entrypoints reject if `now < created_at`. + - Settle: settlement rejects if `now < created_at` or `now < funded_at`. + - Default: default handlers reject if `now < created_at` or `now < funded_at`. +- Error behavior: + - Non-monotonic transitions fail with `InvalidTimestamp`. +- Data integrity assumptions: + - `created_at` is immutable once written. + - If present, `funded_at` must not precede `created_at`. + - Lifecycle transitions rely only on ledger timestamp (not sequence number) for time checks. + +### Threat Model Notes +- Mitigated: + - Backward-time execution paths that could otherwise settle/default before a valid funding-time + reference. + - Cross-step inconsistencies caused by stale temporal assumptions. + - Double-settlement via finalization flag. + - Accounting drift via disbursement invariant check. + - Unbounded storage via payment count limit. +- Not mitigated: + - Consensus-level manipulation of canonical ledger time beyond protocol tolerance. + - Misconfigured off-chain automation that never advances time far enough to pass grace windows. + +## Running Tests +From `quicklendx-contracts/`: + +```bash +cargo test test_partial_payments -- --nocapture +cargo test test_settlement -- --nocapture +``` + +## Vesting Validation Notes +The vesting flow also relies on ledger-time validation to keep token release schedules sane and reviewable. + +- Schedule creation rejects zero-value vesting amounts. +- The creating caller must authorize and must be the configured protocol admin. +- `start_time` cannot be backdated relative to the current ledger timestamp. +- `end_time` must be strictly after `start_time`. +- `cliff_time = start_time + cliff_seconds` must not overflow and must be strictly before `end_time`. +- Release calculations reject impossible stored states such as `released_amount > total_amount` or timelines where `cliff_time` falls outside `[start_time, end_time)`. + +These checks prevent schedules that would unlock immediately from stale timestamps, collapse into zero-duration timelines, or defer the entire vesting curve to an invalid cliff boundary. + +## Timestamp Consistency Guarantees +Settlement and adjacent lifecycle entrypoints enforce monotonic ledger-time assumptions to avoid +temporal anomalies when validators, simulation environments, or test harnesses move time backward. + +- Guarded flows: + - Create: invoice due date must remain strictly in the future (`due_date > now`). + - Fund: funding entrypoints reject if `now < created_at`. + - Settle: settlement rejects if `now < created_at` or `now < funded_at`. + - Default: default handlers reject if `now < created_at` or `now < funded_at`. +- Error behavior: + - Non-monotonic transitions fail with `InvalidTimestamp`. +- Data integrity assumptions: + - `created_at` is immutable once written. + - If present, `funded_at` must not precede `created_at`. + - Lifecycle transitions rely only on ledger timestamp (not sequence number) for time checks. + +### Threat Model Notes +- Mitigated: + - Backward-time execution paths that could otherwise settle/default before a valid funding-time + reference. + - Cross-step inconsistencies caused by stale temporal assumptions. +- Not mitigated: + - Consensus-level manipulation of canonical ledger time beyond protocol tolerance. + - Misconfigured off-chain automation that never advances time far enough to pass grace windows. + +## Escrow Release Rules + +The escrow release lifecycle follows a strict path to prevent premature or repeated release of funds. + +### Release Conditions +- **Invoice Status**: Must be `Funded`. Release is prohibited for `Pending`, `Verified`, `Refunded`, or `Cancelled` invoices. +- **Escrow Status**: Must be `Held`. This ensures funds are only moved once. +- **Verification**: If an invoice is verified *after* being funded, the protocol can automatically trigger the release to ensure the business receives capital promptly. + +### Idempotency and Retries +- The release operation is idempotent. +- Atomic Transfer: Funds move before the state update. If the transfer fails, the state is NOT updated, allowing for safe retries. +- Success Guard: Once status becomes `Released`, further attempts are rejected with `InvalidStatus`. + +### Lifecycle Transitions +| Action | Invoice Status | Escrow Status | Result | +|--------|----------------|--------------|--------| +| `accept_bid` | `Verified` -> `Funded` | `None` -> `Held` | Funds locked in contract | +| `release_escrow` | `Funded` | `Held` -> `Released` | Funds moved to Business | +| `refund_escrow` | `Funded` -> `Refunded` | `Held` -> `Refunded` | Funds moved to Investor | +| `settle_invoice` | `Funded` -> `Paid` | `Released` | Invoice settled; Investor paid | + +## Running Tests +From `quicklendx-contracts/`: + +```bash +cargo test test_partial_payments -- --nocapture +cargo test test_settlement -- --nocapture +cargo test test_release_escrow_ -- --nocapture +``` diff --git a/docs/contracts/vesting.md b/docs/contracts/vesting.md index fda20a0c..b3da9bbb 100644 --- a/docs/contracts/vesting.md +++ b/docs/contracts/vesting.md @@ -67,11 +67,40 @@ The implementation uses inclusive/exclusive boundaries correctly: ## Security Considerations -1. **Admin authorization**: Schedule creation requires admin auth -2. **Beneficiary authorization**: Release requires beneficiary auth -3. **No over-release**: `released_amount` tracked to prevent double-spending -4. **Overflow protection**: Checked arithmetic for calculations -5. **Timestamp validation**: `end_time > start_time` enforced +1. **Admin authorization**: Schedule creation requires admin auth; non-admin callers are rejected with `NotAdmin` +2. **Beneficiary authorization**: Release requires beneficiary auth; non-beneficiary callers are rejected with `Unauthorized` +3. **Cliff enforcement**: `release()` returns `InvalidTimestamp` (not a silent no-op) when called before `cliff_time`, so callers can distinguish "too early" from "fully released" +4. **No over-release**: `released_amount` is tracked and validated after every release; overflow uses checked arithmetic +5. **Overflow protection**: `checked_mul`, `checked_add`, `checked_sub` used throughout; overflow returns `InvalidAmount` +6. **Timestamp validation**: `end_time > start_time` and `cliff_time < end_time` enforced at creation; backdated `start_time` rejected +7. **State invariant re-check**: `validate_schedule_state` re-validates stored schedule before every arithmetic operation + +## Admin Threat Model + +### Admin Powers +The protocol admin is the only address that can create vesting schedules. Specifically, admin can: +- Lock any amount of any token into a new schedule for any beneficiary +- Transfer the admin role to a new address (after which the old address loses all admin powers) + +### Threat Scenarios + +| Threat | Mitigation | +|--------|-----------| +| Non-admin creates a schedule | `require_auth` + `require_admin` gate; rejected with `NotAdmin` | +| Admin creates zero-amount schedule | `total_amount <= 0` check; rejected with `InvalidAmount` | +| Admin backdates `start_time` | `start_time < now` check; rejected with `InvalidTimestamp` | +| Admin sets `end_time <= start_time` | Explicit check; rejected with `InvalidTimestamp` | +| Admin sets `cliff_time >= end_time` (degenerate) | `cliff_time >= end_time` check; rejected with `InvalidTimestamp` | +| Old admin retains power after role transfer | `require_admin` reads live admin key; old address fails after transfer | +| Beneficiary releases before cliff | `release()` returns `InvalidTimestamp`; no state mutation occurs | +| Beneficiary double-releases | `released_amount` tracking; second call returns `Ok(0)` | +| Beneficiary releases more than total | Post-release `validate_schedule_state` catches `released_amount > total_amount` | +| Non-beneficiary releases tokens | `beneficiary` field compared to caller; rejected with `Unauthorized` | + +### Not Mitigated +- **Compromised admin key**: A stolen key can create arbitrary schedules. Mitigate at the key-management layer (multisig, hardware wallet). +- **Consensus-level time manipulation**: Ledger timestamp is trusted; extreme validator collusion could affect cliff/end boundaries. +- **Token contract bugs**: `transfer_funds` delegates to the token contract; a malicious token can re-enter or fail silently. ## Time Boundaries Table @@ -116,10 +145,11 @@ Returns the vesting schedule by ID, if exists. ### `get_vesting_vested` ```rust -pub fn vested_amount(env: &Env, schedule_id: u64) -> Result +pub fn get_vesting_vested(env: Env, id: u64) -> Option ``` Calculates total vested amount at current time using linear vesting from `start_time`. +Returns `None` if the schedule does not exist or the stored state is invalid. ### `get_vesting_releasable` @@ -132,11 +162,15 @@ Returns amount available for release: `max(vested - released, 0)`. ### `release_vested_tokens` ```rust -pub fn release(env: &Env, beneficiary: &Address, id: u64) -> Result +pub fn release_vested_tokens(env: Env, beneficiary: Address, id: u64) -> Result ``` Transfers releasable tokens to beneficiary. Updates `released_amount`. +- Returns `InvalidTimestamp` if called before `cliff_time` (not a silent no-op). +- Returns `Ok(0)` if called after full release (idempotent). +- Returns `Unauthorized` if caller is not the schedule beneficiary. + ## Testing Run vesting tests: @@ -147,7 +181,7 @@ cargo test vesting --lib ### Test Coverage -- Before cliff: 0 releasable +- Before cliff: 0 releasable; `release()` returns `InvalidTimestamp` - At cliff: positive releasable - After cliff, before end: partial release - At end time: full amount @@ -156,3 +190,11 @@ cargo test vesting --lib - Off-by-one timestamp boundaries - Multiple partial releases - Integer division rounding +- Admin boundary: non-admin rejected +- Admin boundary: zero amount rejected +- Admin boundary: backdated start rejected +- Admin boundary: `end_time <= start_time` rejected +- Admin boundary: `cliff_time >= end_time` rejected +- Admin boundary: old admin loses power after role transfer +- Non-beneficiary release rejected +- Querying non-existent schedule returns `None` diff --git a/quicklendx-contracts/src/test_vesting.rs b/quicklendx-contracts/src/test_vesting.rs index f6c8cea8..e612cea5 100644 --- a/quicklendx-contracts/src/test_vesting.rs +++ b/quicklendx-contracts/src/test_vesting.rs @@ -877,12 +877,16 @@ fn test_very_long_vesting_period() { #[test] fn test_immediate_cliff_equals_end() { + // A cliff that lands exactly at end_time is rejected by the contract (cliff_time >= end_time). + // This test verifies that the contract correctly rejects such a degenerate schedule and that + // a schedule with cliff just before end_time works as expected. let (env, client, admin, beneficiary, token_id, _token_client) = setup(); let total = 1000i128; let start = 1000u64; - let cliff_seconds = 1000u64; // cliff = end - let end = start + cliff_seconds; + // cliff_seconds = end - start - 1 so cliff_time = end_time - 1 (valid) + let end = start + 1001u64; + let cliff_seconds = 1000u64; // cliff_time = start + 1000 = end - 1 client.create_vesting_schedule( &admin, @@ -894,12 +898,12 @@ fn test_immediate_cliff_equals_end() { &end, ); - // At cliff/end + // At end time all tokens are vested env.ledger().set_timestamp(end); let releasable = client.get_vesting_releasable(&1).unwrap(); assert_eq!( releasable, total, - "Full amount should be releasable when cliff equals end" + "Full amount should be releasable at end time" ); } @@ -1027,3 +1031,115 @@ fn test_only_admin_can_create_schedule() { assert!(result.is_err()); } + +// ============================================================================ +// ADMIN BOUNDARY TESTS +// These tests cover the threat model for admin powers over vesting schedules. +// ============================================================================ + +/// Admin cannot create a schedule with zero total_amount. +#[test] +fn test_admin_rejects_zero_amount() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + let result = client.try_create_vesting_schedule( + &admin, &token_id, &beneficiary, + &0i128, &1500u64, &0u64, &2000u64, + ); + assert!(result.is_err(), "Zero-amount schedule must be rejected"); +} + +/// Admin cannot create a schedule with a backdated start_time. +#[test] +fn test_admin_rejects_backdated_start() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + // Ledger is at 1000; start_time = 999 is in the past. + let result = client.try_create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &999u64, &0u64, &2000u64, + ); + assert!(result.is_err(), "Backdated start_time must be rejected"); +} + +/// Admin cannot create a schedule where end_time <= start_time. +#[test] +fn test_admin_rejects_end_before_start() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + let result = client.try_create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &1500u64, &0u64, &1500u64, // end == start + ); + assert!(result.is_err(), "end_time == start_time must be rejected"); +} + +/// Admin cannot create a schedule where cliff_time >= end_time. +#[test] +fn test_admin_rejects_cliff_at_or_after_end() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + // cliff_seconds = 1000, start = 1000 → cliff_time = 2000 = end_time + let result = client.try_create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &1000u64, &1000u64, &2000u64, + ); + assert!(result.is_err(), "cliff_time == end_time must be rejected"); +} + +/// After admin role is transferred, the old admin loses the ability to create schedules. +#[test] +fn test_old_admin_loses_vesting_power_after_transfer() { + let (env, client, admin, beneficiary, token_id, token_client) = setup(); + let new_admin = Address::generate(&env); + + // Fund new_admin so it can back a schedule + token_client.approve(&new_admin, &client.address, &ADMIN_BALANCE, &(env.ledger().sequence() + 10_000)); + + // Transfer admin role + client.transfer_admin(&new_admin); + + // Old admin can no longer create a vesting schedule + let result = client.try_create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &1500u64, &0u64, &2000u64, + ); + assert!(result.is_err(), "Old admin must not create schedules after role transfer"); +} + +/// Non-beneficiary cannot release tokens from someone else's schedule. +#[test] +fn test_non_beneficiary_cannot_release() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + let attacker = Address::generate(&env); + + let id = client.create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &1000u64, &0u64, &2000u64, + ); + + env.ledger().set_timestamp(1500); + let result = client.try_release_vested_tokens(&attacker, &id); + assert!(result.is_err(), "Non-beneficiary must not release tokens"); +} + +/// Release before cliff returns an error (not a silent no-op). +#[test] +fn test_release_before_cliff_is_error_not_noop() { + let (env, client, admin, beneficiary, token_id, _) = setup(); + + let id = client.create_vesting_schedule( + &admin, &token_id, &beneficiary, + &1000i128, &1000u64, &500u64, &3000u64, + ); + + // cliff_time = 1500; set ledger to 1499 + env.ledger().set_timestamp(1499); + let result = client.try_release_vested_tokens(&beneficiary, &id); + assert!(result.is_err(), "Release before cliff must return an error"); +} + +/// Querying a non-existent schedule returns None without panicking. +#[test] +fn test_get_nonexistent_schedule_returns_none() { + let (_env, client, _, _, _, _) = setup(); + assert!(client.get_vesting_schedule(&9999).is_none()); + assert!(client.get_vesting_releasable(&9999).is_none()); + assert!(client.get_vesting_vested(&9999).is_none()); +} diff --git a/quicklendx-contracts/src/vesting.rs b/quicklendx-contracts/src/vesting.rs index 235e2523..e55042a2 100644 --- a/quicklendx-contracts/src/vesting.rs +++ b/quicklendx-contracts/src/vesting.rs @@ -237,7 +237,9 @@ impl Vesting { /// /// # Security /// - Requires beneficiary authorization - /// - Enforces timelock/cliff and prevents over-release + /// - Enforces timelock/cliff: returns `InvalidTimestamp` if called before cliff + /// - Prevents over-release via `released_amount` tracking + /// - Idempotent after full release: returns `Ok(0)` when nothing remains pub fn release(env: &Env, beneficiary: &Address, id: u64) -> Result { beneficiary.require_auth(); @@ -248,6 +250,13 @@ impl Vesting { return Err(QuickLendXError::Unauthorized); } + // Enforce cliff: reject early calls with a typed error so callers can distinguish + // "too early" from "already fully released". + let now = env.ledger().timestamp(); + if now < schedule.cliff_time { + return Err(QuickLendXError::InvalidTimestamp); + } + let releasable = Self::releasable_amount(env, &schedule)?; if releasable <= 0 { // Idempotent behavior: repeated calls return 0 instead of error diff --git a/quicklendx-contracts/tests/wasm_build_size_budget.rs b/quicklendx-contracts/tests/wasm_build_size_budget.rs index 52524003..2c588f1a 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` | 241 218 B | Last recorded optimised size | //! | `WASM_REGRESSION_MARGIN` | 0.05 (5 %) | Max allowed growth vs baseline | use std::path::PathBuf;