Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .codex
Empty file.
79 changes: 68 additions & 11 deletions docs/contracts/invoice-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Invoice Lifecycle Management

This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.
This document describes the invoice lifecycle management functionality in the QuickLendX protocol, including invoice upload, verification/approval, and cancellation.

> Note
> `update_invoice_status` is an admin-only recovery/backfill pathway. It is not a
> replacement for the normal `accept_bid`, `settle_invoice`, or overdue-default
> flows, and it intentionally avoids escrow and payment side effects.

## Overview

Expand Down Expand Up @@ -84,7 +89,7 @@ Allows an admin or oracle to verify an uploaded invoice, making it available for

---

### 3. `cancel_invoice`
### 3. `cancel_invoice`

Allows a business to cancel their own invoice before it has been funded by an investor.

Expand All @@ -111,10 +116,58 @@ Allows a business to cancel their own invoice before it has been funded by an in
**Failure Cases**:
- `InvoiceNotFound` - Invoice does not exist
- `Unauthorized` - Caller is not the business owner
- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled

---
### 4. `refund_escrow_funds`
- `InvalidStatus` - Invoice is already funded, paid, defaulted, or cancelled

---
### 4. `update_invoice_status`

Allows the configured admin to move an invoice through a limited recovery path
when tests, migrations, or operational repair require a manual state correction.

**Authorization**: Admin only (requires authentication)

**Parameters**:
- `env: Env` - Contract environment
- `invoice_id: BytesN<32>` - ID of the invoice to update
- `new_status: InvoiceStatus` - Target lifecycle status

**Returns**: `Result<(), QuickLendXError>` - Success or error

**Supported transitions**:
- `Pending` → `Verified`
- `Verified` → `Funded`
- `Funded` → `Paid`
- `Funded` → `Defaulted`

**Unsupported transitions**:
- Any transition targeting `Pending`, `Cancelled`, or `Refunded`
- Any transition from terminal invoices (`Cancelled`, `Refunded`)
- Any transition that skips the supported recovery path, such as `Verified` → `Paid`

**Index updates**:
- Removes the invoice ID from the previous status bucket before persisting
- Adds the invoice ID to the new status bucket after persisting
- Keeps `get_invoices_by_status` and `get_invoice_count_by_status` aligned

**Events Emitted**:
- `inv_ver` when moving to `Verified`
- `inv_fnd` when moving to `Funded`
- `inv_set` when moving to `Paid` through the admin override path
- `inv_def` when moving to `Defaulted`

**Security Notes**:
- The function requires the stored admin address and fails with `NotAdmin` if none is configured
- Manual `Paid` updates emit the canonical settlement event with zeroed settlement values because no payment transfer is executed by this pathway
- Manual `Funded` updates are bookkeeping-only and do not create escrow or investment records
- Production flows should prefer `verify_invoice`, `accept_bid`, `settle_invoice`, and `mark_invoice_defaulted`

**Failure Cases**:
- `NotAdmin` - No admin configured
- `InvoiceNotFound` - Invoice does not exist
- `InvalidStatus` - Unsupported target status or invalid transition

---
### 5. `refund_escrow_funds`

Allows an admin or the business owner to refund a funded invoice, returning funds to the investor.

Expand Down Expand Up @@ -159,10 +212,11 @@ Allows an admin or the business owner to refund a funded invoice, returning fund
- Can update invoice metadata
- Can update invoice category and tags

### Admin/Oracle
- Can verify invoices
- Can reject verification
- Can set admin address
### Admin/Oracle
- Can verify invoices
- Can run the constrained `update_invoice_status` recovery path
- Can reject verification
- Can set admin address

### Investor
- Cannot directly interact with invoice lifecycle (can only bid on verified invoices)
Expand Down Expand Up @@ -237,7 +291,10 @@ cancel_invoice(env, invoice_id)?;

## Security Considerations

