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
52 changes: 52 additions & 0 deletions docs/dispute-timeline-invariants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dispute Timeline Invariants

This document is the executable reference for the dispute timeline property
tests in `quicklendx-contracts/src/test_dispute_timeline_props.rs`.

## State Machine

| Action | Allowed current `dispute_status` | Next `dispute_status` | Timeline effect | Terminal |
|---|---|---|---|---|
| `create` | `None` | `Disputed` | Append `Opened` | No |
| `evidence` | `Disputed` | `Disputed` | No new timeline entry | No |
| `under_review` | `Disputed` | `UnderReview` | Append `UnderReview` | No |
| `resolve` | `UnderReview` | `Resolved` | Append `Resolved` | Yes |

Legal action grammar:

`create -> evidence* -> (under_review -> resolve?)?`

## Ordering Rules

- `Opened` is always the first visible timeline entry.
- `UnderReview` may appear at most once and only after `Opened`.
- `Resolved` may appear at most once and only after `UnderReview`.
- Evidence updates are allowed only while the dispute remains `Disputed`, and
they never create a visible timeline row.
- `Resolved` is terminal. Any later `evidence`, `under_review`, or `resolve`
action must be rejected deterministically.

## Timestamp Rules

- `Opened.timestamp` is the dispute creation ledger timestamp.
- `UnderReview.timestamp` is the exact ledger timestamp when the dispute enters
review, persisted separately from the final resolution timestamp.
- `Resolved.timestamp` is the dispute resolution ledger timestamp.
- When accepted transitions occur at strictly increasing ledger timestamps, the
timeline returned by `get_dispute_timeline` must also be strictly increasing.

## Duplicate Prevention

- The timeline must not contain duplicate lifecycle entries.
- Duplicate `create` attempts must be rejected with `DisputeAlreadyExists`.
- Duplicate `under_review` attempts after review has started must be rejected
with `InvalidStatus`.
- Duplicate `resolve` attempts after final resolution must be rejected with
`DisputeNotUnderReview`.

## Audit Trail Interplay

