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
19 changes: 15 additions & 4 deletions .github/workflows/formal-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@ jobs:
- name: Run formal verification spec tests
working-directory: stellar-lend
run: |
cargo test -p stellarlend-lending --features spec -- spec:: \
cargo check -p stellarlend-lending --features spec
cargo test -p stellarlend-lending --features spec --test protocol_formal_specs -- \
--test-threads=4

- name: Verify spec tests pass with --release profile too
working-directory: stellar-lend
run: |
cargo test -p stellarlend-lending --features spec --release -- spec:: \
cargo test -p stellarlend-lending --features spec --release --test protocol_formal_specs -- \
--test-threads=4

# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -142,6 +143,9 @@ jobs:
cargo kani \
--package stellarlend-lending \
--features spec \
--harness spec::protocol_invariants::kani_protocol_accounting_preserves_identity \
--harness spec::protocol_invariants::kani_health_factor_consistency \
--harness spec::protocol_invariants::kani_interest_index_monotonic \
--harness spec::interest::kani_interest_properties \
--harness spec::collateral::kani_collateral_ratio_properties \
--harness spec::accounting::kani_accounting_invariants \
Expand Down Expand Up @@ -189,6 +193,13 @@ jobs:
- name: Clippy on spec modules
working-directory: stellar-lend
run: |
cargo clippy -p stellarlend-lending --features spec -- \
cargo clippy -p stellarlend-lending --features spec --test protocol_formal_specs -- \
-D warnings \
-A clippy::module_name_repetitions
-A unused-imports \
-A dead-code \
-A unexpected-cfgs \
-A clippy::module_name_repetitions \
-A clippy::too-many-arguments \
-A clippy::manual-clamp \
-A clippy::manual-range-contains \
-A clippy::let-and-return
43 changes: 35 additions & 8 deletions docs/FORMAL_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

This document describes the formal verification specification suite
introduced for **Issue #224** (DevTooling: formal verification of critical
functions).
functions) and extended for **Issue #384** (protocol-level lending
invariants).

## Overview

Expand Down Expand Up @@ -32,8 +33,9 @@ and are completely absent from the production WASM binary.
| `spec::rewards` | `update_global_index`, `update_user`, `claim` | R-01 … R-07 |
| `spec::deposit_spec` | `deposit` | D-01 … D-07 |
| `spec::withdraw_spec` | `withdraw` | W-01 … W-07 |
| `spec::protocol_invariants` | protocol accounting, health factor, interest index | P-01 ... P-05, H-01 ... H-05, I-01 ... I-03 |

**Total: 56 lemmas across 8 critical function groups**
**Total: 69 lemmas across 9 critical function groups**

---

Expand All @@ -43,21 +45,29 @@ and are completely absent from the production WASM binary.

```bash
# From stellar-lend/ workspace root:
cargo test -p stellarlend-lending --features spec -- spec::
cargo check -p stellarlend-lending --features spec
cargo test -p stellarlend-lending --features spec --test protocol_formal_specs

# Single spec module:
cargo test -p stellarlend-lending --features spec -- spec::interest::
cargo test -p stellarlend-lending --features spec --test protocol_formal_specs \
lemma_p01_accounting_identity_holds_after_deposits

# With backtrace on failure:
RUST_BACKTRACE=1 cargo test -p stellarlend-lending --features spec -- spec::
RUST_BACKTRACE=1 cargo test -p stellarlend-lending --features spec \
--test protocol_formal_specs
```

Expected output:

```
test result: ok. 56 passed; 0 failed; 0 ignored
test result: ok. 13 passed; 0 failed; 0 ignored
```

The `protocol_formal_specs` integration target is the CI gate for Issue #384.
It compiles the lending crate with the `spec` feature but avoids unrelated
legacy unit-test modules, so failures map directly to the formal invariant
suite.

### Kani bounded model-checking (exhaustive, slow)