1. **Authentication**: All state-changing operations require proper authentication
1. **Authentication**: All state-changing operations require proper authentication
2. **Recovery pathway isolation**: `update_invoice_status` is admin-only and does not move funds
3. **Index consistency**: Status-list removals/additions happen in the same override operation
4. **Canonical events**: Admin overrides emit the same lifecycle topics used by normal flows so indexers do not need a separate schema
- `upload_invoice`: Business must authenticate
- `verify_invoice`: Admin must authenticate
- `cancel_invoice`: Business owner must authenticate
Expand Down
4 changes: 2 additions & 2 deletions quicklendx-contracts/src/currency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ impl CurrencyWhitelist {
admin: &Address,
currency: &Address,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let mut list = Self::get_whitelisted_currencies(env);
if list.iter().any(|a| a == *currency) {
Expand Down Expand Up @@ -86,7 +86,7 @@ impl CurrencyWhitelist {
admin: &Address,
currencies: &Vec<Address>,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let mut deduped: Vec<Address> = Vec::new(env);
for currency in currencies.iter() {
Expand Down
169 changes: 169 additions & 0 deletions quicklendx-contracts/src/dispute.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,171 @@
use crate::admin::AdminStorage;
use crate::errors::QuickLendXError;
use crate::invoice::{Dispute, DisputeStatus, InvoiceStatus, InvoiceStorage};
use crate::protocol_limits::{
MAX_DISPUTE_EVIDENCE_LENGTH, MAX_DISPUTE_REASON_LENGTH, MAX_DISPUTE_RESOLUTION_LENGTH,
};
use soroban_sdk::{symbol_short, Address, BytesN, Env, String, Vec};

fn dispute_index_key() -> soroban_sdk::Symbol {
symbol_short!("dispute")
}

fn get_dispute_index(env: &Env) -> Vec<BytesN<32>> {
env.storage()
.instance()
.get(&dispute_index_key())
.unwrap_or_else(|| Vec::new(env))
}

fn add_to_dispute_index(env: &Env, invoice_id: &BytesN<32>) {
let mut ids = get_dispute_index(env);
if !ids.iter().any(|id| id == *invoice_id) {
ids.push_back(invoice_id.clone());
env.storage().instance().set(&dispute_index_key(), &ids);
}
}

fn zero_address(env: &Env) -> Address {
Address::from_str(env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
}

#[allow(dead_code)]
pub fn create_dispute(
env: &Env,
invoice_id: &BytesN<32>,
creator: &Address,
reason: &String,
evidence: &String,
) -> Result<(), QuickLendXError> {
creator.require_auth();

let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

if invoice.dispute_status != DisputeStatus::None {
return Err(QuickLendXError::DisputeAlreadyExists);
}

match invoice.status {
InvoiceStatus::Pending
| InvoiceStatus::Verified
| InvoiceStatus::Funded
| InvoiceStatus::Paid => {}
_ => return Err(QuickLendXError::InvalidStatus),
}

let is_business = *creator == invoice.business;
let is_investor = invoice
.investor
.as_ref()
.map_or(false, |investor| *creator == *investor);
if !is_business && !is_investor {
return Err(QuickLendXError::DisputeNotAuthorized);
}

if reason.len() == 0 || reason.len() > MAX_DISPUTE_REASON_LENGTH {
return Err(QuickLendXError::InvalidDisputeReason);
}
if evidence.len() == 0 || evidence.len() > MAX_DISPUTE_EVIDENCE_LENGTH {
return Err(QuickLendXError::InvalidDisputeEvidence);
}

invoice.dispute_status = DisputeStatus::Disputed;
invoice.dispute = Dispute {
created_by: creator.clone(),
created_at: env.ledger().timestamp(),
reason: reason.clone(),
evidence: evidence.clone(),
resolution: String::from_str(env, ""),
resolved_by: zero_address(env),
resolved_at: 0,
};

InvoiceStorage::update_invoice(env, &invoice);
add_to_dispute_index(env, invoice_id);
Ok(())
}

#[allow(dead_code)]
pub fn put_dispute_under_review(
env: &Env,
admin: &Address,
invoice_id: &BytesN<32>,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin(env, admin)?;
let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

if invoice.dispute_status == DisputeStatus::None {
return Err(QuickLendXError::DisputeNotFound);
}
if invoice.dispute_status != DisputeStatus::Disputed {
return Err(QuickLendXError::InvalidStatus);
}

invoice.dispute_status = DisputeStatus::UnderReview;
InvoiceStorage::update_invoice(env, &invoice);
Ok(())
}

#[allow(dead_code)]
pub fn resolve_dispute(
env: &Env,
admin: &Address,
invoice_id: &BytesN<32>,
resolution: &String,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin(env, admin)?;
let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

if invoice.dispute_status == DisputeStatus::None {
return Err(QuickLendXError::DisputeNotFound);
}
if invoice.dispute_status != DisputeStatus::UnderReview {
return Err(QuickLendXError::DisputeNotUnderReview);
}
if resolution.len() == 0 || resolution.len() > MAX_DISPUTE_RESOLUTION_LENGTH {
return Err(QuickLendXError::InvalidDisputeReason);
}

invoice.dispute_status = DisputeStatus::Resolved;
invoice.dispute.resolution = resolution.clone();
invoice.dispute.resolved_by = admin.clone();
invoice.dispute.resolved_at = env.ledger().timestamp();
InvoiceStorage::update_invoice(env, &invoice);
Ok(())
}

#[allow(dead_code)]
pub fn get_dispute_details(env: &Env, invoice_id: &BytesN<32>) -> Option<Dispute> {
let invoice = InvoiceStorage::get_invoice(env, invoice_id)?;
if invoice.dispute_status == DisputeStatus::None {
None
} else {
Some(invoice.dispute)
}
}

#[allow(dead_code)]
pub fn get_invoices_with_disputes(env: &Env) -> Vec<BytesN<32>> {
get_dispute_index(env)
}

#[allow(dead_code)]
pub fn get_invoices_by_dispute_status(
env: &Env,
status: &DisputeStatus,
) -> Vec<BytesN<32>> {
let mut result = Vec::new(env);
for invoice_id in get_dispute_index(env).iter() {
if let Some(invoice) = InvoiceStorage::get_invoice(env, &invoice_id) {
if invoice.dispute_status == *status {
result.push_back(invoice_id);
}
}
}
result
}
//! Invoice disputes are represented on [`crate::invoice::Invoice`] and handled by contract
//! entry points in `lib.rs`. This module is reserved for future dispute-specific helpers.
6 changes: 3 additions & 3 deletions quicklendx-contracts/src/emergency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ impl EmergencyWithdraw {
amount: i128,
target: Address,
) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

if amount <= 0 {
return Err(QuickLendXError::InvalidAmount);
Expand Down Expand Up @@ -173,7 +173,7 @@ impl EmergencyWithdraw {
/// * `EmergencyWithdrawCancelled` if withdrawal was cancelled
/// * Transfer errors (e.g. `InsufficientFunds`) if contract balance is insufficient
pub fn execute(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let pending: PendingEmergencyWithdrawal = env
.storage()
Expand Down Expand Up @@ -243,7 +243,7 @@ impl EmergencyWithdraw {
/// * `EmergencyWithdrawNotFound` if no pending withdrawal exists
/// * `EmergencyWithdrawCancelled` if withdrawal is already cancelled
pub fn cancel(env: &Env, admin: &Address) -> Result<(), QuickLendXError> {
AdminStorage::require_admin_auth(env, admin)?;
AdminStorage::require_admin(env, admin)?;

let mut pending: PendingEmergencyWithdrawal = env
.storage()
Expand Down
Loading
Loading