The dispute timeline is a user-facing summary and does not replace the append-only invoice audit trail.
Timeline redaction rules remain in force even when audit queries are available,
so evidence and privileged reviewer identity are not leaked through the
timeline endpoint.
13 changes: 11 additions & 2 deletions quicklendx-contracts/src/dispute.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use crate::admin::AdminStorage;
use crate::dispute_timeline::{
clear_under_review_timestamp, set_under_review_timestamp,
};
use crate::errors::QuickLendXError;
use crate::storage::InvoiceStorage;
use crate::types::{Dispute, DisputeStatus};
Expand Down Expand Up @@ -74,6 +77,7 @@ pub fn create_dispute(
validate_dispute_reason(reason)?;
validate_dispute_evidence(evidence)?;
validate_dispute_eligibility(&invoice, creator)?;
clear_under_review_timestamp(env, invoice_id);

// Set dispute fields
invoice.dispute_status = DisputeStatus::Disputed;
Expand Down Expand Up @@ -102,12 +106,17 @@ pub fn put_dispute_under_review(
let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

if invoice.dispute_status != DisputeStatus::Disputed {
return Err(QuickLendXError::DisputeNotFound);
match invoice.dispute_status {
DisputeStatus::None => return Err(QuickLendXError::DisputeNotFound),
DisputeStatus::Disputed => {}
DisputeStatus::UnderReview | DisputeStatus::Resolved => {
return Err(QuickLendXError::InvalidStatus);
}
}

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

Expand Down
71 changes: 59 additions & 12 deletions quicklendx-contracts/src/dispute_timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,33 @@
//! must not leak to unprivileged callers (evidence, resolution text), and
//! returns a paginated [`DisputeTimeline`] value.
//!
//! # Invariants
//!
//! The timeline is intentionally stricter than the dispute storage shape:
//! - `Opened` always comes first.
//! - `UnderReview` may appear at most once and only after `Opened`.
//! - `Resolved` may appear at most once and only after `UnderReview`.
//! - `update_dispute_evidence` never appends a visible timeline entry.
//! - `Resolved` is terminal; later actions must be rejected by the state machine.
//!
//! The executable version of this ordering lives in
//! `docs/dispute-timeline-invariants.md`, and the property tests lock that
//! document to the code path so drift becomes a test failure.
//!
//! # Security
//!
//! - Evidence is **always** redacted from timeline entries; it is only
//! accessible via `get_dispute_details` to authorized parties.
//! - Resolution text is redacted until the dispute reaches `Resolved` status.
//! - No PII from invoice metadata is included.
//! - Pagination bounds use saturating arithmetic to prevent overflow.
//! - The dispute timeline is a user-facing summary, not a replacement for the
//! append-only invoice audit trail.

use crate::errors::QuickLendXError;
use crate::invoice::{Dispute, DisputeStatus, InvoiceStorage};
use soroban_sdk::{contracttype, Address, BytesN, Env, String, Vec};
use crate::invoice::{Dispute, DisputeStatus};
use crate::storage::InvoiceStorage;
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec};

// ---------------------------------------------------------------------------
// Constants
Expand All @@ -31,6 +47,9 @@ pub const TIMELINE_MAX_PAGE_SIZE: u32 = 50;
/// Sentinel address used when a field is redacted (all-zero Stellar address).
const REDACTED_ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";

/// Persistent dispute-review timestamp namespace.
const DISPUTE_REVIEW_AT_KEY: Symbol = symbol_short!("dsp_rvat");

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -83,10 +102,36 @@ fn redacted_address(env: &Env) -> Address {
Address::from_str(env, REDACTED_ADDRESS)
}

fn dispute_review_at_key(invoice_id: &BytesN<32>) -> (Symbol, BytesN<32>) {
(DISPUTE_REVIEW_AT_KEY, invoice_id.clone())
}

/// Persist the exact ledger timestamp when a dispute entered `UnderReview`.
pub(crate) fn set_under_review_timestamp(env: &Env, invoice_id: &BytesN<32>, timestamp: u64) {
env.storage()
.persistent()
.set(&dispute_review_at_key(invoice_id), &timestamp);
}

/// Read the persisted `UnderReview` timestamp, if the dispute reached review.
pub(crate) fn get_under_review_timestamp(env: &Env, invoice_id: &BytesN<32>) -> Option<u64> {
env.storage()
.persistent()
.get(&dispute_review_at_key(invoice_id))
}

/// Remove any stale persisted `UnderReview` timestamp for a dispute.
pub(crate) fn clear_under_review_timestamp(env: &Env, invoice_id: &BytesN<32>) {
env.storage()
.persistent()
.remove(&dispute_review_at_key(invoice_id));
}

/// Builds the full ordered event list from a [`Dispute`] and its current
/// [`DisputeStatus`]. Returns at most 3 entries (one per lifecycle stage).
fn build_all_entries(
env: &Env,
invoice_id: &BytesN<32>,
dispute: &Dispute,
status: &DisputeStatus,
) -> Vec<DisputeTimelineEntry> {
Expand All @@ -107,18 +152,20 @@ fn build_all_entries(
// Present when status is UnderReview or Resolved.
let include_review = matches!(status, DisputeStatus::UnderReview | DisputeStatus::Resolved);
if include_review {
let review_timestamp = get_under_review_timestamp(env, invoice_id).unwrap_or_else(|| {
// Older records may predate the dedicated review timestamp key.
// Fall back to the best available lower bound without mutating state.
if dispute.resolved_at > dispute.created_at {
dispute.resolved_at.saturating_sub(1)
} else {
dispute.created_at
}
});

entries.push_back(DisputeTimelineEntry {
sequence: 1,
event: String::from_str(env, "UnderReview"),
// The review timestamp is not stored separately; we use the
// resolution timestamp as a lower bound when resolved, otherwise
// we use created_at as a conservative placeholder. This reflects
// on-chain truth: the exact review time is not persisted.
timestamp: if dispute.resolved_at > 0 {
dispute.resolved_at
} else {
dispute.created_at
},
timestamp: review_timestamp,
// Admin identity is redacted to avoid leaking privileged info.
actor: redacted_address(env),
summary: String::from_str(env, ""),
Expand Down Expand Up @@ -210,7 +257,7 @@ pub fn get_dispute_timeline(
return Err(QuickLendXError::DisputeNotFound);
}

let all_entries = build_all_entries(env, &invoice.dispute, &invoice.dispute_status);
let all_entries = build_all_entries(env, invoice_id, &invoice.dispute, &invoice.dispute_status);
let total = all_entries.len() as u32;
let (entries, has_more) = paginate(env, &all_entries, offset, limit);

Expand Down
28 changes: 28 additions & 0 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub mod currency;
pub mod defaults;
pub mod diagnostics;
pub mod dispute;
pub mod dispute_timeline;
pub mod emergency;
pub mod errors;
pub mod escrow;
Expand Down Expand Up @@ -80,6 +81,8 @@ mod test_cleanup_pagination;
mod test_currency;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dispute;
#[cfg(test)]
mod test_dispute_timeline_props;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_invariant_model;
#[cfg(all(test, feature = "legacy-tests"))]
Expand Down Expand Up @@ -2892,6 +2895,7 @@ impl QuickLendXContract {
if reason.len() == 0 {
return Err(QuickLendXError::InvalidDisputeReason);
}
dispute_timeline::clear_under_review_timestamp(&env, &invoice_id);
invoice.dispute_status = DisputeStatus::Disputed;
invoice.dispute = crate::types::Dispute {
created_by: creator.clone(),
Expand Down Expand Up @@ -2974,9 +2978,19 @@ impl QuickLendXContract {
AdminStorage::require_admin(&env, &admin)?;
let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id)
.ok_or(QuickLendXError::InvoiceNotFound)?;

match invoice.dispute_status {
DisputeStatus::None => return Err(QuickLendXError::DisputeNotFound),
DisputeStatus::Disputed => {}
DisputeStatus::UnderReview | DisputeStatus::Resolved => {
return Err(QuickLendXError::InvalidStatus);
}
}

invoice.dispute_status = DisputeStatus::UnderReview;
InvoiceStorage::update_invoice(&env, &invoice);
dispute::track_dispute_invoice(&env, &invoice_id);
dispute_timeline::set_under_review_timestamp(&env, &invoice_id, env.ledger().timestamp());
// Emit DisputeUnderReview event immediately after state mutation.
emit_dispute_under_review(&env, &invoice_id, &admin);
Ok(())
Expand All @@ -2992,6 +3006,11 @@ impl QuickLendXContract {
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.clone();
invoice.dispute.resolved_by = admin.clone();
Expand Down Expand Up @@ -3022,6 +3041,15 @@ impl QuickLendXContract {
result
}

pub fn get_dispute_timeline(
env: Env,
invoice_id: BytesN<32>,
offset: u32,
limit: u32,
) -> Result<dispute_timeline::DisputeTimeline, QuickLendXError> {
dispute_timeline::get_dispute_timeline(&env, &invoice_id, offset, limit)
}

pub fn get_invoices_by_dispute_status(
env: Env,
dispute_status: DisputeStatus,
Expand Down
Loading
Loading