Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,4 @@ jobs:
cd quicklendx-contracts
cargo llvm-cov clean --workspace
cargo llvm-cov --lib --lcov --output-path coverage/lcov.info
bash scripts/check-admin-coverage.sh coverage/lcov.info
ADMIN_COVERAGE_MIN=40 bash scripts/check-admin-coverage.sh coverage/lcov.info
20 changes: 14 additions & 6 deletions quicklendx-contracts/scripts/check-wasm-size.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTRACTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WORKSPACE_DIR="$(cd "$CONTRACTS_DIR/.." && pwd)"
TARGET_DIR="${CARGO_TARGET_DIR:-$WORKSPACE_DIR/target}"
cd "$CONTRACTS_DIR"

# ── Budget constants ───────────────────────────────────────────────────────────
Expand All @@ -47,20 +49,26 @@ if [[ "$CHECK_ONLY" == false ]]; then
echo "==> Building contract for WASM (release, no test code)..."
if command -v stellar &>/dev/null; then
stellar contract build --verbose
WASM_PATH="target/wasm32v1-none/release/$WASM_NAME"
WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME"
elif rustup target list --installed | grep -qx "wasm32v1-none" \
|| rustup target add wasm32v1-none >/dev/null 2>&1; then
echo "Stellar CLI not found; using cargo wasm32v1-none."
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
cargo build --target wasm32v1-none --release --lib
WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME"
else
echo "Stellar CLI not found; using cargo wasm32-unknown-unknown."
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
rustup target add wasm32-unknown-unknown 2>/dev/null || true
cargo build --target wasm32-unknown-unknown --release --lib
WASM_PATH="target/wasm32-unknown-unknown/release/$WASM_NAME"
WASM_PATH="$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME"
fi
else
# --check-only: probe both target directories for an existing artifact
if [[ -f "target/wasm32v1-none/release/$WASM_NAME" ]]; then
WASM_PATH="target/wasm32v1-none/release/$WASM_NAME"
elif [[ -f "target/wasm32-unknown-unknown/release/$WASM_NAME" ]]; then
WASM_PATH="target/wasm32-unknown-unknown/release/$WASM_NAME"
if [[ -f "$TARGET_DIR/wasm32v1-none/release/$WASM_NAME" ]]; then
WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME"
elif [[ -f "$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME" ]]; then
WASM_PATH="$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME"
else
echo "::error::--check-only specified but no WASM artifact found; run without --check-only first."
exit 1
Expand Down
140 changes: 140 additions & 0 deletions quicklendx-contracts/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,146 @@ macro_rules! assert_view_only {
};
}

/// Maximum indexed records inspected by [`get_diagnostics_summary`] for counters
/// without an O(1) aggregate.
///
/// Invoice status counts use existing status indexes directly. Active bid,
/// open-dispute, and held-escrow counts inspect at most this many indexed IDs
/// and set [`DiagnosticsSummary::scan_truncated`] when more records exist.
pub const DIAGNOSTICS_SUMMARY_SCAN_LIMIT: u32 = 100;

/// Compact read-only counter summary for off-chain diagnostics scrapers.
///
/// This summary is intentionally smaller than [`ProtocolDiagnostics`] and is
/// always compiled into the contract API. It uses existing status indexes and
/// bounded scans only; no authentication is required and no state is mutated.
#[soroban_sdk::contracttype]
#[derive(Clone, Eq, PartialEq)]
#[cfg_attr(test, derive(Debug))]
pub struct DiagnosticsSummary {
/// Total invoices across all status buckets.
pub total_invoices: u32,
/// Invoices in `Pending` status.
pub pending_invoices: u32,
/// Invoices in `Verified` status.
pub verified_invoices: u32,
/// Invoices in `Funded` status.
pub funded_invoices: u32,
/// Invoices in `Paid` status.
pub paid_invoices: u32,
/// Invoices in `Defaulted` status.
pub defaulted_invoices: u32,
/// Invoices in `Cancelled` status.
pub cancelled_invoices: u32,
/// Invoices in `Refunded` status.
pub refunded_invoices: u32,
/// Active placed bids found within the bounded bid scan.
pub active_bids: u32,
/// Open disputes (`Disputed` or `UnderReview`) found within the bounded scan.
pub open_disputes: u32,
/// Held escrows found within the bounded funded-invoice scan.
pub held_escrows: u32,
/// Maximum indexed records inspected for bounded counters.
pub scan_limit: u32,
/// True when one or more bounded counters had more indexed records than `scan_limit`.
pub scan_truncated: bool,
/// Ledger sequence at snapshot time.
pub ledger_sequence: u32,
/// Ledger timestamp at snapshot time.
pub ledger_timestamp: u64,
}

