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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions docs/QLX_INVOICE_TAGS.md
Original file line number Diff line number Diff line change
@@ -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<String>,
// ...
}
```

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
48 changes: 48 additions & 0 deletions quicklendx-contracts/src/bid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,3 +1309,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(())
}
8 changes: 0 additions & 8 deletions quicklendx-contracts/src/currency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions quicklendx-contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<QuickLendXError> for Symbol {
Expand Down Expand Up @@ -282,6 +301,7 @@ impl From<QuickLendXError> 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"),
Expand Down Expand Up @@ -348,6 +368,14 @@ impl From<QuickLendXError> 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"),
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions quicklendx-contracts/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
24 changes: 0 additions & 24 deletions quicklendx-contracts/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions quicklendx-contracts/src/idempotency.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
Loading
Loading