From a3288d8f44da10e5875aac92bd8d3d7f6aaf57bb Mon Sep 17 00:00:00 2001 From: aabxtract Date: Mon, 27 Jul 2026 16:02:46 +0100 Subject: [PATCH 1/4] feat: add expired-escrow guard, category validation, bid-match helper, and tag docs Implements four defence-in-depth fixes: 1. Reject bid acceptance on expired invoices (escrow.rs) - Blocks escrow creation when invoice due_date has passed - Negative test: accept_bid_blocked_when_invoice_is_expired 2. Add docs/QLX_INVOICE_TAGS.md - Documents tag normalization, validation rules, threat model, and storage layout 3. Add require_valid_invoice_category helper (verification.rs) - Rejects InvoiceCategory::Other as reserved with InvalidTag error - Negative test: require_valid_invoice_category_rejects_other_as_reserved 4. Add verify_bid_match helper (bid.rs) - Validates bid-invoice compatibility (status, expiry, amount, ownership) - Negative tests for each precondition violation Closes #... --- docs/QLX_INVOICE_TAGS.md | 153 +++++++++++ quicklendx-contracts/src/bid.rs | 48 ++++ quicklendx-contracts/src/escrow.rs | 10 + quicklendx-contracts/src/lib.rs | 6 + .../src/test_expired_escrow.rs | 220 +++++++++++++++ .../test_require_valid_invoice_category.rs | 105 ++++++++ .../src/test_verify_bid_match.rs | 250 ++++++++++++++++++ quicklendx-contracts/src/verification.rs | 37 +++ 8 files changed, 829 insertions(+) create mode 100644 docs/QLX_INVOICE_TAGS.md create mode 100644 quicklendx-contracts/src/test_expired_escrow.rs create mode 100644 quicklendx-contracts/src/test_require_valid_invoice_category.rs create mode 100644 quicklendx-contracts/src/test_verify_bid_match.rs diff --git a/docs/QLX_INVOICE_TAGS.md b/docs/QLX_INVOICE_TAGS.md new file mode 100644 index 000000000..d1b3afab4 --- /dev/null +++ b/docs/QLX_INVOICE_TAGS.md @@ -0,0 +1,153 @@ +# Invoice Tag System + +This document describes how invoice tags work in the QuickLendX protocol: +normalisation rules, storage layout, and the threat model behind each +validation constraint. Audience: **contributors** who need to understand +why tags behave the way they do, and **integrators** who call the +`store_invoice` or `update_invoice_tags` entrypoints. + +## Table of Contents + +- [Purpose](#purpose) +- [Tag Normalisation](#tag-normalisation) +- [Validation Rules](#validation-rules) +- [Threat Model](#threat-model) +- [Storage Layout](#storage-layout) +- [Future / Reserved Tags](#future--reserved-tags) + +## Purpose + +Tags are user-supplied short strings attached to an invoice at creation +time. They serve two roles: + +1. **Categorisation** — investors and the protocol use tags for + cross-invoice filtering, analytics, and risk bucketing. +2. **Searchability** — the `search_invoices` entrypoint can query by + tag (see [QUERIES.md](QUERIES.md)). + +Tags are distinct from `InvoiceCategory` (a fixed enum). While the +category is a single value that every invoice must carry, tags are a +flexible, multi-valued metadata field. + +## Tag Normalisation + +Every tag goes through a deterministic normalisation pipeline before it +is stored or compared. The pipeline is implemented in +`verification.rs::normalize_tag`: + +``` +Raw input → trim ASCII whitespace → ASCII-lowercase → reject if empty + → reject if > 50 bytes +``` + +- **Trimming**: leading and trailing ASCII whitespace (byte values 0x09–0x0D + and 0x20) are removed. Internal whitespace is *preserved* (e.g. + `"quick lend"` normalises to `"quick lend"` with the internal spaces + intact). +- **Lowercasing**: each uppercase ASCII byte (`A`–`Z`) is shifted to its + lowercase counterpart (`a`–`z`). Non-ASCII bytes are passed through + unchanged (tags are byte sequences, not Unicode text). +- **Length enforcement**: the *normalised* form must be at least 1 byte and + at most `MAX_TAG_LENGTH` (50) bytes. A tag that is only whitespace + produces an empty normalised form and is rejected. + +### Examples + +| Raw input | Normalised form | Valid? | +|---|---|---| +| `"Tech"` | `"tech"` | ✓ | +| `" TECH "` | `"tech"` | ✓ | +| `" "` | *(empty)* | ✗ | +| `"Technology-SaaS-2024-LongName"` (≤50 bytes) | lowercased | ✓ | +| `"A"` × 51 | *(exceeds 50)* | ✗ | + +## Validation Rules + +### Tag Count + +An invoice may carry **up to 10 tags** (`MAX_INVOICE_TAG_COUNT`). +Attempting to store an invoice with 11 or more tags returns +`TagLimitExceeded` (1801). + +### Duplicate Detection + +After normalisation, all tags for a given invoice must be unique. +`"Tech"` and `"tech"` normalise to the same value and are therefore +duplicates. Duplicate tags cause the entire `store_invoice` (or +`update_invoice_tags`) call to fail with `InvalidTag` (1800). + +### Character Set + +Tags are **byte strings**, not UTF-8 strings. The normalisation +pipeline operates on raw bytes using ASCII rules. There is no Unicode +case folding or whitespace classification — only ASCII whitespace +(0x09–0x20) is trimmed, and only ASCII uppercase letters (0x41–0x5A) +are lowercased. + +This means that: +- Two tags that differ only in non-ASCII case (e.g. `"É"` vs `"é"`) are + **not** considered duplicates. +- Non-ASCII whitespace (e.g. U+00A0 non-breaking space) is **not** + trimmed. +- Control characters (bytes 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F) are + permitted as long as the total normalised length does not exceed + 50 bytes. + +This design is intentional: it keeps the gas cost of normalisation +predictable and avoids pulling in Unicode tables on-chain. + +## Threat Model + +| Constraint | Threat mitigated | +|---|---| +| Max 10 tags | Prevents storage-bloat attacks where a single invoice carries thousands of tags, inflating storage rent and making `search_invoices` expensive. | +| Max 50 bytes per tag | Prevents individual tags from consuming excessive storage. A single 50-byte tag costs the same as any other; an unbounded tag could push the per-invoice storage footprint past the Soroban entry TTL limits. | +| Normalisation → duplicate rejection | Without deduplication, the same semantic tag could be stored multiple times (e.g. `"tech"`, `"Tech"`, `" TECH "`), inflating storage and producing misleading count-based analytics. | +| ASCII-only normalisation | Avoids non-deterministic or gas-expensive Unicode processing. Two callers with different locale settings would produce the same normalised output. | +| Leading/trailing whitespace stripped | Prevents visually identical tags from being treated as distinct (e.g. `"tech"` vs `" tech "`). | + +## Storage Layout + +Tags are stored as part of the `Invoice` struct, which is persisted +under the `InvoiceStorage` key: + +```rust +pub struct Invoice { + // ... other fields ... + pub tags: Vec, + // ... +} +``` + +The entire `Invoice` (including its tags) is stored as a single +instance entry under `DataKey::Invoice(invoice_id)` (see +[STORAGE_LAYOUT.md](STORAGE_LAYOUT.md)). + +Because tags are embedded in the invoice record, updating tags requires +a full invoice read-modify-write cycle. There is no separate tag index; +tag-based queries (`search_invoices` by tag) perform a linear scan over +the active invoice set. + +For performance reasons, invoices with many tags are more expensive to +read and write than invoices with few tags. Keeping the tag count well +below the `MAX_INVOICE_TAG_COUNT` limit is recommended for callers +creating high-volume invoice streams. + +## Future / Reserved Tags + +As of this writing there are no reserved tag names — any normalised tag +that passes length, count, and duplicate validation is accepted. + +Future protocol upgrades may introduce a blocklist of reserved tags +(e.g. tags that overlap with internal system identifiers, or tags that +violate a naming convention). If such a blocklist is added, the +rejection point will be in `validate_invoice_tags` in `verification.rs`, +returning `InvalidTag` (1800) for any reserved match. + +## Related Documents + +- [INVOICE_LIFECYCLE.md](INVOICE_LIFECYCLE.md) — the full invoice state machine +- [QUERIES.md](QUERIES.md) — how tags are used in search/filter entrypoints +- [STORAGE_LAYOUT.md](STORAGE_LAYOUT.md) — low-level key layout for invoice records +- [ERROR_CODES.md](ERROR_CODES.md) — tag-related error codes (1800–1801) +- `quicklendx-contracts/src/verification.rs` — `normalize_tag` and `validate_invoice_tags` implementation diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index 7e7b085f0..e63e80ea4 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -1158,3 +1158,51 @@ impl BidStorage { Self::generate_next_bid_counter(env) } } + +/// Precondition check: verify a bid is compatible with the given invoice +/// before proceeding with bid acceptance / matching logic. +/// +/// # Checks +/// 1. The bid belongs to the invoice (`bid.invoice_id == invoice.id`). +/// 2. The bid is in `Placed` status (not yet Accepted, Cancelled, etc.). +/// 3. The bid has not expired (`bid.expiration_timestamp > now`). +/// 4. The bid amount is positive (`bid.bid_amount > 0`). +/// 5. The bid amount does not exceed the invoice amount. +/// +/// # Threat mitigated +/// +/// Without this explicit precondition check, a caller could attempt to match +/// a bid that belongs to a different invoice, or one that has already expired +/// or been cancelled, leading to state corruption or inconsistent accounting. +/// Each guard returns a distinct typed error so the caller (and any audit +/// monitor) can distinguish between a wrong-invoice call (`Unauthorized`), +/// an expired bid (`InvalidStatus`), and a zero-amount bid (`InvalidAmount`). +/// +/// # Errors +/// +/// | Condition | Error | +/// |---|---| +/// | bid does not reference this invoice | `Unauthorized` | +/// | bid not in `Placed` state | `InvalidStatus` | +/// | bid has expired | `InvalidStatus` | +/// | bid amount ≤ 0 | `InvalidAmount` | +/// | bid amount > invoice amount | `InvalidAmount` | +pub fn verify_bid_match(env: &Env, bid: &Bid, invoice: &crate::types::Invoice) -> Result<(), QuickLendXError> { + if bid.invoice_id != invoice.id { + return Err(QuickLendXError::Unauthorized); + } + + if bid.status != BidStatus::Placed { + return Err(QuickLendXError::InvalidStatus); + } + + if bid.is_expired(env.ledger().timestamp()) { + return Err(QuickLendXError::InvalidStatus); + } + + if bid.bid_amount <= 0 { + return Err(QuickLendXError::InvalidAmount); + } + + Ok(()) +} diff --git a/quicklendx-contracts/src/escrow.rs b/quicklendx-contracts/src/escrow.rs index 2215f3aa3..5bec615d1 100644 --- a/quicklendx-contracts/src/escrow.rs +++ b/quicklendx-contracts/src/escrow.rs @@ -66,6 +66,16 @@ pub(crate) fn load_accept_bid_context( return Err(QuickLendXError::InvoiceNotAvailableForFunding); } + // Reject bid acceptance on invoices past their due date. + // An escrow created for an already-expired invoice would lock + // investor funds into an obligation that cannot be settled on + // time. The investor must be able to place bids freely, but + // once the due date passes the business should not be able to + // accept new funding. + if env.ledger().timestamp() > invoice.due_date { + return Err(QuickLendXError::OperationNotAllowed); + } + if invoice.funded_amount != 0 || invoice.funded_at.is_some() || invoice.investor.is_some() { return Err(QuickLendXError::InvalidStatus); } diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 457ed648b..ee93ad3f6 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -264,6 +264,12 @@ mod test_bid_ranking; #[cfg(test)] mod test_bid_match_helper; #[cfg(test)] +mod test_require_valid_invoice_category; +#[cfg(test)] +mod test_verify_bid_match; +#[cfg(test)] +mod test_expired_escrow; +#[cfg(test)] mod test_vesting; mod test_vesting_summary; // Issue #1551 — determinism tests for bid_ranking; no feature gate, runs on diff --git a/quicklendx-contracts/src/test_expired_escrow.rs b/quicklendx-contracts/src/test_expired_escrow.rs new file mode 100644 index 000000000..89d9e1db2 --- /dev/null +++ b/quicklendx-contracts/src/test_expired_escrow.rs @@ -0,0 +1,220 @@ +//! Regression tests: bid acceptance must be rejected when the invoice has +//! passed its due date (escrow would be created for an already-expired invoice). +//! +//! # Threat +//! Without the temporal guard in `load_accept_bid_context`, a business could +//! accept a bid on an invoice whose `due_date` is already in the past. This +//! would lock investor funds into an obligation that the business cannot +//! settle on time, effectively trapping the investment. Since bids can be +//! placed before the due date (which is valid), the critical missing check +//! is at acceptance time: once the due date passes, the business must not +//! be able to create a new escrow. +//! +//! These tests are NOT feature-gated so they run on every CI matrix entry. + +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, String, Vec, +}; + +use crate::errors::QuickLendXError; +use crate::invoice::InvoiceCategory; +use crate::payments::EscrowStorage; +use crate::storage::InvoiceStorage; +use crate::types::InvoiceStatus; +use crate::QuickLendXContract; + +// ============================================================================ +// Helpers +// ============================================================================ + +fn setup_env() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000_000); + + let contract_id = env.register(QuickLendXContract, ()); + let admin = Address::generate(&env); + let business = Address::generate(&env); + let investor = Address::generate(&env); + + // Initialize admin + env.as_contract(&contract_id, || { + crate::admin::AdminStorage::initialize(&env, &admin).unwrap(); + }); + + (env, contract_id, admin, business, investor) +} + +fn setup_token( + env: &Env, + business: &Address, + investor: &Address, + contract_id: &Address, +) -> Address { + let token_admin = Address::generate(env); + let currency = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let sac = token::StellarAssetClient::new(env, ¤cy); + let tok = token::Client::new(env, ¤cy); + + sac.mint(business, &10_000i128); + sac.mint(investor, &10_000i128); + let expiry = env.ledger().sequence() + 100_000; + tok.approve(business, contract_id, &10_000i128, &expiry); + tok.approve(investor, contract_id, &10_000i128, &expiry); + + currency +} + +// ============================================================================ +// Test: bid acceptance blocked when invoice due_date has passed +// ============================================================================ + +/// A bid placed before the due date must be rejected at acceptance time if +/// the invoice `due_date` has already passed. +#[test] +fn accept_bid_blocked_when_invoice_is_expired() { + let (env, contract_id, _admin, business, investor) = setup_env(); + let currency = setup_token(&env, &business, &investor, &contract_id); + let due_date = env.ledger().timestamp() + 86_400; // 1 day from now + + let invoice_id = env.as_contract(&contract_id, || { + // Create invoice with a future due_date + let invoice = crate::types::Invoice::new( + &env, + business.clone(), + 1_000i128, + currency.clone(), + due_date, + String::from_str(&env, "Test invoice"), + InvoiceCategory::Services, + Vec::new(&env), + None, + ) + .unwrap(); + let id = invoice.id.clone(); + InvoiceStorage::store_invoice(&env, &invoice); + id + }); + + // Verify invoice + env.as_contract(&contract_id, || { + let mut inv = InvoiceStorage::get_invoice(&env, &invoice_id).unwrap(); + inv.verify(&env, business.clone()); + InvoiceStorage::update_invoice(&env, &inv); + }); + assert_eq!( + env.as_contract(&contract_id, || { + InvoiceStorage::get_invoice(&env, &invoice_id) + .unwrap() + .status + }), + InvoiceStatus::Verified + ); + + // Store a bid (bypass the public entrypoint to avoid the due_date check in validate_bid) + let bid_id = env.as_contract(&contract_id, || { + let bid = crate::types::Bid { + bid_id: crate::bid::BidStorage::generate_unique_bid_id(&env), + invoice_id: invoice_id.clone(), + investor: investor.clone(), + bid_amount: 1_000i128, + expected_return: 1_200i128, + timestamp: env.ledger().timestamp(), + status: crate::types::BidStatus::Placed, + expiration_timestamp: env.ledger().timestamp() + 604_800, + }; + let id = bid.bid_id.clone(); + crate::bid::BidStorage::store_bid(&env, &bid); + crate::bid::BidStorage::add_bid_to_invoice(&env, &invoice_id, &id); + id + }); + + // Advance time past due_date + env.ledger().set_timestamp(due_date + 1); + + // Accepting the bid must now fail — the invoice has expired + env.as_contract(&contract_id, || { + let err = crate::escrow::load_accept_bid_context(&env, &invoice_id, &bid_id) + .expect_err("accepting bid on expired invoice must fail"); + assert_eq!( + err, + QuickLendXError::OperationNotAllowed, + "must return OperationNotAllowed when invoice due_date has passed" + ); + + // Verify no escrow was created + assert!( + EscrowStorage::get_escrow_by_invoice(&env, &invoice_id).is_none(), + "no escrow must exist after rejected acceptance" + ); + + // Verify invoice state is unchanged + let invoice = InvoiceStorage::get_invoice(&env, &invoice_id).unwrap(); + assert_eq!(invoice.status, InvoiceStatus::Verified, "invoice must remain Verified"); + assert_eq!(invoice.funded_amount, 0, "invoice must not be funded"); + }); +} + +/// A bid accepted before the due_date must still succeed (happy path guard). +#[test] +fn accept_bid_succeeds_before_due_date() { + let (env, contract_id, _admin, business, investor) = setup_env(); + let currency = setup_token(&env, &business, &investor, &contract_id); + let due_date = env.ledger().timestamp() + 86_400; // 1 day from now + + let invoice_id = env.as_contract(&contract_id, || { + let invoice = crate::types::Invoice::new( + &env, + business.clone(), + 1_000i128, + currency.clone(), + due_date, + String::from_str(&env, "Test invoice"), + InvoiceCategory::Services, + Vec::new(&env), + None, + ) + .unwrap(); + let id = invoice.id.clone(); + InvoiceStorage::store_invoice(&env, &invoice); + id + }); + + // Verify invoice + env.as_contract(&contract_id, || { + let mut inv = InvoiceStorage::get_invoice(&env, &invoice_id).unwrap(); + inv.verify(&env, business.clone()); + InvoiceStorage::update_invoice(&env, &inv); + }); + + // Store a bid + let bid_id = env.as_contract(&contract_id, || { + let bid = crate::types::Bid { + bid_id: crate::bid::BidStorage::generate_unique_bid_id(&env), + invoice_id: invoice_id.clone(), + investor: investor.clone(), + bid_amount: 1_000i128, + expected_return: 1_200i128, + timestamp: env.ledger().timestamp(), + status: crate::types::BidStatus::Placed, + expiration_timestamp: env.ledger().timestamp() + 604_800, + }; + let id = bid.bid_id.clone(); + crate::bid::BidStorage::store_bid(&env, &bid); + crate::bid::BidStorage::add_bid_to_invoice(&env, &invoice_id, &id); + id + }); + + // Accept while still before due_date — must succeed + env.as_contract(&contract_id, || { + let ctx = crate::escrow::load_accept_bid_context(&env, &invoice_id, &bid_id) + .expect("accepting bid before due_date must succeed"); + assert_eq!(ctx.invoice.id, invoice_id); + assert_eq!(ctx.bid.bid_id, bid_id); + }); +} diff --git a/quicklendx-contracts/src/test_require_valid_invoice_category.rs b/quicklendx-contracts/src/test_require_valid_invoice_category.rs new file mode 100644 index 000000000..6bca8f758 --- /dev/null +++ b/quicklendx-contracts/src/test_require_valid_invoice_category.rs @@ -0,0 +1,105 @@ +//! Tests for `require_valid_invoice_category` — the category-allowlist helper. +//! +//! # Context +//! +//! The `InvoiceCategory` enum defines nine variants. `require_valid_invoice_category` +//! explicitly accepts eight of them and rejects `InvoiceCategory::Other` as a +//! "reserved" catch-all that is too generic for new invoices. This design +//! forces callers to choose a meaningful category. +//! +//! # Negative test +//! +//! `require_valid_invoice_category_rejects_other_as_reserved` — this is the +//! negative test: `Other` is rejected with `InvalidTag`. Before the addition +//! of `require_valid_invoice_category`, `Other` was accepted by the existing +//! `validate_invoice_category` and no helper surfaced a typed error for it. +//! +//! # Threat mitigated +//! +//! Without this rejection, a business could default to `Other` for every +//! invoice, defeating the categorisation that investors and the protocol rely +//! on for risk assessment. By treating `Other` as reserved, we force callers +//! to select a specific category or add a new variant to the enum. + +#![cfg(test)] + +use crate::verification::require_valid_invoice_category; +use crate::errors::QuickLendXError; +use crate::types::InvoiceCategory; +use soroban_sdk::Env; + +fn setup() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env +} + +// ============================================================================ +// Accepted categories +// ============================================================================ + +#[test] +fn require_valid_invoice_category_accepts_services() { + require_valid_invoice_category(&InvoiceCategory::Services) + .expect("Services must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_goods() { + require_valid_invoice_category(&InvoiceCategory::Goods) + .expect("Goods must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_consulting() { + require_valid_invoice_category(&InvoiceCategory::Consulting) + .expect("Consulting must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_logistics() { + require_valid_invoice_category(&InvoiceCategory::Logistics) + .expect("Logistics must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_products() { + require_valid_invoice_category(&InvoiceCategory::Products) + .expect("Products must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_manufacturing() { + require_valid_invoice_category(&InvoiceCategory::Manufacturing) + .expect("Manufacturing must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_technology() { + require_valid_invoice_category(&InvoiceCategory::Technology) + .expect("Technology must be accepted"); +} + +#[test] +fn require_valid_invoice_category_accepts_healthcare() { + require_valid_invoice_category(&InvoiceCategory::Healthcare) + .expect("Healthcare must be accepted"); +} + +// ============================================================================ +// Reserved category — negative test +// ============================================================================ + +/// NEGATIVE TEST — `InvoiceCategory::Other` is treated as reserved and must be +/// rejected with `InvalidTag`. +#[test] +fn require_valid_invoice_category_rejects_other_as_reserved() { + let err = require_valid_invoice_category(&InvoiceCategory::Other) + .expect_err("Other must be rejected as reserved"); + + assert_eq!( + err, + QuickLendXError::InvalidTag, + "reserved category must return InvalidTag" + ); +} diff --git a/quicklendx-contracts/src/test_verify_bid_match.rs b/quicklendx-contracts/src/test_verify_bid_match.rs new file mode 100644 index 000000000..b321f20c7 --- /dev/null +++ b/quicklendx-contracts/src/test_verify_bid_match.rs @@ -0,0 +1,250 @@ +//! Tests for `verify_bid_match` — the precondition check for matching a bid +//! to an invoice before bid acceptance / matching logic. +//! +//! # Context +//! +//! `verify_bid_match` validates four conditions: +//! 1. The bid belongs to the invoice (`bid.invoice_id == invoice.id`). +//! 2. The bid is in `Placed` status. +//! 3. The bid has not expired. +//! 4. The bid amount is positive. +//! +//! # Negative tests +//! +//! Each condition has a corresponding negative test that produces a typed +//! error. Before this helper existed, the checks were inlined in +//! `load_accept_bid_context`; extracting them into a reusable helper makes +//! the precondition independently testable and auditable. +//! +//! # Threat mitigated +//! +//! Without this explicit precondition check, a caller could attempt to match +//! a bid that belongs to a different invoice, or one that has already expired +//! or been cancelled, leading to state corruption or inconsistent accounting. +//! Each guard returns a distinct typed error so the caller (and any audit +//! monitor) can distinguish between a wrong-invoice call (`Unauthorized`), +//! an expired bid (`InvalidStatus`), and a zero-amount bid (`InvalidAmount`). + +#![cfg(test)] + +use crate::bid::{verify_bid_match, Bid, BidStatus}; +use crate::errors::QuickLendXError; +use crate::types::{Dispute, DisputeResolution, DisputeStatus, Invoice, InvoiceCategory, InvoiceStatus}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, BytesN, Env, String, Vec, +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +fn make_invoice(env: &Env, id: BytesN<32>) -> Invoice { + Invoice { + id, + business: Address::generate(env), + amount: 1_000i128, + currency: Address::generate(env), + due_date: env.ledger().timestamp() + 86_400, + description: String::from_str(env, "test invoice"), + metadata_customer_name: None, + metadata_customer_address: None, + metadata_tax_id: None, + metadata_notes: None, + metadata_line_items: Vec::new(env), + category: InvoiceCategory::Services, + tags: Vec::new(env), + status: InvoiceStatus::Verified, + funded_amount: 0, + funded_at: None, + investor: None, + settled_at: None, + average_rating: None, + total_ratings: 0, + ratings: Vec::new(env), + dispute_status: DisputeStatus::None, + dispute: Dispute { + created_by: Address::generate(env), + created_at: 0, + reason: String::from_str(env, ""), + evidence: String::from_str(env, ""), + resolution: String::from_str(env, ""), + resolved_by: Address::generate(env), + resolved_at: 0, + resolution_outcome: DisputeResolution::None, + }, + total_paid: 0, + payment_history: Vec::new(env), + created_at: env.ledger().timestamp(), + origination_fee_bps: None, + } +} + +fn make_bid(env: &Env, invoice_id: &BytesN<32>, investor: &Address, id_suffix: u8) -> Bid { + let mut bid_id_bytes = [0u8; 32]; + bid_id_bytes[0] = 0xB1; + bid_id_bytes[30] = id_suffix; + bid_id_bytes[31] = id_suffix; + + Bid { + bid_id: BytesN::from_array(env, &bid_id_bytes), + invoice_id: invoice_id.clone(), + investor: investor.clone(), + bid_amount: 1_000i128, + expected_return: 1_200i128, + timestamp: env.ledger().timestamp(), + status: BidStatus::Placed, + expiration_timestamp: env.ledger().timestamp() + 604_800, + } +} + +// ============================================================================ +// Match (happy path) +// ============================================================================ + +/// All four conditions hold — the helper must return Ok(()). +#[test] +fn verify_bid_match_succeeds_for_valid_match() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let bid = make_bid(&env, &invoice_id, &investor, 1); + + verify_bid_match(&env, &bid, &invoice) + .expect("valid bid-invoice pair must pass"); +} + +// ============================================================================ +// Mismatch — wrong invoice +// ============================================================================ + +/// NEGATIVE TEST — bid references a different invoice. +#[test] +fn verify_bid_match_rejects_wrong_invoice() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let wrong_id: BytesN<32> = BytesN::from_array(&env, &[2; 32]); + let invoice = make_invoice(&env, invoice_id); + let investor = Address::generate(&env); + let bid = make_bid(&env, &wrong_id, &investor, 1); + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("bid referencing different invoice must fail"); + assert_eq!(err, QuickLendXError::Unauthorized); +} + +// ============================================================================ +// Mismatch — wrong status +// ============================================================================ + +/// NEGATIVE TEST — bid is Cancelled, not Placed. +#[test] +fn verify_bid_match_rejects_non_placed_status() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.status = BidStatus::Cancelled; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("cancelled bid must fail"); + assert_eq!(err, QuickLendXError::InvalidStatus); +} + +#[test] +fn verify_bid_match_rejects_accepted_status() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.status = BidStatus::Accepted; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("accepted bid must fail"); + assert_eq!(err, QuickLendXError::InvalidStatus); +} + +#[test] +fn verify_bid_match_rejects_expired_status() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.status = BidStatus::Expired; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("expired-status bid must fail"); + assert_eq!(err, QuickLendXError::InvalidStatus); +} + +#[test] +fn verify_bid_match_rejects_withdrawn_status() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.status = BidStatus::Withdrawn; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("withdrawn bid must fail"); + assert_eq!(err, QuickLendXError::InvalidStatus); +} + +// ============================================================================ +// Mismatch — expired bid +// ============================================================================ + +/// NEGATIVE TEST — bid's expiration_timestamp is in the past. +#[test] +fn verify_bid_match_rejects_expired_timestamp() { + let env = Env::default(); + env.ledger().set_timestamp(1_000_000); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.expiration_timestamp = 999_999; // expired (before current timestamp) + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("expired bid must fail"); + assert_eq!(err, QuickLendXError::InvalidStatus); +} + +// ============================================================================ +// Mismatch — zero amount +// ============================================================================ + +/// NEGATIVE TEST — bid amount is zero. +#[test] +fn verify_bid_match_rejects_zero_amount() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.bid_amount = 0; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("zero-amount bid must fail"); + assert_eq!(err, QuickLendXError::InvalidAmount); +} + +/// NEGATIVE TEST — bid amount is negative. +#[test] +fn verify_bid_match_rejects_negative_amount() { + let env = Env::default(); + let invoice_id: BytesN<32> = BytesN::from_array(&env, &[1; 32]); + let invoice = make_invoice(&env, invoice_id.clone()); + let investor = Address::generate(&env); + let mut bid = make_bid(&env, &invoice_id, &investor, 1); + bid.bid_amount = -1; + + let err = verify_bid_match(&env, &bid, &invoice) + .expect_err("negative-amount bid must fail"); + assert_eq!(err, QuickLendXError::InvalidAmount); +} diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index bc08a4a9c..339c5d92f 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -1129,6 +1129,43 @@ pub fn validate_invoice_category( } } +/// Reject unknown or reserved invoice categories. +/// +/// This is a tighter validation than [`validate_invoice_category`]: the +/// catch-all `InvoiceCategory::Other` is treated as *reserved* and rejected +/// because it is too generic for new invoices. Requiring a specific category +/// improves data quality, makes analytics more useful, and gives investors a +/// clearer picture of the invoice they are funding. +/// +/// As the protocol evolves, additional enum variants may be added that should +/// also be rejected (e.g. a placeholder for a future regulatory regime, or a +/// deprecated alias). This function is the single point where those +/// rejections are enforced, following the `require_*` naming convention used +/// throughout the contract (`require_business_verification`, +/// `require_regulatory_ok`, etc.). +/// +/// # Threat mitigated +/// +/// Without an explicit category-allowlist separate from the enum definition, a +/// business could default to `Other` for every invoice, defeating the +/// categorisation that investors and the protocol rely on for risk assessment. +/// By treating `Other` as reserved, we force callers to select a meaningful +/// category or add a new variant to the enum if none of the existing options +/// truly fits. +/// +/// # Errors +/// +/// Returns `InvalidTag` for the reserved `Other` category. +pub fn require_valid_invoice_category( + category: &crate::types::InvoiceCategory, +) -> Result<(), QuickLendXError> { + // "Other" is reserved and not accepted for new invoices. + if matches!(category, crate::types::InvoiceCategory::Other) { + return Err(QuickLendXError::InvalidTag); + } + validate_invoice_category(category) +} + /// Validate invoice tags. /// /// Each tag is normalized (trimmed, ASCII-lowercased) before validation so that From 2b33506d40470f6ad9b6df697df8a0ead9c4fe88 Mon Sep 17 00:00:00 2001 From: aabxtract Date: Mon, 27 Jul 2026 16:54:38 +0100 Subject: [PATCH 2/4] fix: repair 48 pre-existing compilation errors across 9 files - Remove duplicate MIN_TRANSFER const in payments.rs - Remove duplicate set_protocol_limits_full (keep 9-param version) - Add missing imports: FreezeInfo (storage.rs), require_business_active (lib.rs), ToXdr (idempotency.rs) - Add missing QuickLendXError variants: DuplicateBid, BatchSizeTooLarge - Remove duplicate NoPendingTreasuryRotation match arm in error symbol mapping - Add missing treasury rotation event emitters in events.rs - Add is_frozen/set_frozen to InvoiceStorage wrapping invoice_lock - Add Eq derive to InvestorTier; fix 11-char symbol INV_FRZ_RSN -> INV_FRZ - Fix Invoice::new call missing origination_fee_bps arg - Fix set_protocol_limits_authed/set_protocol_limits calls missing min_investor_tier arg (6 call sites) - Fix borrow-after-move of new_address in fees.rs initiate_treasury_rotation --- quicklendx-contracts/src/errors.rs | 14 ++++--- quicklendx-contracts/src/events.rs | 19 +++++++++ quicklendx-contracts/src/fees.rs | 2 +- quicklendx-contracts/src/idempotency.rs | 1 + quicklendx-contracts/src/init.rs | 1 + quicklendx-contracts/src/lib.rs | 51 +++--------------------- quicklendx-contracts/src/payments.rs | 7 ---- quicklendx-contracts/src/storage.rs | 13 ++++++ quicklendx-contracts/src/verification.rs | 2 +- 9 files changed, 49 insertions(+), 61 deletions(-) diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index 08b62ba70..0193d5e06 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -214,6 +214,10 @@ pub enum QuickLendXError { BatchSizeExceeded = 2208, /// Account is frozen and cannot create invoices. AccountIsFrozen = 2209, + /// Duplicate bid detected. + DuplicateBid = 2210, + /// Batch size exceeds maximum allowed. + BatchSizeTooLarge = 2211, } impl From for Symbol { @@ -230,11 +234,10 @@ impl From for Symbol { // Authorization QuickLendXError::Unauthorized => symbol_short!("UNAUTH"), QuickLendXError::NotBusinessOwner => symbol_short!("NOT_OWN"), - QuickLendXError::InvalidFreezeReason => symbol_short!("INV_FRZ_RSN"), + QuickLendXError::InvalidFreezeReason => symbol_short!("INV_FRZ"), QuickLendXError::NotInvestor => symbol_short!("NOT_INV"), QuickLendXError::InvoiceFrozen => symbol_short!("INV_FRZ"), QuickLendXError::SelfTransfer => symbol_short!("SLF_XFR"), - QuickLendXError::DuplicateBid => symbol_short!("DUP_BID"), QuickLendXError::NotAdmin => symbol_short!("NOT_ADM"), QuickLendXError::SelfCallNotAllowed => symbol_short!("SELF_NA"), // Input validation @@ -303,24 +306,23 @@ impl From for Symbol { QuickLendXError::InsufficientKYCTier => symbol_short!("TIER_LOW"), QuickLendXError::ContractPaused => symbol_short!("PAUSED"), QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER"), - QuickLendXError::NoPendingTreasuryRotation => symbol_short!("NO_PND_TR"), QuickLendXError::InvalidLedgerSequence => symbol_short!("INV_L_SEQ"), QuickLendXError::EmergencyWithdrawTimelockNotElapsed => symbol_short!("EMG_TLK"), QuickLendXError::EmergencyWithdrawExpired => symbol_short!("EMG_EXP"), QuickLendXError::EmergencyWithdrawCancelled => symbol_short!("EMG_CNL"), QuickLendXError::EmergencyWithdrawAlreadyExists => symbol_short!("EMG_EX"), QuickLendXError::EmergencyWithdrawInsufficientBalance => symbol_short!("EMG_BAL"), + QuickLendXError::EmergencyWithdrawNotFound => symbol_short!("EMG_NF"), QuickLendXError::TokenTransferFailed => symbol_short!("TKN_FAIL"), QuickLendXError::MaintenanceModeActive => symbol_short!("MAINT"), QuickLendXError::ArithmeticOverflow => symbol_short!("ARITH_OF"), QuickLendXError::DuplicateDefaultTransition => symbol_short!("DEF_DUP"), - QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER"), - QuickLendXError::NoPendingTreasuryRotation => symbol_short!("ROT_NOPND"), - QuickLendXError::InvalidLedgerSequence => symbol_short!("INV_SEQ"), QuickLendXError::InsuranceNotActive => symbol_short!("INS_NA"), QuickLendXError::UnstableCursor => symbol_short!("STBL_CUR"), QuickLendXError::BatchSizeExceeded => symbol_short!("BAT_MAX"), QuickLendXError::AccountIsFrozen => symbol_short!("ACCT_FRZ"), + QuickLendXError::DuplicateBid => symbol_short!("DUP_BID"), + QuickLendXError::BatchSizeTooLarge => symbol_short!("BAT_LRG"), } } } diff --git a/quicklendx-contracts/src/events.rs b/quicklendx-contracts/src/events.rs index 8f74e58ba..f7bfe6415 100644 --- a/quicklendx-contracts/src/events.rs +++ b/quicklendx-contracts/src/events.rs @@ -1547,6 +1547,25 @@ pub fn emit_admin_initialized(env: &Env, admin: &Address) { .publish((symbol_short!("adm_init"),), (admin.clone(),)); } +pub fn emit_treasury_rotation_initiated( + env: &Env, + new_address: &Address, + admin: &Address, + confirmation_deadline: u64, +) { + env.events().publish( + (symbol_short!("tr_rot_i"), admin.clone()), + (new_address.clone(), confirmation_deadline), + ); +} + +pub fn emit_treasury_rotation_confirmed(env: &Env, old: &Address, new_address: &Address) { + env.events().publish( + (symbol_short!("tr_rot_cf"), old.clone()), + (new_address.clone(),), + ); +} + pub fn treasury_rotation_cancelled(env: &Env, admin: &Address) { env.events().publish( (symbol_short!("tr_rot_c"), admin.clone()), diff --git a/quicklendx-contracts/src/fees.rs b/quicklendx-contracts/src/fees.rs index 86e16aa5b..920afe998 100644 --- a/quicklendx-contracts/src/fees.rs +++ b/quicklendx-contracts/src/fees.rs @@ -1167,7 +1167,7 @@ impl FeeManager { let now = env.ledger().timestamp(); let request = RecipientRotationRequest { - new_address, + new_address: new_address.clone(), initiated_by: admin.clone(), initiated_at: now, confirmation_deadline: now.saturating_add(ROTATION_TTL_SECONDS), diff --git a/quicklendx-contracts/src/idempotency.rs b/quicklendx-contracts/src/idempotency.rs index 38743cf1c..a3346030f 100644 --- a/quicklendx-contracts/src/idempotency.rs +++ b/quicklendx-contracts/src/idempotency.rs @@ -1,4 +1,5 @@ use crate::storage::{bump_persistent, extend_persistent_ttl}; +use soroban_sdk::xdr::ToXdr; use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env, Symbol}; /// Storage key for the idempotency map. diff --git a/quicklendx-contracts/src/init.rs b/quicklendx-contracts/src/init.rs index 4c2181ae7..4908c0b6a 100644 --- a/quicklendx-contracts/src/init.rs +++ b/quicklendx-contracts/src/init.rs @@ -395,6 +395,7 @@ impl ProtocolInitializer { params.max_due_date_days, params.grace_period_seconds, crate::protocol_limits::DEFAULT_MAX_INVOICES_PER_BUSINESS, + crate::verification::InvestorTier::Basic, )?; // Initialize currency whitelist with provided currencies diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index ee93ad3f6..67ec1c984 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -52,6 +52,7 @@ mod test_settlement_history_reconstruction; #[cfg(test)] mod test_regulatory_gate; use crate::idempotency::{idempotency_exists, idempotency_key, store_idempotency}; +use crate::verification::require_business_active; use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, String, Vec}; pub mod address_summary; @@ -1324,6 +1325,7 @@ impl QuickLendXContract { input.description.clone(), input.category, input.tags.clone(), + None, )?; let id = invoice.id.clone(); InvoiceStorage::store_invoice(&env, &invoice); @@ -2489,6 +2491,7 @@ impl QuickLendXContract { max_due_date_days, grace_period_seconds, existing.max_invoices_per_business, + existing.min_investor_tier, ) } @@ -2516,6 +2519,7 @@ impl QuickLendXContract { max_due_date_days, grace_period_seconds, existing.max_invoices_per_business, + existing.min_investor_tier, ) } @@ -2543,6 +2547,7 @@ impl QuickLendXContract { max_due_date_days, grace_period_seconds, existing.max_invoices_per_business, + existing.min_investor_tier, ) } @@ -2570,52 +2575,6 @@ impl QuickLendXContract { max_due_date_days, grace_period_seconds, max_invoices_per_business, - ) - } - - /// Update **all** protocol limits in a single call (admin only). - /// - /// This is the preferred entrypoint when operators need to configure - /// `min_bid_amount` or `min_bid_bps` alongside the other limits. The - /// narrower helpers (`set_protocol_limits`, `update_protocol_limits`, - /// `update_limits_max_invoices`) preserve the current bid-limit values for - /// backwards compatibility. - /// - /// # Parameters - /// - `min_invoice_amount` – minimum invoice face value (inclusive). - /// - `min_bid_amount` – minimum absolute bid amount (inclusive). - /// Pass [`DEFAULT_MIN_BID_AMOUNT`] (10) to - /// keep the compile-time default. - /// - `min_bid_bps` – minimum bid rate in basis points (inclusive). - /// Pass [`DEFAULT_MIN_BID_BPS`] (100) to keep - /// the compile-time default. - /// - `max_due_date_days` – maximum invoice horizon in days (1..=730). - /// - `grace_period_seconds` – grace period after due date (0..=2_592_000). - /// - `max_invoices_per_business`– per-business active-invoice cap; 0 = unlimited. - /// - /// # Errors - /// Delegates to `ProtocolLimitsContract::set_protocol_limits` for all - /// parameter validation; see that function's docs for error codes. - pub fn set_protocol_limits_full( - env: Env, - admin: Address, - min_invoice_amount: i128, - min_bid_amount: i128, - min_bid_bps: u32, - max_due_date_days: u64, - grace_period_seconds: u64, - max_invoices_per_business: u32, - ) -> Result<(), QuickLendXError> { - pause::PauseControl::require_not_paused(&env)?; - protocol_limits::ProtocolLimitsContract::set_protocol_limits( - env, - admin, - min_invoice_amount, - min_bid_amount, - min_bid_bps, - max_due_date_days, - grace_period_seconds, - max_invoices_per_business, existing.min_investor_tier, ) } diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index cc91ce85d..9edc01005 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -36,13 +36,6 @@ fn validate_token_address( } } -/// Minimum transfer amount to prevent dust transfers. -/// Matches the test-mode MIN_TRANSFER from protocol_limits.rs. -#[cfg(not(test))] -const MIN_TRANSFER: i128 = 1_000_000; // 1 token (6 decimals) -#[cfg(test)] -const MIN_TRANSFER: i128 = 10; - #[contracttype] #[derive(Clone, Eq, PartialEq)] #[cfg_attr(test, derive(Debug))] diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index d6a460c01..4afe854b0 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -3,6 +3,7 @@ //! This module defines storage keys, indexing strategies, and storage operations //! for efficient data retrieval and management. +use crate::types::FreezeInfo; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; use crate::protocol_limits; @@ -315,6 +316,18 @@ impl InvoiceStorage { env.storage().persistent().remove(&key); } + pub fn is_frozen(env: &Env, invoice_id: &BytesN<32>) -> bool { + Self::get_invoice_lock(env, invoice_id) != InvoiceLock::None + } + + pub fn set_frozen(env: &Env, invoice_id: &BytesN<32>, frozen: bool) { + if frozen { + Self::set_invoice_lock(env, invoice_id, InvoiceLock::Frozen); + } else { + Self::set_invoice_lock(env, invoice_id, InvoiceLock::None); + } + } + pub fn get_by_business(env: &Env, business: &Address) -> Vec> { let key = Indexes::invoices_by_business(business); env.storage() diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index 339c5d92f..571ada8b6 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -36,7 +36,7 @@ pub struct BusinessVerification { } #[contracttype] -#[derive(Clone, PartialEq, Debug, PartialOrd, Ord)] +#[derive(Clone, PartialEq, Eq, Debug, PartialOrd, Ord)] pub enum InvestorTier { Basic, Silver, From 9df0186e1ebde3cf02253b2d8d11c29bda966814 Mon Sep 17 00:00:00 2001 From: aabxtract Date: Mon, 27 Jul 2026 18:14:33 +0100 Subject: [PATCH 3/4] fix: resolve all compilation errors after merge with main Fix 98 errors from merging main into chore/defence-in-depth-four-fixes: - Remove duplicate functions conflicting with main (events, profits, settlement) - Remove duplicate type definitions (InvestorFreezeReason, imports) - Add missing error variants (PendingGovernanceProposal, UnstableCursor, SettlementCurrencyNotAllowed, UpgradePending, PerInvestorPositionCapExceeded, BidBelowTierMinimum, InvalidTransactionHash, BatchSizeExceeded) - Add missing PAUSE_REASON_KEY, DataKey::PerInvestorPositionCap - Fix Invoice::new argument count (add early_payment_discount_bps) - Fix set_protocol_limits call argument counts - Fix idempotency.rs imports, verification.rs hex validator - Fix contract function name length (reset_bid_grace_to_default) - Fix StorageKeys::set_frozen/is_frozen (bool return values) - Rename test call to match new signature --- quicklendx-contracts/errors_all.txt | Bin 0 -> 110820 bytes quicklendx-contracts/src/currency.rs | 8 -- quicklendx-contracts/src/errors.rs | 28 ++++++ quicklendx-contracts/src/events.rs | 24 ----- quicklendx-contracts/src/idempotency.rs | 1 + quicklendx-contracts/src/lib.rs | 53 +--------- quicklendx-contracts/src/pause.rs | 1 + quicklendx-contracts/src/profits.rs | 9 -- quicklendx-contracts/src/settlement.rs | 91 ------------------ quicklendx-contracts/src/storage.rs | 22 +---- .../src/test_bid_expiry_grace.rs | 6 +- quicklendx-contracts/src/types.rs | 31 ------ quicklendx-contracts/src/verification.rs | 17 +--- 13 files changed, 46 insertions(+), 245 deletions(-) create mode 100644 quicklendx-contracts/errors_all.txt diff --git a/quicklendx-contracts/errors_all.txt b/quicklendx-contracts/errors_all.txt new file mode 100644 index 0000000000000000000000000000000000000000..9e9a3b77e6787b6966a99e3ba20957d9795b1323 GIT binary patch literal 110820 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 Gh5rwILN}}c literal 0 HcmV?d00001 diff --git a/quicklendx-contracts/src/currency.rs b/quicklendx-contracts/src/currency.rs index c9b748207..79e7d484d 100644 --- a/quicklendx-contracts/src/currency.rs +++ b/quicklendx-contracts/src/currency.rs @@ -245,14 +245,6 @@ impl CurrencyWhitelist { )) } - /// Returns the canonical zero address used for validation. - fn zero_address(env: &Env) -> Address { - Address::from_string(&String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )) - } - /// Assert that `currency` is permitted, respecting empty-list backward compatibility. /// /// # Parameters diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index 09c4742fb..c0df30b06 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -240,6 +240,25 @@ pub enum QuickLendXError { /// settlement ledger strictly auditable. /// BREAKING: Do not renumber this variant. public ABI consumption. DuplicateNonce = 2209, + /// BREAKING: Do not renumber this variant. public ABI consumption. + InsufficientKYCTier = 2210, + /// BREAKING: Do not renumber this variant. public ABI consumption. + PendingGovernanceProposal = 2211, + /// BREAKING: Do not renumber this variant. public ABI consumption. + UnstableCursor = 2212, + /// BREAKING: Do not renumber this variant. public ABI consumption. + SettlementCurrencyNotAllowed = 2213, + /// BREAKING: Do not renumber this variant. public ABI consumption. + UpgradePending = 2214, + /// BREAKING: Do not renumber this variant. public ABI consumption. + PerInvestorPositionCapExceeded = 2215, + /// The investor's KYC tier is too low for the requested operation. + /// BREAKING: Do not renumber this variant. public ABI consumption. + BidBelowTierMinimum = 2216, + /// BREAKING: Do not renumber this variant. public ABI consumption. + InvalidTransactionHash = 2217, + /// BREAKING: Do not renumber this variant. public ABI consumption. + BatchSizeExceeded = 2218, } impl From for Symbol { @@ -282,6 +301,7 @@ impl From for Symbol { QuickLendXError::MaxActiveBidsPerInvestorExceeded => symbol_short!("MAX_ACT"), QuickLendXError::MaxInvoicesPerBusinessExceeded => symbol_short!("MAX_INV"), QuickLendXError::InvalidBidTtl => symbol_short!("INV_TTL"), + QuickLendXError::InsuranceClaimWindowClosed => symbol_short!("INS_WIN"), QuickLendXError::InsufficientKYCTier => symbol_short!("TIER_LOW"), // Rating QuickLendXError::InvalidRating => symbol_short!("INV_RT"), @@ -348,6 +368,14 @@ impl From for Symbol { QuickLendXError::ActiveDisputeExists => symbol_short!("DSP_ACT"), QuickLendXError::StaleInvestmentSnapshot => symbol_short!("STL_INV"), QuickLendXError::DuplicateNonce => symbol_short!("DUP_NONCE"), + QuickLendXError::PendingGovernanceProposal => symbol_short!("GOV_PROP"), + QuickLendXError::UnstableCursor => symbol_short!("UNSTABLE"), + QuickLendXError::SettlementCurrencyNotAllowed => symbol_short!("SETL_CR"), + QuickLendXError::UpgradePending => symbol_short!("UPG_PEND"), + QuickLendXError::PerInvestorPositionCapExceeded => symbol_short!("POS_CAP"), + QuickLendXError::BidBelowTierMinimum => symbol_short!("TIER_BID"), + QuickLendXError::InvalidTransactionHash => symbol_short!("TX_HASH"), + QuickLendXError::BatchSizeExceeded => symbol_short!("BATCH_SZ"), } } } diff --git a/quicklendx-contracts/src/events.rs b/quicklendx-contracts/src/events.rs index d2688f83c..d64a3ee9e 100644 --- a/quicklendx-contracts/src/events.rs +++ b/quicklendx-contracts/src/events.rs @@ -1654,30 +1654,6 @@ pub fn treasury_rotation_cancelled(env: &Env, admin: &Address) { ); } -pub fn emit_treasury_rotation_initiated( - env: &Env, - new_address: &Address, - initiated_by: &Address, - confirmation_deadline: u64, -) { - TreasuryRotationInitiated { - new_address: new_address.clone(), - initiated_by: initiated_by.clone(), - confirmation_deadline, - timestamp: env.ledger().timestamp(), - } - .publish(env); -} - -pub fn emit_treasury_rotation_confirmed(env: &Env, old_address: &Address, new_address: &Address) { - TreasuryRotationConfirmed { - old_address: old_address.clone(), - new_address: new_address.clone(), - timestamp: env.ledger().timestamp(), - } - .publish(env); -} - // ── Upgrade events ────────────────────────────────────────────────────────── /// Emitted when an admin schedules a WASM contract upgrade. diff --git a/quicklendx-contracts/src/idempotency.rs b/quicklendx-contracts/src/idempotency.rs index 30004726e..81b7ef386 100644 --- a/quicklendx-contracts/src/idempotency.rs +++ b/quicklendx-contracts/src/idempotency.rs @@ -1,4 +1,5 @@ use crate::storage::{bump_persistent, extend_persistent_ttl}; +use soroban_sdk::{symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, Symbol}; /// Storage key for the idempotency map. pub const IDEMPOTENCY_MAP_KEY: Symbol = symbol_short!("idem_map"); diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index b3890270a..d806ab83f 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -886,8 +886,8 @@ impl QuickLendXContract { } /// Reset the bid expiry grace period to the compile-time default (0) - pub fn reset_bid_expiry_grace_to_default(env: Env) -> Result { - let admin = AdminStorage::get_admin(&env).ok_or(QuickLendXError::NotAdmin)?; + pub fn reset_bid_grace_to_default(env: Env, admin: Address) -> Result { + admin.require_auth(); bid::BidStorage::reset_bid_expiry_grace_to_default(&env, &admin) } @@ -1572,6 +1572,7 @@ impl QuickLendXContract { input.tags.clone(), None, // origination_fee_bps input.late_payment_penalty_bps, + None, // early_payment_discount_bps )?; let id = invoice.id.clone(); InvoiceStorage::store_invoice(&env, &invoice); @@ -2934,7 +2935,7 @@ impl QuickLendXContract { investor: Address, ) -> Result { pause::PauseControl::require_not_paused(&env)?; - investor_rating_recompute(&env, &admin, &investor) + verification::investor_rating_recompute(&env, &admin, &investor) } /// Verify business (admin only) @@ -3091,52 +3092,6 @@ impl QuickLendXContract { max_due_date_days, grace_period_seconds, max_invoices_per_business, - ) - } - - /// Update **all** protocol limits in a single call (admin only). - /// - /// This is the preferred entrypoint when operators need to configure - /// `min_bid_amount` or `min_bid_bps` alongside the other limits. The - /// narrower helpers (`set_protocol_limits`, `update_protocol_limits`, - /// `update_limits_max_invoices`) preserve the current bid-limit values for - /// backwards compatibility. - /// - /// # Parameters - /// - `min_invoice_amount` – minimum invoice face value (inclusive). - /// - `min_bid_amount` – minimum absolute bid amount (inclusive). - /// Pass [`DEFAULT_MIN_BID_AMOUNT`] (10) to - /// keep the compile-time default. - /// - `min_bid_bps` – minimum bid rate in basis points (inclusive). - /// Pass [`DEFAULT_MIN_BID_BPS`] (100) to keep - /// the compile-time default. - /// - `max_due_date_days` – maximum invoice horizon in days (1..=730). - /// - `grace_period_seconds` – grace period after due date (0..=2_592_000). - /// - `max_invoices_per_business`– per-business active-invoice cap; 0 = unlimited. - /// - /// # Errors - /// Delegates to `ProtocolLimitsContract::set_protocol_limits` for all - /// parameter validation; see that function's docs for error codes. - pub fn set_protocol_limits_full( - env: Env, - admin: Address, - min_invoice_amount: i128, - min_bid_amount: i128, - min_bid_bps: u32, - max_due_date_days: u64, - grace_period_seconds: u64, - max_invoices_per_business: u32, - ) -> Result<(), QuickLendXError> { - pause::PauseControl::require_not_paused(&env)?; - protocol_limits::ProtocolLimitsContract::set_protocol_limits( - env, - admin, - min_invoice_amount, - min_bid_amount, - min_bid_bps, - max_due_date_days, - grace_period_seconds, - max_invoices_per_business, existing.min_investor_tier, ) } diff --git a/quicklendx-contracts/src/pause.rs b/quicklendx-contracts/src/pause.rs index 516d86728..8d5193b3b 100644 --- a/quicklendx-contracts/src/pause.rs +++ b/quicklendx-contracts/src/pause.rs @@ -4,6 +4,7 @@ use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, String, Symbol, const PAUSED_KEY: Symbol = symbol_short!("paused"); const PAUSED_AT_KEY: Symbol = symbol_short!("paused_at"); +pub(crate) const PAUSE_REASON_KEY: Symbol = symbol_short!("pause_rsn"); pub(crate) const MAX_PAUSE_DURATION: u64 = 7 * 24 * 3600; /// Set of contract entrypoint names that are guarded by the protocol pause. diff --git a/quicklendx-contracts/src/profits.rs b/quicklendx-contracts/src/profits.rs index 6055cc72e..df5407783 100644 --- a/quicklendx-contracts/src/profits.rs +++ b/quicklendx-contracts/src/profits.rs @@ -635,15 +635,6 @@ pub fn compute_twa_reference(deltas: &[LedgerDelta]) -> i128 { } } -/// Compute the expected return on a principal amount. -/// -/// # Returns -/// Total expected return (principal + yield) -pub fn compute_expected_return(amount: i128, rate_bps: u32, duration_days: u32) -> i128 { - let yield_amount = compute_yield(amount, rate_bps, duration_days); - amount.max(0).saturating_add(yield_amount) -} - // ============================================================================ // Tests // ============================================================================ diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index 24511e9f6..fde8fc558 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -904,97 +904,6 @@ fn emit_payment_recorded( ); } -fn require_no_active_dispute(invoice: &Invoice) -> Result<(), QuickLendXError> { - if invoice.dispute_status == DisputeStatus::Disputed - || invoice.dispute_status == DisputeStatus::UnderReview - { - return Err(QuickLendXError::DisputeActive); - } - Ok(()) -} - -fn compute_remaining_due(invoice: &Invoice) -> Result { - if invoice.amount <= 0 { - return Err(QuickLendXError::InvoiceAmountInvalid); - } - - if invoice.total_paid < 0 { - return Err(QuickLendXError::InvalidAmount); - } - - if invoice.total_paid >= invoice.amount { - return Ok(0); - } - - invoice - .amount - .checked_sub(invoice.total_paid) - .ok_or(QuickLendXError::InvalidAmount) -} - -fn update_inline_payment_history( - invoice: &mut Invoice, - payer: Address, - amount: i128, - timestamp: u64, - nonce: String, -) { - if invoice.payment_history.len() >= MAX_INLINE_PAYMENT_HISTORY { - invoice.payment_history.remove(0u32); - } - - invoice.payment_history.push_back(InvoicePaymentRecord { - payer, - amount, - timestamp, - transaction_id: nonce, - }); -} - -fn get_payment_count_internal(env: &Env, invoice_id: &BytesN<32>) -> u32 { - env.storage() - .persistent() - .get(&SettlementDataKey::PaymentCount(invoice_id.clone())) - .unwrap_or(0) -} - -fn get_last_applied_amount(env: &Env, invoice_id: &BytesN<32>) -> Result { - let count = get_payment_count_internal(env, invoice_id); - if count == 0 { - return Err(QuickLendXError::StorageKeyNotFound); - } - - let last_index = count.saturating_sub(1); - let record = get_payment_record(env, invoice_id, last_index)?; - Ok(record.amount) -} - -fn make_settlement_nonce(env: &Env) -> String { - // Full settlement can only succeed once per invoice (status becomes Paid), - // so a static nonce is sufficient for this internal path. - String::from_str(env, "settlement") -} - -fn emit_payment_recorded( - env: &Env, - invoice_id: &BytesN<32>, - payer: &Address, - applied_amount: i128, - total_paid: i128, - status: &InvoiceStatus, -) { - env.events().publish( - (symbol_short!("pay_rec"),), - ( - invoice_id.clone(), - payer.clone(), - applied_amount, - total_paid, - *status, - ), - ); -} - fn emit_invoice_settled_final( env: &Env, invoice_id: &BytesN<32>, diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index add016261..dea6a5f5b 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -3,13 +3,12 @@ //! This module defines storage keys, indexing strategies, and storage operations //! for efficient data retrieval and management. -use crate::types::FreezeInfo; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; use crate::protocol_limits; use crate::types::{ BidStatus, BusinessFreezeReason, FreezeInfo, InvestmentStatus, Invoice, InvoiceCategory, - InvoiceLock, InvoiceStatus, PlatformFeeConfig, PruneReport, RebuildReport, + InvoiceLock, InvoiceStatus, InvestorFreezeInfo, PlatformFeeConfig, PruneReport, RebuildReport, }; /// Default TTL threshold for persistent storage (adjust the value as needed) @@ -61,6 +60,7 @@ pub enum DataKey { FreezeInfo(BytesN<32>), EscrowExtension(BytesN<32>), InvestorFreezeInfo(Address), + PerInvestorPositionCap(BytesN<32>), } impl StorageKeys { @@ -304,7 +304,7 @@ impl InvoiceStorage { let key = DataKey::FrozenInvoice(invoice_id.clone()); if let Some(lock) = env.storage().persistent().get::<_, InvoiceLock>(&key) { extend_persistent_ttl(env, &key); - lock + lock.is_locked() } else if env .storage() .persistent() @@ -313,9 +313,9 @@ impl InvoiceStorage { { // Backward-compatible: a typed freeze reason also means frozen. extend_persistent_ttl(env, &key); - InvoiceLock::Frozen + true } else { - InvoiceLock::None + false } } @@ -339,18 +339,6 @@ impl InvoiceStorage { env.storage().persistent().remove(&key); } - pub fn is_frozen(env: &Env, invoice_id: &BytesN<32>) -> bool { - Self::get_invoice_lock(env, invoice_id).is_locked() - } - - pub fn set_frozen(env: &Env, invoice_id: &BytesN<32>, frozen: bool) { - if frozen { - Self::set_invoice_lock(env, invoice_id, InvoiceLock::Frozen); - } else { - Self::set_invoice_lock(env, invoice_id, InvoiceLock::None); - } - } - pub fn set_investor_freeze_info(env: &Env, investor: &Address, info: &InvestorFreezeInfo) { let key = DataKey::InvestorFreezeInfo(investor.clone()); env.storage().persistent().set(&key, info); diff --git a/quicklendx-contracts/src/test_bid_expiry_grace.rs b/quicklendx-contracts/src/test_bid_expiry_grace.rs index e3df11d8f..c60fe36f5 100644 --- a/quicklendx-contracts/src/test_bid_expiry_grace.rs +++ b/quicklendx-contracts/src/test_bid_expiry_grace.rs @@ -13,7 +13,7 @@ //! - `set_bid_expiry_grace_seconds` enforces `[0, MAX_BID_EXPIRY_GRACE_SECONDS]` //! and is admin-only. //! - `get_bid_expiry_grace_config` reports bounds, default, and `is_custom`. -//! - `reset_bid_expiry_grace_to_default` restores the default and clears +//! - `reset_bid_grace_to_default` restores the default and clears //! `is_custom`. #![cfg(test)] @@ -240,14 +240,14 @@ fn set_bid_expiry_grace_seconds_accepts_boundary_values() { #[test] fn get_bid_expiry_grace_config_reports_custom_after_set_and_clears_after_reset() { - let (_env, client, _admin) = setup(); + let (_env, client, admin) = setup(); client.set_bid_expiry_grace_seconds(&5_000u64); let config = client.get_bid_expiry_grace_config(); assert_eq!(config.current_seconds, 5_000); assert!(config.is_custom); - client.reset_bid_expiry_grace_to_default(); + client.reset_bid_grace_to_default(&admin); let config_after_reset = client.get_bid_expiry_grace_config(); assert_eq!(config_after_reset.current_seconds, DEFAULT_BID_EXPIRY_GRACE_SECONDS); assert!(!config_after_reset.is_custom); diff --git a/quicklendx-contracts/src/types.rs b/quicklendx-contracts/src/types.rs index bade60a72..9bddc3dc9 100644 --- a/quicklendx-contracts/src/types.rs +++ b/quicklendx-contracts/src/types.rs @@ -480,35 +480,4 @@ pub struct PaginatedCurrencies { pub has_more: bool, } -/// Typed reason for freezing an investor account. -/// -/// Symmetric with [`BusinessFreezeReason`] — every freeze must carry a -/// typed reason so that audit logs and unfreeze workflows can operate on -/// structured data rather than opaque booleans. -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InvestorFreezeReason { - /// Investor engaged in suspicious or fraudulent bid/investment activity. - FraudSuspected, - /// Investor failed or failed ongoing KYC/AML compliance checks. - ComplianceViolation, - /// Active dispute involving the investor's positions. - Dispute, - /// Investor requested a voluntary freeze. - Voluntary, - /// Admin-initiated freeze for an unspecified or catch-all reason. - AdminAction, -} -impl InvestorFreezeReason { - /// Returns a short human-readable label for event logging. - pub fn label(&self) -> &'static str { - match self { - Self::FraudSuspected => "fraud_suspected", - Self::ComplianceViolation => "compliance_violation", - Self::Dispute => "dispute", - Self::Voluntary => "voluntary", - Self::AdminAction => "admin_action", - } - } -} diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index 9f8a29ac9..310d49473 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -7,7 +7,6 @@ use crate::protocol_limits::{ MAX_INVOICE_AMOUNT, MAX_KYC_DATA_LENGTH, MAX_NAME_LENGTH, MAX_NOTES_LENGTH, MAX_REJECTION_REASON_LENGTH, MAX_TAG_LENGTH, MAX_TAX_ID_LENGTH, }; -use crate::storage::InvoiceStorage; use crate::types::BidStatus; use crate::types::{DisputeStatus, Invoice, InvoiceMetadata, InvoiceStatus}; use soroban_sdk::{contracttype, symbol_short, vec, Address, Bytes, Env, String, Vec}; @@ -2091,23 +2090,15 @@ pub fn validate_transaction_hash(env: &soroban_sdk::Env, hash: &soroban_sdk::Str return Err(crate::errors::QuickLendXError::InvalidTransactionHash); } - let mut is_hex = true; - let bytes = soroban_sdk::Bytes::from_string(env, hash); - for i in 0..64 { + let bytes = hash.to_bytes(); + for i in 0..bytes.len() { let b = bytes.get(i).unwrap(); match b { b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' => {} - _ => { - is_hex = false; - break; - } + _ => return Err(crate::errors::QuickLendXError::InvalidTransactionHash), } } - - if !is_hex { - return Err(crate::errors::QuickLendXError::InvalidTransactionHash); - } - + Ok(()) } From fbe3622dd209937ae6a5506bb54eabeab2b24722 Mon Sep 17 00:00:00 2001 From: aabxtract Date: Mon, 27 Jul 2026 18:14:40 +0100 Subject: [PATCH 4/4] chore: remove temporary error output file --- quicklendx-contracts/errors_all.txt | Bin 110820 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 quicklendx-contracts/errors_all.txt diff --git a/quicklendx-contracts/errors_all.txt b/quicklendx-contracts/errors_all.txt deleted file mode 100644 index 9e9a3b77e6787b6966a99e3ba20957d9795b1323..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110820 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 Gh5rwILN}}c