Install Kani:
Expand All @@ -73,6 +83,9 @@ Run all harnesses:
cargo kani \
--package stellarlend-lending \
--features spec \
--harness spec::protocol_invariants::kani_protocol_accounting_preserves_identity \
--harness spec::protocol_invariants::kani_health_factor_consistency \
--harness spec::protocol_invariants::kani_interest_index_monotonic \
--harness spec::interest::kani_interest_properties \
--harness spec::collateral::kani_collateral_ratio_properties \
--harness spec::accounting::kani_accounting_invariants \
Expand All @@ -96,6 +109,7 @@ contracts/lending/src/
├── collateral.rs — C-01…C-06: validate_collateral_ratio lemmas
├── accounting.rs — A-01…A-08: borrow/repay accounting invariants
├── health_factor.rs — H-01…H-07: compute_health_factor lemmas
├── protocol_invariants.rs — P/H/I protocol-level conservation lemmas
├── liquidation.rs — L-01…L-06: get_max_liquidatable_amount lemmas
├── rewards.rs — R-01…R-07: rewards distribution lemmas
├── deposit_spec.rs — D-01…D-07: deposit lemmas
Expand Down Expand Up @@ -151,6 +165,13 @@ Invariants INV-1 through INV-6 (from `spec::accounting`) prove that:
- Total debt is bounded by the ceiling (INV-6)
- Borrow and repay are inverse operations (A-05)

The protocol-level model in `spec::protocol_invariants` additionally proves:
- `total_deposits = total_debt + reserves` after deposits, borrows, and repays
- over-borrow and over-repay paths reject without mutating state
- health factor calculations track the liquidation threshold before and after
collateral/debt changes
- interest index accrual never decreases when timestamps move forward

### Mathematical Correctness

The interest formula (I-07), collateral boundary (C-06), health factor boundary
Expand Down Expand Up @@ -183,7 +204,9 @@ When modifying a function covered by a spec:
2. **Update the mathematical model** in the spec doc comment.
3. **Update the reference implementation** to match the new logic.
4. **Update or add lemmas** that cover the new behaviour.
5. **Re-run** `cargo test --features spec -- spec::` and confirm green.
5. **Re-run** `cargo check -p stellarlend-lending --features spec` and
`cargo test -p stellarlend-lending --features spec --test protocol_formal_specs`
and confirm green.

The GitHub Actions workflow enforces this gate on every PR touching the
affected files.
Expand All @@ -196,7 +219,11 @@ The workflow at `.github/workflows/formal-verification.yml` runs:

| Job | Trigger | Purpose |
|---|---|---|
| `spec-lemmas` | Every push/PR | Run 56 lemma tests (ubuntu + macos) |
| `spec-lemmas` | Every push/PR | Run protocol formal spec lemmas (ubuntu + macos) |
| `spec-isolation` | Every push/PR | Assert no spec symbols in production WASM |
| `spec-lint` | Every push/PR | Clippy on spec modules |
| `kani-harnesses` | Manual dispatch | Bounded model-checking (slow) |

Kani is used as the Rust/Soroban model checker for this repository. It fills
the same role that Certora Prover fills for Solidity/EVM projects while keeping
the verification target native to the contract language used here.
3 changes: 3 additions & 0 deletions stellar-lend/contracts/lending/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ testutils = ["soroban-sdk/testutils"]
spec = []
# Kani bounded model-checking harnesses (requires `cargo kani`).
kani = []

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
32 changes: 20 additions & 12 deletions stellar-lend/contracts/lending/src/borrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,14 +758,17 @@ fn get_debt_position(
}
}

env.storage().persistent().get(&key).unwrap_or(DebtPosition {
borrowed_amount: 0,
interest_accrued: 0,
last_update: env.ledger().timestamp(),
asset: default_asset.cloned().unwrap_or_else(|| user.clone()),
rate_type,
stable_rate_bps: 0,
})
env.storage()
.persistent()
.get(&key)
.unwrap_or(DebtPosition {
borrowed_amount: 0,
interest_accrued: 0,
last_update: env.ledger().timestamp(),
asset: default_asset.cloned().unwrap_or_else(|| user.clone()),
rate_type,
stable_rate_bps: 0,
})
}

