diff --git a/docs/dispute-timeline-invariants.md b/docs/dispute-timeline-invariants.md new file mode 100644 index 00000000..3b041f0f --- /dev/null +++ b/docs/dispute-timeline-invariants.md @@ -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. diff --git a/quicklendx-contracts/src/dispute.rs b/quicklendx-contracts/src/dispute.rs index fd79e3e7..cb10f84d 100644 --- a/quicklendx-contracts/src/dispute.rs +++ b/quicklendx-contracts/src/dispute.rs @@ -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}; @@ -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; @@ -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(()) } diff --git a/quicklendx-contracts/src/dispute_timeline.rs b/quicklendx-contracts/src/dispute_timeline.rs index 8b313141..a7eb0264 100644 --- a/quicklendx-contracts/src/dispute_timeline.rs +++ b/quicklendx-contracts/src/dispute_timeline.rs @@ -9,6 +9,19 @@ //! 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 @@ -16,10 +29,13 @@ //! - 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 @@ -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 // --------------------------------------------------------------------------- @@ -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), ×tamp); +} + +/// Read the persisted `UnderReview` timestamp, if the dispute reached review. +pub(crate) fn get_under_review_timestamp(env: &Env, invoice_id: &BytesN<32>) -> Option { + 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 { @@ -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, ""), @@ -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); diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 99fa8a3f..26ea9c78 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -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; @@ -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"))] @@ -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(), @@ -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(()) @@ -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(); @@ -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::get_dispute_timeline(&env, &invoice_id, offset, limit) + } + pub fn get_invoices_by_dispute_status( env: Env, dispute_status: DisputeStatus, diff --git a/quicklendx-contracts/src/test_dispute_timeline_props.rs b/quicklendx-contracts/src/test_dispute_timeline_props.rs new file mode 100644 index 00000000..7ea235b5 --- /dev/null +++ b/quicklendx-contracts/src/test_dispute_timeline_props.rs @@ -0,0 +1,534 @@ +#[cfg(test)] +mod test_dispute_timeline_props { + use alloc::vec::Vec as RustVec; + use crate::dispute_timeline::DisputeTimeline; + use crate::errors::QuickLendXError; + use crate::invoice::{DisputeStatus, Invoice, InvoiceCategory}; + use crate::storage::InvoiceStorage; + use crate::QuickLendXContract; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, BytesN, Env, String, Vec, + }; + + const RANDOMIZED_SEQUENCE_CASES: usize = 20_000; + const MAX_ACTIONS_PER_SEQUENCE: usize = 8; + const REDACTED_ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const DOC_STATE_MACHINE_TABLE: &str = r#"| 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 |"#; + const DOC_GRAMMAR_LINE: &str = + "`create -> evidence* -> (under_review -> resolve?)?`"; + const DOC_AUDIT_NOTE: &str = + "The dispute timeline is a user-facing summary and does not replace the append-only invoice audit trail."; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Action { + Create, + Evidence, + UnderReview, + Resolve, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum ModelState { + None, + Disputed, + UnderReview, + Resolved, + } + + #[derive(Clone, Debug)] + struct ModelOutcome { + final_state: ModelState, + accepted_steps: usize, + first_error: Option, + created_at: Option, + review_at: Option, + resolved_at: Option, + } + + struct XorShift64 { + state: u64, + } + + impl XorShift64 { + fn new(seed: u64) -> Self { + Self { + state: if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed }, + } + } + + fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x + } + } + + fn setup() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + let contract_id = env.register(QuickLendXContract, ()); + let admin = Address::generate(&env); + let business = Address::generate(&env); + let currency = Address::generate(&env); + + env.as_contract(&contract_id, || { + QuickLendXContract::set_admin(env.clone(), admin.clone()) + .expect("admin should initialize"); + }); + + (env, contract_id, admin, business, currency) + } + + fn create_invoice( + env: &Env, + contract_id: &Address, + business: &Address, + currency: &Address, + ) -> BytesN<32> { + env.as_contract(contract_id, || { + let invoice = Invoice::new( + env, + business.clone(), + 100_000i128, + currency.clone(), + env.ledger().timestamp() + 30 * 24 * 60 * 60, + String::from_str(env, "Dispute timeline property invoice"), + InvoiceCategory::Services, + Vec::new(env), + ) + .expect("invoice should build"); + + let invoice_id = invoice.id.clone(); + InvoiceStorage::store(env, &invoice); + invoice_id + }) + } + + fn redacted_address(env: &Env) -> Address { + Address::from_str(env, REDACTED_ADDRESS) + } + + fn action_from_roll(roll: u64) -> Action { + match roll % 4 { + 0 => Action::Create, + 1 => Action::Evidence, + 2 => Action::UnderReview, + _ => Action::Resolve, + } + } + + fn simulate(actions: &[Action], timestamps: &[u64]) -> ModelOutcome { + let mut state = ModelState::None; + let mut accepted_steps = 0usize; + let mut created_at = None; + let mut review_at = None; + let mut resolved_at = None; + let mut first_error = None; + + for (index, action) in actions.iter().enumerate() { + let timestamp = timestamps[index]; + let outcome = match (state, action) { + (ModelState::None, Action::Create) => { + state = ModelState::Disputed; + created_at = Some(timestamp); + Ok(()) + } + (ModelState::None, Action::Evidence) => Err(QuickLendXError::InvalidStatus), + (ModelState::None, Action::UnderReview) => Err(QuickLendXError::DisputeNotFound), + (ModelState::None, Action::Resolve) => { + Err(QuickLendXError::DisputeNotUnderReview) + } + (ModelState::Disputed, Action::Create) => { + Err(QuickLendXError::DisputeAlreadyExists) + } + (ModelState::Disputed, Action::Evidence) => Ok(()), + (ModelState::Disputed, Action::UnderReview) => { + state = ModelState::UnderReview; + review_at = Some(timestamp); + Ok(()) + } + (ModelState::Disputed, Action::Resolve) => { + Err(QuickLendXError::DisputeNotUnderReview) + } + (ModelState::UnderReview, Action::Create) => { + Err(QuickLendXError::DisputeAlreadyExists) + } + (ModelState::UnderReview, Action::Evidence) => { + Err(QuickLendXError::InvalidStatus) + } + (ModelState::UnderReview, Action::UnderReview) => { + Err(QuickLendXError::InvalidStatus) + } + (ModelState::UnderReview, Action::Resolve) => { + state = ModelState::Resolved; + resolved_at = Some(timestamp); + Ok(()) + } + (ModelState::Resolved, Action::Create) => { + Err(QuickLendXError::DisputeAlreadyExists) + } + (ModelState::Resolved, Action::Evidence) => Err(QuickLendXError::InvalidStatus), + (ModelState::Resolved, Action::UnderReview) => { + Err(QuickLendXError::InvalidStatus) + } + (ModelState::Resolved, Action::Resolve) => { + Err(QuickLendXError::DisputeNotUnderReview) + } + }; + + match outcome { + Ok(()) => accepted_steps += 1, + Err(err) => { + first_error = Some(err); + break; + } + } + } + + ModelOutcome { + final_state: state, + accepted_steps, + first_error, + created_at, + review_at, + resolved_at, + } + } + + fn apply_action( + env: &Env, + contract_id: &Address, + admin: &Address, + business: &Address, + invoice_id: &BytesN<32>, + action: Action, + ) -> Result<(), QuickLendXError> { + let reason = String::from_str(env, "Timeline dispute reason"); + let evidence = String::from_str(env, "Timeline dispute evidence"); + let updated_evidence = String::from_str(env, "Timeline dispute evidence updated"); + let resolution = String::from_str(env, "Timeline dispute resolved"); + + match action { + Action::Create => env.as_contract(contract_id, || { + QuickLendXContract::create_dispute( + env.clone(), + invoice_id.clone(), + business.clone(), + reason.clone(), + evidence.clone(), + ) + }), + Action::Evidence => env.as_contract(contract_id, || { + QuickLendXContract::update_dispute_evidence( + env.clone(), + invoice_id.clone(), + business.clone(), + updated_evidence.clone(), + ) + }), + Action::UnderReview => env.as_contract(contract_id, || { + QuickLendXContract::put_dispute_under_review( + env.clone(), + invoice_id.clone(), + admin.clone(), + ) + }), + Action::Resolve => env.as_contract(contract_id, || { + QuickLendXContract::resolve_dispute( + env.clone(), + invoice_id.clone(), + admin.clone(), + resolution.clone(), + ) + }), + } + } + + fn assert_timeline_matches_model( + env: &Env, + timeline: &DisputeTimeline, + model: &ModelOutcome, + business: &Address, + ) { + let expected_status = match model.final_state { + ModelState::None => DisputeStatus::None, + ModelState::Disputed => DisputeStatus::Disputed, + ModelState::UnderReview => DisputeStatus::UnderReview, + ModelState::Resolved => DisputeStatus::Resolved, + }; + assert_eq!(timeline.current_status, expected_status); + assert_eq!(timeline.total as usize, timeline.entries.len() as usize); + + for i in 0..timeline.entries.len() { + let current = timeline.entries.get(i).unwrap(); + assert_eq!(current.sequence, i); + + if i > 0 { + let previous = timeline.entries.get(i - 1).unwrap(); + assert!( + previous.timestamp < current.timestamp, + "timeline timestamps must be strictly increasing" + ); + assert_ne!( + previous.event, current.event, + "timeline must not contain duplicate lifecycle entries" + ); + } + } + + match model.final_state { + ModelState::None => panic!("timeline should not exist without a dispute"), + ModelState::Disputed => { + assert_eq!(timeline.entries.len(), 1); + let opened = timeline.entries.get(0).unwrap(); + assert_eq!(opened.event, String::from_str(env, "Opened")); + assert_eq!(opened.timestamp, model.created_at.unwrap()); + assert_eq!(opened.actor, *business); + } + ModelState::UnderReview => { + assert_eq!(timeline.entries.len(), 2); + let opened = timeline.entries.get(0).unwrap(); + let review = timeline.entries.get(1).unwrap(); + assert_eq!(opened.event, String::from_str(env, "Opened")); + assert_eq!(review.event, String::from_str(env, "UnderReview")); + assert_eq!(opened.timestamp, model.created_at.unwrap()); + assert_eq!(review.timestamp, model.review_at.unwrap()); + assert_eq!(review.actor, redacted_address(env)); + } + ModelState::Resolved => { + assert_eq!(timeline.entries.len(), 3); + let opened = timeline.entries.get(0).unwrap(); + let review = timeline.entries.get(1).unwrap(); + let resolved = timeline.entries.get(2).unwrap(); + assert_eq!(opened.event, String::from_str(env, "Opened")); + assert_eq!(review.event, String::from_str(env, "UnderReview")); + assert_eq!(resolved.event, String::from_str(env, "Resolved")); + assert_eq!(opened.timestamp, model.created_at.unwrap()); + assert_eq!(review.timestamp, model.review_at.unwrap()); + assert_eq!(resolved.timestamp, model.resolved_at.unwrap()); + assert_eq!(review.actor, redacted_address(env)); + } + } + } + + #[test] + fn test_dispute_timeline_props_doc_sync() { + let docs = include_str!("../../docs/dispute-timeline-invariants.md"); + assert!( + docs.contains(DOC_STATE_MACHINE_TABLE), + "docs/dispute-timeline-invariants.md must contain the authoritative state-machine table" + ); + assert!( + docs.contains(DOC_GRAMMAR_LINE), + "docs/dispute-timeline-invariants.md must contain the legal action grammar" + ); + assert!( + docs.contains(DOC_AUDIT_NOTE), + "docs/dispute-timeline-invariants.md must document audit-trail interplay" + ); + } + + #[test] + fn test_dispute_timeline_props_randomized_sequences() { + let mut legal_sequences = 0usize; + let mut illegal_sequences = 0usize; + let mut resolved_sequences = 0usize; + let (env, contract_id, admin, business, currency) = setup(); + + for seed in 1..=RANDOMIZED_SEQUENCE_CASES as u64 { + let mut rng = XorShift64::new(seed); + let sequence_len = (rng.next_u64() as usize % MAX_ACTIONS_PER_SEQUENCE) + 1; + + let mut action_list = RustVec::new(); + let mut timestamp_list = RustVec::new(); + let mut current_timestamp = 1_000u64; + for _ in 0..sequence_len { + action_list.push(action_from_roll(rng.next_u64())); + current_timestamp = current_timestamp.saturating_add(1 + (rng.next_u64() % 17)); + timestamp_list.push(current_timestamp); + } + + let model = simulate(&action_list, ×tamp_list); + env.ledger().set_timestamp(1_000); + let invoice_id = create_invoice(&env, &contract_id, &business, ¤cy); + + for (index, action) in action_list.iter().enumerate() { + env.ledger().set_timestamp(timestamp_list[index]); + let actual = apply_action( + &env, + &contract_id, + &admin, + &business, + &invoice_id, + *action, + ); + + if index < model.accepted_steps { + assert!( + actual.is_ok(), + "legal prefix action {index} should succeed for seed {seed}" + ); + } else { + let expected = model.first_error.expect("illegal sequence must have an error"); + let actual_err = actual.expect_err("illegal action should fail"); + assert_eq!( + actual_err, expected, + "illegal action mismatch at step {index} for seed {seed}" + ); + break; + } + } + + match model.final_state { + ModelState::None => { + illegal_sequences += 1; + let err = env + .as_contract(&contract_id, || { + QuickLendXContract::get_dispute_timeline( + env.clone(), + invoice_id.clone(), + 0, + 10, + ) + }) + .expect_err("timeline should not exist without a dispute"); + assert_eq!(err, QuickLendXError::DisputeNotFound); + } + _ => { + if model.first_error.is_some() { + illegal_sequences += 1; + } else { + legal_sequences += 1; + } + if model.final_state == ModelState::Resolved { + resolved_sequences += 1; + } + + let timeline = env + .as_contract(&contract_id, || { + QuickLendXContract::get_dispute_timeline( + env.clone(), + invoice_id.clone(), + 0, + 10, + ) + }) + .expect("timeline should load"); + assert_timeline_matches_model(&env, &timeline, &model, &business); + } + } + } + + assert!(legal_sequences > 0, "randomized harness must exercise legal sequences"); + assert!( + illegal_sequences > 0, + "randomized harness must exercise illegal sequences" + ); + assert!( + resolved_sequences > 0, + "randomized harness must reach terminal resolved sequences" + ); + } + + #[test] + fn test_dispute_timeline_props_resolve_is_terminal() { + let (env, contract_id, admin, business, currency) = setup(); + let invoice_id = create_invoice(&env, &contract_id, &business, ¤cy); + + env.ledger().set_timestamp(1_010); + env.as_contract(&contract_id, || { + QuickLendXContract::create_dispute( + env.clone(), + invoice_id.clone(), + business.clone(), + String::from_str(&env, "Reason"), + String::from_str(&env, "Evidence"), + ) + }) + .expect("create_dispute should succeed"); + + env.ledger().set_timestamp(1_020); + env.as_contract(&contract_id, || { + QuickLendXContract::put_dispute_under_review( + env.clone(), + invoice_id.clone(), + admin.clone(), + ) + }) + .expect("put_dispute_under_review should succeed"); + + env.ledger().set_timestamp(1_030); + env.as_contract(&contract_id, || { + QuickLendXContract::resolve_dispute( + env.clone(), + invoice_id.clone(), + admin.clone(), + String::from_str(&env, "Resolution"), + ) + }) + .expect("resolve_dispute should succeed"); + + env.ledger().set_timestamp(1_040); + let resolve_err = env + .as_contract(&contract_id, || { + QuickLendXContract::resolve_dispute( + env.clone(), + invoice_id.clone(), + admin.clone(), + String::from_str(&env, "Overwrite"), + ) + }) + .expect_err("expected contract error for second resolve"); + assert_eq!(resolve_err, QuickLendXError::DisputeNotUnderReview); + + env.ledger().set_timestamp(1_050); + let review_err = env + .as_contract(&contract_id, || { + QuickLendXContract::put_dispute_under_review( + env.clone(), + invoice_id.clone(), + admin.clone(), + ) + }) + .expect_err("expected contract error for re-review"); + assert_eq!(review_err, QuickLendXError::InvalidStatus); + + env.ledger().set_timestamp(1_060); + let evidence_err = env + .as_contract(&contract_id, || { + QuickLendXContract::update_dispute_evidence( + env.clone(), + invoice_id.clone(), + business.clone(), + String::from_str(&env, "Too late"), + ) + }) + .expect_err("expected contract error for evidence rewrite"); + assert_eq!(evidence_err, QuickLendXError::InvalidStatus); + + let timeline = env + .as_contract(&contract_id, || { + QuickLendXContract::get_dispute_timeline( + env.clone(), + invoice_id.clone(), + 0, + 10, + ) + }) + .expect("timeline should load"); + assert_eq!(timeline.entries.len(), 3); + let resolved = timeline.entries.get(2).unwrap(); + assert_eq!(resolved.event, String::from_str(&env, "Resolved")); + assert_eq!(resolved.timestamp, 1_030); + } +}