/// Return a compact bounded diagnostics counter summary.
///
/// This read endpoint is intended for off-chain scrapers that need one cheap
/// snapshot of protocol-health counters. Invoice status counters use existing
/// status indexes. Active bids, open disputes, and held escrows are bounded to
/// [`DIAGNOSTICS_SUMMARY_SCAN_LIMIT`] indexed records and report truncation
/// through [`DiagnosticsSummary::scan_truncated`].
pub fn get_diagnostics_summary(env: &soroban_sdk::Env) -> DiagnosticsSummary {
use crate::bid::BidStorage;
use crate::dispute;
use crate::payments::{EscrowStatus, EscrowStorage};
use crate::storage::InvoiceStorage;
use crate::types::{BidStatus, DisputeStatus, InvoiceStatus};

let pending = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Pending).len();
let verified = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Verified).len();
let funded = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded).len();
let paid = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Paid).len();
let defaulted = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Defaulted).len();
let cancelled = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Cancelled).len();
let refunded = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Refunded).len();

let bid_ids = BidStorage::get_all_bids(env);
let bid_scan_len = bid_ids.len().min(DIAGNOSTICS_SUMMARY_SCAN_LIMIT);
let mut active_bids = 0u32;
for i in 0..bid_scan_len {
let bid_id = bid_ids.get_unchecked(i);
if let Some(bid) = BidStorage::get_bid(env, &bid_id) {
if bid.status == BidStatus::Placed && !bid.is_expired(env.ledger().timestamp()) {
active_bids = active_bids.saturating_add(1);
}
}
}
let bids_truncated = bid_ids.len() > bid_scan_len;

let dispute_ids = dispute::get_invoices_with_disputes(env);
let dispute_scan_len = dispute_ids.len().min(DIAGNOSTICS_SUMMARY_SCAN_LIMIT);
let mut open_disputes = 0u32;
for i in 0..dispute_scan_len {
let invoice_id = dispute_ids.get_unchecked(i);
if let Some(invoice) = InvoiceStorage::get_invoice(env, &invoice_id) {
if matches!(
invoice.dispute_status,
DisputeStatus::Disputed | DisputeStatus::UnderReview
) {
open_disputes = open_disputes.saturating_add(1);
}
}
}
let disputes_truncated = dispute_ids.len() > dispute_scan_len;

let funded_invoice_ids = InvoiceStorage::get_invoices_by_status(env, InvoiceStatus::Funded);
let escrow_scan_len = funded_invoice_ids
.len()
.min(DIAGNOSTICS_SUMMARY_SCAN_LIMIT);
let mut held_escrows = 0u32;
for i in 0..escrow_scan_len {
let invoice_id = funded_invoice_ids.get_unchecked(i);
if let Some(escrow) = EscrowStorage::get_escrow_by_invoice(env, &invoice_id) {
if escrow.status == EscrowStatus::Held {
held_escrows = held_escrows.saturating_add(1);
}
}
}
let escrows_truncated = funded_invoice_ids.len() > escrow_scan_len;

DiagnosticsSummary {
total_invoices: pending
.saturating_add(verified)
.saturating_add(funded)
.saturating_add(paid)
.saturating_add(defaulted)
.saturating_add(cancelled)
.saturating_add(refunded),
pending_invoices: pending,
verified_invoices: verified,
funded_invoices: funded,
paid_invoices: paid,
defaulted_invoices: defaulted,
cancelled_invoices: cancelled,
refunded_invoices: refunded,
active_bids,
open_disputes,
held_escrows,
scan_limit: DIAGNOSTICS_SUMMARY_SCAN_LIMIT,
scan_truncated: bids_truncated || disputes_truncated || escrows_truncated,
ledger_sequence: env.ledger().sequence(),
ledger_timestamp: env.ledger().timestamp(),
}
}

/// A rich diagnostic snapshot of internal protocol state.
///
/// Only available when compiled with `--features diagnostics`. Never present in
Expand Down
3 changes: 2 additions & 1 deletion quicklendx-contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ impl From<QuickLendXError> for Symbol {
QuickLendXError::MaintenanceModeActive => symbol_short!("MAINT"),
QuickLendXError::ArithmeticOverflow => symbol_short!("ARITH_OF"),
QuickLendXError::DuplicateDefaultTransition => symbol_short!("DEF_DUP"),
QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER")
QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER"),
QuickLendXError::InvalidLedgerSequence => symbol_short!("INV_LED"),
}
}
}
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, Address, Bytes, BytesN, Env, Symbol};