fn save_debt_position(env: &Env, user: &Address, position: &DebtPosition) {
Expand Down Expand Up @@ -890,9 +893,10 @@ pub fn initialize_borrow_settings(
.persistent()
.has(&BorrowDataKey::StableRatePremiumBps)
{
env.storage()
.persistent()
.set(&BorrowDataKey::StableRatePremiumBps, &DEFAULT_STABLE_PREMIUM_BPS);
env.storage().persistent().set(
&BorrowDataKey::StableRatePremiumBps,
&DEFAULT_STABLE_PREMIUM_BPS,
);
}
if !env
.storage()
Expand All @@ -904,7 +908,11 @@ pub fn initialize_borrow_settings(
&DEFAULT_STABLE_RECALC_INTERVAL_SECS,
);
}
if !env.storage().persistent().has(&BorrowDataKey::RateSwitchFeeBps) {
if !env
.storage()
.persistent()
.has(&BorrowDataKey::RateSwitchFeeBps)
{
env.storage()
.persistent()
.set(&BorrowDataKey::RateSwitchFeeBps, &DEFAULT_SWITCH_FEE_BPS);
Expand Down
4 changes: 1 addition & 3 deletions stellar-lend/contracts/lending/src/insurance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,7 @@ pub fn initialize(env: &Env, admin: &Address) -> Result<(), InsuranceError> {
return Err(InsuranceError::AlreadyInitialized);
}
admin.require_auth();
env.storage()
.persistent()
.set(&InsuranceKey::Admin, admin);
env.storage().persistent().set(&InsuranceKey::Admin, admin);
Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion stellar-lend/contracts/lending/src/interest_rate.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use soroban_sdk::{contracterror, contracttype, Address, Env};

use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowError, BorrowDataKey};
use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowDataKey, BorrowError};

const BPS_SCALE: i128 = 10_000;

Expand Down
11 changes: 7 additions & 4 deletions stellar-lend/contracts/lending/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ mod borrow;
mod deposit;
mod events;
mod flash_loan;
mod interest_rate;
mod pause;
mod risk_monitor;
mod token_receiver;
mod withdraw;

Expand Down Expand Up @@ -48,10 +50,9 @@ use insurance::{
collect_premium as insurance_collect_premium, evaluate_claim as insurance_evaluate_claim,
fund_pool as insurance_fund_pool, get_analytics as insurance_get_analytics,
get_claim_by_id as insurance_get_claim, get_coverage_limit as insurance_get_coverage_limit,
get_premium_rate as insurance_get_premium_rate,
initialize as insurance_initialize,
set_coverage_limit as insurance_set_coverage_limit,
submit_claim as insurance_submit_claim, InsuranceAnalytics, InsuranceClaim, InsuranceError,
get_premium_rate as insurance_get_premium_rate, initialize as insurance_initialize,
set_coverage_limit as insurance_set_coverage_limit, submit_claim as insurance_submit_claim,
InsuranceAnalytics, InsuranceClaim, InsuranceError,
};

#[cfg(test)]
Expand All @@ -68,6 +69,8 @@ mod insurance_test;
mod math_safety_test;
#[cfg(test)]
mod pause_test;
#[cfg(any(test, feature = "spec"))]
mod spec;
#[cfg(test)]
mod token_receiver_test;
#[cfg(test)]
Expand Down
68 changes: 55 additions & 13 deletions stellar-lend/contracts/lending/src/spec/accounting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,38 @@ pub enum AccountingError {

impl AccountingState {
pub fn new(ceiling: i128) -> Self {
Self { total_debt: 0, debt_ceiling: ceiling, user_principal: 0, user_interest: 0 }
Self {
total_debt: 0,
debt_ceiling: ceiling,
user_principal: 0,
user_interest: 0,
}
}

pub fn apply_borrow(&mut self, amount: i128) -> Result<(), AccountingError> {
if amount <= 0 { return Err(AccountingError::InvalidAmount); }
let new_total = self.total_debt.checked_add(amount).ok_or(AccountingError::Overflow)?;
if new_total > self.debt_ceiling { return Err(AccountingError::DebtCeilingReached); }
let new_user = self.user_principal.checked_add(amount).ok_or(AccountingError::Overflow)?;
if amount <= 0 {
return Err(AccountingError::InvalidAmount);
}
let new_total = self
.total_debt
.checked_add(amount)
.ok_or(AccountingError::Overflow)?;
if new_total > self.debt_ceiling {
return Err(AccountingError::DebtCeilingReached);
}
let new_user = self
.user_principal
.checked_add(amount)
.ok_or(AccountingError::Overflow)?;
self.total_debt = new_total;
self.user_principal = new_user;
Ok(())
}

pub fn apply_repay(&mut self, amount: i128) -> Result<(), AccountingError> {
if amount <= 0 { return Err(AccountingError::InvalidAmount); }
if amount <= 0 {
return Err(AccountingError::InvalidAmount);
}
let mut remaining = amount;
if remaining >= self.user_interest {
remaining -= self.user_interest;
Expand All @@ -65,9 +82,14 @@ impl AccountingState {
remaining = 0;
}
if remaining > 0 {
if remaining > self.user_principal { return Err(AccountingError::RepayAmountTooHigh); }
if remaining > self.user_principal {
return Err(AccountingError::RepayAmountTooHigh);
}
self.user_principal -= remaining;
self.total_debt = self.total_debt.checked_sub(remaining).ok_or(AccountingError::Overflow)?;
self.total_debt = self
.total_debt
.checked_sub(remaining)
.ok_or(AccountingError::Overflow)?;
}
Ok(())
}
Expand All @@ -80,7 +102,11 @@ fn lemma_a01_borrow_increments_total_debt() {
let mut state = AccountingState::new(i128::MAX);
let before = state.total_debt;
state.apply_borrow(amount).expect("A-01");
assert_eq!(state.total_debt, before + amount, "A-01 failed for amount={amount}");
assert_eq!(
state.total_debt,
before + amount,
"A-01 failed for amount={amount}"
);
}
}

Expand All @@ -103,7 +129,11 @@ fn lemma_a03_total_debt_stays_non_negative() {
let mut state = AccountingState::new(i128::MAX);
state.apply_borrow(100_000).unwrap();
state.apply_repay(100_000).expect("A-03");
assert!(state.total_debt >= 0, "A-03: negative total_debt={}", state.total_debt);
assert!(
state.total_debt >= 0,
"A-03: negative total_debt={}",
state.total_debt
);
assert_eq!(state.total_debt, 0, "A-03");
}

Expand All @@ -113,7 +143,11 @@ fn lemma_a04_borrow_ceiling_enforced() {
let ceiling = 1_000_000i128;
let mut state = AccountingState::new(ceiling);
state.apply_borrow(ceiling).expect("A-04");
assert_eq!(state.apply_borrow(1), Err(AccountingError::DebtCeilingReached), "A-04");
assert_eq!(
state.apply_borrow(1),
Err(AccountingError::DebtCeilingReached),
"A-04"
);
assert_eq!(state.total_debt, ceiling, "A-04: ceiling breached");
}

Expand Down Expand Up @@ -144,7 +178,11 @@ fn lemma_a06_partial_repay_leaves_correct_remainder() {
fn lemma_a07_over_repay_is_rejected() {
let mut state = AccountingState::new(i128::MAX);
state.apply_borrow(50_000).unwrap();
assert_eq!(state.apply_repay(50_001), Err(AccountingError::RepayAmountTooHigh), "A-07");
assert_eq!(
state.apply_repay(50_001),
Err(AccountingError::RepayAmountTooHigh),
"A-07"
);
assert_eq!(state.user_principal, 50_000, "A-07: state mutated");
assert_eq!(state.total_debt, 50_000, "A-07: total_debt mutated");
}
Expand All @@ -154,7 +192,11 @@ fn lemma_a07_over_repay_is_rejected() {
fn lemma_a08_borrow_beyond_ceiling_is_rejected() {
let mut state = AccountingState::new(500_000);
state.apply_borrow(300_000).unwrap();
assert_eq!(state.apply_borrow(250_000), Err(AccountingError::DebtCeilingReached), "A-08");
assert_eq!(
state.apply_borrow(250_000),
Err(AccountingError::DebtCeilingReached),
"A-08"
);
assert_eq!(state.total_debt, 300_000, "A-08: state mutated");
}

Expand Down
Loading