Skip to content
Closed
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
82 changes: 82 additions & 0 deletions .github/workflows/rustdoc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Rustdoc

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
paths:
- ".github/workflows/rustdoc.yml"
- "Cargo.toml"
- "Cargo.lock"
- "quicklendx-contracts/**"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: rustdoc-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always

jobs:
cargo-doc:
name: cargo doc (${{ matrix.package }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- quicklendx-contracts

steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
run: |
rustup show
rustup target add wasm32v1-none

- name: Generate rustdoc
run: cargo doc -p "${{ matrix.package }}" --no-deps

- name: Stage package docs
run: |
mkdir -p "public/${{ matrix.package }}"
cp -R target/doc/. "public/${{ matrix.package }}/"

- name: Upload package rustdoc artifact
uses: actions/upload-artifact@v4
with:
name: rustdoc-${{ matrix.package }}
path: public/${{ matrix.package }}
retention-days: 14

- name: Upload GitHub Pages artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: public

deploy-pages:
name: Publish rustdoc to GitHub Pages
needs: cargo-doc
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

steps:
- name: Configure GitHub Pages
uses: actions/configure-pages@v5

- name: Deploy rustdoc
id: deployment
uses: actions/deploy-pages@v4
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
• docs/ : Project-wide design, implementation, and audit documentation.
• docs/VESTING.md /docs/VESTING.md: Vesting model, edge cases, and admin protections.
• docs/QUERIES.md /docs/QUERIES.md: Catalog of common read-only entrypoints with concrete invocation examples and return values — the quickest way to find the query you need.
• docs/RUSTDOC.md : Published contract API reference and local rustdoc generation instructions.
• docs/contracts/platform-fee-ops.md /docs/contracts/platform-fee-ops.md: Admin operations playbook for managing fee rates, treasury rotation, and revenue splits.
• docs/RUNBOOK_INCIDENT_RESPONSE.md : Operator playbook for unexpected contract behavior and incident-mode recovery.
• docs/INVESTOR_TIER.md : How the investor risk score, tier, and investment limit are computed — math, thresholds, and worked examples.
Expand Down Expand Up @@ -65,6 +66,7 @@ npm run dev
- [Invoice Lifecycle](docs/INVOICE_LIFECYCLE.md): State diagram and entrypoint reference — Pending → Verified → Funded → Settled/Defaulted.
- [Dispute Lifecycle](file:///c:/Users/HP/quicklendx-protocol/docs/DISPUTE.md): Who can open, who resolves, timeout behaviour, and fund implications.
- [`docs/QUERIES.md`](docs/QUERIES.md): Catalog of common read-only entrypoints with concrete invocation examples and return values — the quickest way to find the query you need.
- [`docs/RUSTDOC.md`](docs/RUSTDOC.md): Published contract API reference and local rustdoc generation instructions.
- `docs/INVESTOR_TIER.md`: How the investor risk score, tier, and investment limit are computed — math, thresholds, and worked examples.
- `docs/KYC.md`: Business KYC vs investor KYC, what each gates.
- `quicklendx-contracts/README.md`: Smart contract-specific documentation.
Expand Down
31 changes: 18 additions & 13 deletions docs/RUSTDOC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@

## Where to find the docs

The CI pipeline auto-publishes rustdoc for the **latest tagged release** to GitHub Pages every time a tag is pushed:
The CI pipeline auto-publishes rustdoc for the latest `main` branch build to GitHub Pages on every push to `main`:

```
https://<org>.github.io/quicklendx/quicklendx_contracts/
https://<org>.github.io/quicklendx-protocol/quicklendx-contracts/quicklendx_contracts/
```

Replace `<org>` with the GitHub organisation that owns this repository (e.g. `quicklendx-labs`). The URL is stable across releases — it always points to the most recently tagged version.
Replace `<org>` with the GitHub organisation that owns this repository (e.g. `quicklendx-labs`). The URL is stable across `main` updates and points to the most recently published `main` build.

> **Tip:** Bookmark the top-level index at the URL above. Each tagged release overwrites the previous one, so you are always reading the current stable API.
> **Tip:** Bookmark the top-level index at the URL above. Each successful `main`
> publish overwrites the previous one, so you are always reading the current
> branch API.

---

## What is covered

The published docs are generated from the `quicklendx-contracts/` crate with `cargo doc`. They include:
The published docs are generated from each Cargo package with `cargo doc --no-deps`. Today the workspace package list contains `quicklendx-contracts`; future packages should be added to the `package` matrix in `.github/workflows/rustdoc.yml`. The generated docs include:

| Section | What you will find |
|---|---|
Expand Down Expand Up @@ -76,27 +78,30 @@ match result {

## Generating the docs locally

If you need to browse the docs offline, or you are reviewing a branch that has not been tagged yet:
If you need to browse the docs offline, or you are reviewing a branch that has not been published yet:

```bash
cd quicklendx-contracts

# Generate HTML docs (output: target/doc/)
cargo doc --no-deps --target wasm32-unknown-unknown
# Generate HTML docs for the contract package (output: target/doc/)
cargo doc -p quicklendx-contracts --no-deps

# Open in your default browser (macOS / Linux)
open target/doc/quicklendx_contracts/index.html
```

Omit `--target wasm32-unknown-unknown` if you only need to read the docs and do not need to verify the WASM build at the same time.
The workflow also runs this command for pull requests that touch the contract
package, but it publishes only for pushes to `main`.

---

## Staying up to date

The published URL is updated automatically on every tag push — no manual step is needed. To be notified of new releases, watch the repository on GitHub and select **Releases only**.
The published URL is updated automatically on every successful `main` push —
no manual step is needed.

If the published URL returns a 404, the most likely cause is that no tag has been pushed yet for the current development cycle. Generate the docs locally using the command above, or check the repository's Actions tab to confirm the publish workflow ran successfully.
If the published URL returns a 404, the most likely cause is that GitHub Pages
has not been enabled for the repository yet or the latest publish workflow has
not completed successfully. Generate the docs locally using the command above,
or check the repository's Actions tab for the `Rustdoc` workflow.

---

Expand Down
19 changes: 10 additions & 9 deletions quicklendx-contracts/scripts/check-wasm-size.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# WASM build and size budget regression checks for QuickLendX contracts.
#
# Builds the contract for Soroban (wasm32v1-none or wasm32-unknown-unknown)
# Builds the contract for Soroban (wasm32v1-none)
# and applies a three-tier size classification:
#
# OK : size <= WARN_BYTES (90 % of hard limit) – healthy
Expand All @@ -26,6 +26,7 @@ set -euo pipefail

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

# ── Budget constants ───────────────────────────────────────────────────────────
Expand All @@ -49,18 +50,18 @@ if [[ "$CHECK_ONLY" == false ]]; then
stellar contract build --verbose
WASM_PATH="target/wasm32v1-none/release/$WASM_NAME"
else
echo "Stellar CLI not found; using cargo wasm32-unknown-unknown."
echo "Stellar CLI not found; using cargo wasm32v1-none."
[[ -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"
rustup target add wasm32v1-none 2>/dev/null || true
CARGO_TARGET_DIR="$TARGET_DIR" cargo build --target wasm32v1-none --release --lib
WASM_PATH="$TARGET_DIR/wasm32v1-none/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
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!("LED_SEQ"),
}
}
}
3 changes: 2 additions & 1 deletion 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 crate::storage::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
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ use crate::idempotency::{idempotency_key, idempotency_exists, store_idempotency}
pub mod bench;
pub mod admin;
pub mod analytics;
pub mod address_summary;
pub mod audit;
pub mod backpressure;
pub mod backup;
Expand All @@ -84,6 +85,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
83 changes: 24 additions & 59 deletions quicklendx-contracts/src/profits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,41 +529,12 @@ pub fn validate_calculation_inputs(
// Yield Calculation
// ============================================================================

pub fn compute_yield(amount: i128, rate_bps: i128, duration_days: i128) -> i128 {
let safe_amount = amount.max(0);
let safe_rate = rate_bps.max(0);
let safe_days = duration_days.max(0);

if safe_amount == 0 || safe_rate == 0 || safe_days == 0 {
return 0;
}

let days_in_year = 365i128;
let denominator = BPS_DENOMINATOR.saturating_mul(days_in_year);

safe_amount
.saturating_mul(safe_rate)
.saturating_mul(safe_days)
/ denominator
}

/// Compute the simple interest yield on a principal amount.
///
/// # Formula
/// ```text
/// yield = amount * rate_bps * duration_days / (BPS_DENOMINATOR * 365)
/// ```
pub fn compute_yield(amount: i128, rate_bps: u32, duration_days: u32) -> i128 {
let safe_amount = amount.max(0);
let safe_rate = rate_bps as i128;
let safe_days = duration_days as i128;

let numerator = safe_amount
.saturating_mul(safe_rate)
.saturating_mul(safe_days);
let denominator = BPS_DENOMINATOR.saturating_mul(365);
numerator / denominator
}
///
/// All arithmetic uses `saturating_mul` / integer division to stay within
/// `i128` bounds without panicking and to preserve `#![no_std]` discipline.
Expand All @@ -577,44 +548,38 @@ pub fn compute_yield(amount: i128, rate_bps: u32, duration_days: u32) -> i128 {
/// For fixed `rate_bps` and `duration_days`, `yield` is non-decreasing in `amount`.
/// For fixed `amount` and `duration_days`, `yield` is non-decreasing in `rate_bps`.
/// For fixed `amount` and `rate_bps`, `yield` is non-decreasing in `duration_days`.
/// 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.into(), duration_days.into());
amount.max(0).saturating_add(yield_amount)
}

/// Compute the simple interest yield on a principal amount.
///
/// # Formula
/// ```text
/// yield = amount * rate_bps * duration_days / (BPS_DENOMINATOR * 365)
/// ```
///
/// All arithmetic uses `saturating_mul` / integer division to stay within
/// `i128` bounds without panicking and to preserve `#![no_std]` discipline.
///
/// # Arguments
/// * `amount` — Principal (must be >= 0; negative input returns 0)
/// * `rate_bps` — Annual rate in basis points, e.g. 500 = 5 %
/// * `duration_days` — Holding period in days
///
/// # Returns
/// Simple interest yield (non-negative).
pub fn compute_yield(amount: i128, rate_bps: i128, duration_days: i128) -> i128 {
if amount <= 0 || rate_bps <= 0 || duration_days <= 0 {
pub fn compute_yield<R, D>(amount: i128, rate_bps: R, duration_days: D) -> i128
where
R: Into<i128>,
D: Into<i128>,
{
let safe_amount = amount.max(0);
let safe_rate = rate_bps.into().max(0);
let safe_days = duration_days.into().max(0);

if safe_amount == 0 || safe_rate == 0 || safe_days == 0 {
return 0;
}
// amount * rate_bps * duration_days / (10_000 * 365)
let numerator = amount
.saturating_mul(rate_bps)
.saturating_mul(duration_days);
let denominator: i128 = BPS_DENOMINATOR.saturating_mul(365);

let numerator = safe_amount
.saturating_mul(safe_rate)
.saturating_mul(safe_days);
let denominator = BPS_DENOMINATOR.saturating_mul(365);
numerator / denominator
}

/// 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)
}

/// A single ledger-delta entry for time-weighted average calculations.
///
/// Each entry records the `balance` held for `duration_ledgers` ledgers.
Expand Down
Loading