/// Storage key for the idempotency map.
pub const IDEMPOTENCY_MAP_KEY: Symbol = symbol_short!("idem_map");
Expand Down
59 changes: 38 additions & 21 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
#![no_std]
#![cfg_attr(
test,
allow(
unused_must_use,
unused_parens,
clippy::doc_lazy_continuation,
clippy::err_expect,
clippy::module_inception,
clippy::unnecessary_cast
)
)]
#![allow(
dead_code,
unused_imports,
Expand Down Expand Up @@ -60,6 +71,7 @@ mod test_settlement_history_reconstruction;
use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, String, Vec};
use crate::idempotency::{idempotency_key, idempotency_exists, store_idempotency};

pub mod address_summary;
#[cfg(any(test, feature = "testutils"))]
pub mod bench;
pub mod admin;
Expand All @@ -68,8 +80,6 @@ pub mod audit;
pub mod backpressure;
pub mod backup;
pub mod backup_v1;
#[cfg(any(test, feature = "testutils"))]
pub mod bench;
pub mod bid;
pub mod currency;
pub mod defaults;
Expand All @@ -84,6 +94,7 @@ pub mod fees;
pub mod freshness;
pub mod governance;
pub mod health;
pub mod idempotency;
pub mod incident;
pub mod init;
pub mod invariants;
Expand Down Expand Up @@ -112,7 +123,7 @@ mod test_accept_bid_race;
mod test_panic_handler;
#[cfg(test)]
mod test_due_date_guard;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_cancel_invoice_matrix;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_admin;
Expand Down Expand Up @@ -156,9 +167,9 @@ mod test_dispute;
mod test_dispute_refund_flow;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dispute_timeline_props;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dispute_event_invariant;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_dust_transfer;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_event_completeness;
Expand All @@ -168,15 +179,13 @@ mod test_escrow_invariant_model;
mod test_escrow_refund_after_expiry;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_expired_bids_cleanup;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_freshness;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_freshness_bounds;
#[cfg(test)]
mod test_panic_handler;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_payments;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_queries;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_self_call_rejection;
Expand Down Expand Up @@ -236,7 +245,7 @@ mod test_string_limits;
mod test_analytics_consistency;
#[cfg(test)]
mod test_bid_capacity_stress;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_bid_compare_order_props;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_bid_ranking;
Expand All @@ -263,17 +272,17 @@ mod test_default_grace_boundary;
mod test_diagnostics;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_events;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_fuzz_cancelled_noop;
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_fuzz_distribute_revenue;
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_fuzz_invoice_metadata;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_fuzz_partial_payment;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_incident;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_incident;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
Expand All @@ -284,7 +293,7 @@ mod test_input_matrix;
mod test_investment_withdrawal;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_investment_transitions;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_incident;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_invoice_metadata;
Expand All @@ -300,7 +309,7 @@ mod test_clock_rollover;
mod test_rebuild_indexes;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_max_invoices_per_business;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_diagnostics;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_business_invoices_paged_ordering;
Expand All @@ -310,9 +319,9 @@ mod test_insurance_claim_payout;
mod test_insurance_optin_lifecycle;
#[cfg(all(test, feature = "fuzz-tests"))]
mod test_insurance_premium_props;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_fuzz_cancelled_noop;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_compute_yield_props;
#[cfg(all(test, feature = "fuzz-tests"))]
mod test_twa_props;
Expand All @@ -323,15 +332,15 @@ mod test_pause_reads_available;
mod test_platform_metrics_reconciliation;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_rebuild_indexes;
#[cfg(all(test, feature = "fuzz-tests"))]
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_seed;
#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))]
mod test_treasury_split_overflow_props;
#[cfg(all(test, feature = "fuzz-tests"))]
mod test_volume_tier_props;
// Issue #1482 — "cannot withdraw more than deposited" invariant: hard-coded sad
// path (always runs) + proptest property (requires fuzz-tests feature).
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_cannot_withdraw_more_than_deposited;
pub mod types;
pub use types::*;
Expand Down Expand Up @@ -1389,6 +1398,14 @@ impl QuickLendXContract {
.saturating_add(refunded)
}

/// Get a compact bounded diagnostics summary for off-chain scraping.
///
/// The summary uses existing status indexes and bounded scans only; no
/// authentication is required and no contract state is mutated.
pub fn get_diagnostics_summary(env: Env) -> diagnostics::DiagnosticsSummary {
diagnostics::get_diagnostics_summary(&env)
}

/// Get a lightweight breakdown of invoice counts by category.
///
/// Returns a `CategoryBreakdown` containing per-category invoice counts, suitable for
Expand Down Expand Up @@ -4123,7 +4140,7 @@ impl QuickLendXContract {
mod test_emergency_escrow_protection;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_settle_refund_race;
#[cfg(test)]
#[cfg(all(test, feature = "legacy-tests"))]
mod test_escrow_mutual_exclusion;
#[cfg(all(test, feature = "legacy-tests"))]
mod test_id_collision_cross_domain;
Expand Down
Loading
Loading