diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fbb1b8d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,101 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+
+jobs:
+ fmt:
+ name: Rustfmt
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt
+
+ - name: Check formatting
+ run: cargo fmt --all -- --check
+
+ clippy:
+ name: Clippy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - name: Cache cargo registry
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-clippy-
+
+ - name: Run Clippy
+ # Use the host target so Clippy can analyse test code (testutils feature).
+ # -D warnings promotes every warning to an error.
+ run: cargo clippy --all-targets -- -D warnings
+
+ check:
+ name: Cargo Check (wasm32)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain + wasm32 target
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Cache cargo registry
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-check-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-check-
+
+ - name: Check contract compiles to wasm32
+ run: cargo check --target wasm32-unknown-unknown
+
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo registry
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-test-
+
+ - name: Run tests
+ run: cargo test
diff --git a/.gitignore b/.gitignore
index e7d0deb..6b0e8e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@
.env
.env.example
test_snapshots/
+
+# Windows installer binaries
+*.exe
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..f9afef9
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,22 @@
+# Pre-commit hooks for QuorumCredit
+# Setup: pip install pre-commit && pre-commit install
+# Manual run: pre-commit run --all-files
+
+repos:
+ - repo: local
+ hooks:
+ - id: cargo-fmt
+ name: cargo fmt
+ description: Enforce Rust formatting (mirrors CI fmt check)
+ language: system
+ entry: cargo fmt --all --
+ types: [rust]
+ pass_filenames: false
+
+ - id: cargo-clippy
+ name: cargo clippy
+ description: Lint Rust code — same flags as CI (-D warnings)
+ language: system
+ entry: cargo clippy --all-targets -- -D warnings
+ types: [rust]
+ pass_filenames: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3f6f331..c130830 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -64,6 +64,73 @@ We follow standard Rust formatting conventions. Please run the following before
cargo fmt --all
```
+## ✅ CI Pipeline
+
+Every push and pull request against `main` runs four GitHub Actions jobs defined in `.github/workflows/ci.yml`:
+
+| Job | What it does |
+|---|---|
+| Rustfmt | `cargo fmt --all -- --check` — fails if any file is not formatted |
+| Clippy | `cargo clippy --all-targets -- -D warnings` — fails on any lint warning |
+| Cargo Check (wasm32) | `cargo check --target wasm32-unknown-unknown` — verifies the contract compiles to the deployment target |
+| Tests | `cargo test` — runs the full test suite on the host target |
+
+All four jobs must pass before a PR can be merged.
+
+## 🪝 Pre-commit Hooks
+
+This repository uses [pre-commit](https://pre-commit.com/) to automatically run `cargo fmt` and `cargo clippy` before every commit, keeping the codebase consistently formatted and lint-free.
+
+### Prerequisites
+
+- Python 3.7+ and `pip`
+- Rust toolchain with `rustfmt` and `clippy` components
+
+```bash
+rustup component add rustfmt clippy
+```
+
+### Setup
+
+Install the `pre-commit` tool and register the hooks with git:
+
+```bash
+pip install pre-commit
+pre-commit install
+```
+
+That's it. From this point on, every `git commit` will automatically run:
+
+1. `cargo fmt --all` — formats all Rust source files
+2. `cargo clippy --all-targets -- -D warnings` — lints and fails on any warning
+
+If either check fails, the commit is blocked. Fix the reported issues and re-stage your changes before committing again.
+
+### Running hooks manually
+
+To run all hooks against every file without making a commit:
+
+```bash
+pre-commit run --all-files
+```
+
+To run a single hook by ID:
+
+```bash
+pre-commit run cargo-fmt
+pre-commit run cargo-clippy
+```
+
+### Skipping hooks (use sparingly)
+
+In exceptional cases (e.g. a WIP commit on a personal branch) you can bypass the hooks:
+
+```bash
+git commit --no-verify -m "wip: ..."
+```
+
+Do not use `--no-verify` on commits destined for `main` or any PR branch.
+
---
*Happy Coding! 🚀*
diff --git a/src/admin.rs b/src/admin.rs
index 872aff8..25b1785 100644
--- a/src/admin.rs
+++ b/src/admin.rs
@@ -375,7 +375,10 @@ pub fn is_whitelisted(env: Env, voucher: Address) -> bool {
pub fn set_max_vouchers_per_borrower(env: Env, admin_signers: Vec
, max_vouchers: u32) {
require_admin_approval(&env, &admin_signers);
- assert!(max_vouchers > 0, "max_vouchers_per_borrower must be greater than zero");
+ assert!(
+ max_vouchers > 0,
+ "max_vouchers_per_borrower must be greater than zero"
+ );
env.storage()
.instance()
.set(&DataKey::MaxVouchersPerBorrower, &max_vouchers);
diff --git a/src/double_slash_panic_test.rs b/src/double_slash_panic_test.rs
index 53037f7..1d7ac30 100644
--- a/src/double_slash_panic_test.rs
+++ b/src/double_slash_panic_test.rs
@@ -43,7 +43,13 @@ mod double_slash_panic_tests {
// Advance past MIN_VOUCH_AGE (60 s) so vouches are eligible.
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, admin, admin_vec, token: token_id.address() }
+ Setup {
+ env,
+ client,
+ admin,
+ admin_vec,
+ token: token_id.address(),
+ }
}
/// Set up a voucher + borrower with an active loan, then return both addresses.
@@ -107,7 +113,11 @@ mod double_slash_panic_tests {
s.client.vote_slash(&voucher, &borrower, &true);
let loan = s.client.get_loan(&borrower).expect("loan should exist");
- assert!(loan.defaulted, "first slash must mark loan as defaulted");
+ assert_eq!(
+ loan.status,
+ crate::LoanStatus::Defaulted,
+ "first slash must mark loan as defaulted"
+ );
}
/// **Property 2: Preservation** - Missing Loan Still Errors
@@ -126,6 +136,9 @@ mod double_slash_panic_tests {
s.client.vouch(&voucher, &borrower, &1_000_000, &s.token);
let result = s.client.try_vote_slash(&voucher, &borrower, &true);
- assert!(result.is_err(), "slash on a borrower with no loan must return an error");
+ assert!(
+ result.is_err(),
+ "slash on a borrower with no loan must return an error"
+ );
}
}
diff --git a/src/duplicate_loan_test.rs b/src/duplicate_loan_test.rs
index b3746e6..9e3c479 100644
--- a/src/duplicate_loan_test.rs
+++ b/src/duplicate_loan_test.rs
@@ -35,7 +35,11 @@ mod duplicate_loan_tests {
// Advance past MIN_VOUCH_AGE so vouches are eligible.
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, token_id: token_id.address() }
+ Setup {
+ env,
+ client,
+ token_id: token_id.address(),
+ }
}
fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
@@ -82,7 +86,11 @@ mod duplicate_loan_tests {
do_loan(&s, &borrower);
let loan = s.client.get_loan(&borrower).expect("loan should exist");
- assert!(!loan.repaid && !loan.defaulted, "loan should be active");
+ assert_eq!(
+ loan.status,
+ crate::LoanStatus::Active,
+ "loan should be active"
+ );
// try_request_loan returns Err when a loan is already active.
let result = s.client.try_request_loan(
diff --git a/src/get_loan_none_test.rs b/src/get_loan_none_test.rs
index 0dcc0a9..630cfdf 100644
--- a/src/get_loan_none_test.rs
+++ b/src/get_loan_none_test.rs
@@ -33,7 +33,10 @@ mod get_loan_none_tests {
let fresh = Address::generate(&env);
let result = client.get_loan(&fresh);
- assert!(result.is_none(), "get_loan should return None for an address with no loan record");
+ assert!(
+ result.is_none(),
+ "get_loan should return None for an address with no loan record"
+ );
}
/// Property 1: get_loan returns None for any address with no loan history
diff --git a/src/governance.rs b/src/governance.rs
index bacd6d6..4a94ff7 100644
--- a/src/governance.rs
+++ b/src/governance.rs
@@ -1,6 +1,6 @@
use crate::errors::ContractError;
use crate::helpers::{add_slash_balance, config, get_active_loan_record, require_not_paused};
-use crate::types::{DataKey, SlashVoteRecord, VouchRecord, TimelockProposal, TimelockAction};
+use crate::types::{DataKey, SlashVoteRecord, TimelockAction, TimelockProposal, VouchRecord};
use soroban_sdk::{symbol_short, Address, Env, Vec};
/// Default quorum: 50% of total vouched stake must approve.
@@ -81,8 +81,8 @@ pub fn vote_slash(
.get(&DataKey::SlashVoteQuorum)
.unwrap_or(DEFAULT_SLASH_VOTE_QUORUM_BPS);
- let quorum_reached = total_stake > 0
- && vote.approve_stake * 10_000 / total_stake >= quorum_bps as i128;
+ let quorum_reached =
+ total_stake > 0 && vote.approve_stake * 10_000 / total_stake >= quorum_bps as i128;
if quorum_reached {
vote.executed = true;
@@ -138,7 +138,10 @@ fn execute_slash(env: &Env, borrower: &Address) -> Result<(), ContractError> {
// Mark loan as defaulted first so we can read token_address.
let mut loan = get_active_loan_record(env, borrower)?;
- assert!(!loan.defaulted, "already defaulted");
+ assert!(
+ loan.status != crate::types::LoanStatus::Defaulted,
+ "already defaulted"
+ );
let loan_token = soroban_sdk::token::Client::new(env, &loan.token_address);
let mut total_slashed: i128 = 0;
@@ -188,7 +191,7 @@ fn execute_slash(env: &Env, borrower: &Address) -> Result<(), ContractError> {
}
/// ── Issue 109: Slash Proposal Confirmation Window ──
-///
+///
/// Implements a two-step slash with timelock pattern:
/// 1. propose_slash: Admin creates a proposal, sets execution time (eta)
/// 2. execute_slash_proposal: After delay, anyone can execute
@@ -240,10 +243,7 @@ pub fn propose_slash(
}
/// Execute a previously proposed slash action after the delay has passed.
-pub fn execute_slash_proposal(
- env: Env,
- proposal_id: u64,
-) -> Result<(), ContractError> {
+pub fn execute_slash_proposal(env: Env, proposal_id: u64) -> Result<(), ContractError> {
require_not_paused(&env)?;
// Get the proposal
@@ -309,10 +309,7 @@ pub fn cancel_slash_proposal(
.ok_or(ContractError::NoActiveLoan)?;
// Only proposer can cancel
- assert!(
- caller == proposal.proposer,
- "only proposer can cancel"
- );
+ assert!(caller == proposal.proposer, "only proposer can cancel");
if proposal.executed || proposal.cancelled {
return Err(ContractError::SlashAlreadyExecuted);
@@ -337,4 +334,3 @@ pub fn get_timelock_proposal(env: Env, proposal_id: u64) -> Option,
- contract_id: Address,
admin: Address,
token_id: Address,
}
@@ -40,7 +39,6 @@ mod governance_tests {
Setup {
env,
client,
- contract_id,
admin,
token_id: token_id.address(),
}
@@ -54,7 +52,13 @@ mod governance_tests {
/// Request a loan for `borrower` (vouches must already meet threshold).
fn do_loan(s: &Setup, borrower: &Address, amount: i128, threshold: i128) {
- s.client.request_loan(borrower, &amount, &threshold, &soroban_sdk::String::from_str(&s.env, "test loan"), &s.token_id);
+ s.client.request_loan(
+ borrower,
+ &amount,
+ &threshold,
+ &soroban_sdk::String::from_str(&s.env, "test loan"),
+ &s.token_id,
+ );
}
// ── Tests ─────────────────────────────────────────────────────────────────
@@ -107,10 +111,7 @@ mod governance_tests {
// First vote: 30% — not enough
s.client.vote_slash(&voucher_a, &borrower, &true);
- assert_eq!(
- s.client.loan_status(&borrower),
- crate::LoanStatus::Active
- );
+ assert_eq!(s.client.loan_status(&borrower), crate::LoanStatus::Active);
// Second vote: 30% + 30% = 60% ≥ 50% → slash fires
s.client.vote_slash(&voucher_b, &borrower, &true);
@@ -133,10 +134,7 @@ mod governance_tests {
s.client.vote_slash(&voucher_a, &borrower, &false);
// Loan still active
- assert_eq!(
- s.client.loan_status(&borrower),
- crate::LoanStatus::Active
- );
+ assert_eq!(s.client.loan_status(&borrower), crate::LoanStatus::Active);
let vote = s.client.get_slash_vote(&borrower).unwrap();
assert!(!vote.executed);
assert_eq!(vote.reject_stake, 600_000);
diff --git a/src/helpers.rs b/src/helpers.rs
index b797534..30ac722 100644
--- a/src/helpers.rs
+++ b/src/helpers.rs
@@ -57,14 +57,6 @@ pub fn add_slash_balance(env: &Env, amount: i128) {
.set(&DataKey::SlashTreasury, &(current + amount));
}
-/// Issue 112: Get current slash balance to prevent it from being used for yield payouts.
-pub fn get_slash_balance(env: &Env) -> i128 {
- env.storage()
- .instance()
- .get(&DataKey::SlashTreasury)
- .unwrap_or(0)
-}
-
pub fn has_active_loan(env: &Env, borrower: &Address) -> bool {
matches!(get_active_loan_record(env, borrower), Ok(loan) if loan.status == crate::types::LoanStatus::Active)
}
@@ -108,10 +100,6 @@ pub fn token(env: &Env) -> token::Client<'_> {
token::Client::new(env, &addr)
}
-pub fn token_client(env: &Env) -> token::Client<'_> {
- token(env)
-}
-
/// Returns a token client for `addr` after verifying it is an allowed token
/// (either the primary protocol token or in `Config.allowed_tokens`).
pub fn require_allowed_token<'a>(
diff --git a/src/lib.rs b/src/lib.rs
index 01ed7fc..19f319b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,12 +24,12 @@ mod multi_asset_test;
// #[cfg(test)]
mod referral_test;
// #[cfg(test)]
-mod request_loan_insufficient_stake_test;
#[cfg(test)]
mod min_loan_amount_test;
+mod request_loan_insufficient_stake_test;
+mod security_fixes_test;
#[cfg(test)]
mod vouch_zero_stake_test;
-mod security_fixes_test;
// #[cfg(test)]
mod bug_condition_test;
#[cfg(test)]
@@ -40,11 +40,11 @@ mod duplicate_loan_test;
mod get_loan_none_test;
// #[cfg(test)]
-mod slash_multi_voucher_test;
-#[cfg(test)]
-mod paused_state_test;
#[cfg(test)]
mod max_vouchers_per_borrower_test;
+#[cfg(test)]
+mod paused_state_test;
+mod slash_multi_voucher_test;
pub use errors::ContractError;
pub use types::*;
@@ -457,10 +457,7 @@ impl QuorumCreditContract {
}
/// Issue 109: Execute a previously proposed slash after the delay has passed.
- pub fn execute_slash_proposal(
- env: Env,
- proposal_id: u64,
- ) -> Result<(), ContractError> {
+ pub fn execute_slash_proposal(env: Env, proposal_id: u64) -> Result<(), ContractError> {
governance::execute_slash_proposal(env, proposal_id)
}
@@ -480,24 +477,10 @@ impl QuorumCreditContract {
// ── Reputation NFT Tests ──────────────────────────────────────────────────
-
-
// ── Loan Pool Tests ───────────────────────────────────────────────────────
-
-
-
-
-
-
// ── Voucher Cap Tests ─────────────────────────────────────────────────────
-
-
-
-
-
-
pub fn set_slash_vote_quorum(env: Env, admin_signers: Vec, quorum_bps: u32) {
helpers::require_admin_approval(&env, &admin_signers);
governance::set_slash_vote_quorum(&env, quorum_bps);
@@ -506,4 +489,8 @@ impl QuorumCreditContract {
pub fn get_slash_vote_quorum(env: Env) -> u32 {
governance::get_slash_vote_quorum(env)
}
+
+ pub fn get_slash_vote(env: Env, borrower: Address) -> Option {
+ governance::get_slash_vote(env, borrower)
+ }
}
diff --git a/src/loan.rs b/src/loan.rs
index 55656ee..2cd0d16 100644
--- a/src/loan.rs
+++ b/src/loan.rs
@@ -1,10 +1,12 @@
use crate::errors::ContractError;
use crate::helpers::{
- config, get_active_loan_record, get_slash_balance, has_active_loan, next_loan_id, require_allowed_token,
+ config, get_active_loan_record, has_active_loan, next_loan_id, require_allowed_token,
require_not_paused,
};
use crate::reputation::ReputationNftExternalClient;
-use crate::types::{DataKey, LoanRecord, LoanStatus, VouchRecord, DEFAULT_REFERRAL_BONUS_BPS, MIN_VOUCH_AGE};
+use crate::types::{
+ DataKey, LoanRecord, LoanStatus, VouchRecord, DEFAULT_REFERRAL_BONUS_BPS, MIN_VOUCH_AGE,
+};
use soroban_sdk::{panic_with_error, symbol_short, Address, Env, Vec};
/// Register a referrer for a borrower. Must be called before `request_loan`.
@@ -225,12 +227,10 @@ pub fn repay(env: Env, borrower: Address, payment: i128) -> Result<(), ContractE
.persistent()
.get(&DataKey::Vouches(borrower.clone()))
.unwrap_or(Vec::new(&env));
-
+
// Issue 112: Only distribute yield to vouches in the same token as the loan.
- // Verify that available funds exclude slash balance to prevent fund leakage.
let loan_token = soroban_sdk::token::Client::new(&env, &loan.token_address);
- let slash_balance = get_slash_balance(&env);
-
+
let mut total_stake: i128 = 0;
for v in vouches.iter() {
if v.token == loan.token_address {
@@ -252,13 +252,13 @@ pub fn repay(env: Env, borrower: Address, payment: i128) -> Result<(), ContractE
0
};
total_distributed += voucher_yield;
-
+
// Assert that we're not exceeding available yield
assert!(
total_distributed <= available_for_yield,
"yield distribution would exceed available funds"
);
-
+
loan_token.transfer(
&env.current_contract_address(),
&v.voucher,
@@ -281,7 +281,7 @@ pub fn repay(env: Env, borrower: Address, payment: i128) -> Result<(), ContractE
.get(&DataKey::ReferralBonusBps)
.unwrap_or(DEFAULT_REFERRAL_BONUS_BPS);
let bonus = loan.amount * bonus_bps as i128 / 10_000;
-
+
// Issue 112: Ensure bonus doesn't use slash funds
if bonus > 0 {
loan_token.transfer(&env.current_contract_address(), &referrer, &bonus);
diff --git a/src/max_vouchers_per_borrower_test.rs b/src/max_vouchers_per_borrower_test.rs
index 7e6555d..4fff6de 100644
--- a/src/max_vouchers_per_borrower_test.rs
+++ b/src/max_vouchers_per_borrower_test.rs
@@ -7,7 +7,10 @@ mod tests {
Address, Env, String, Vec,
};
- fn create_token_contract<'a>(env: &Env, admin: &Address) -> (TokenClient<'a>, TokenAdminClient<'a>) {
+ fn create_token_contract<'a>(
+ env: &Env,
+ admin: &Address,
+ ) -> (TokenClient<'a>, TokenAdminClient<'a>) {
let contract_address = env.register_stellar_asset_contract_v2(admin.clone());
(
TokenClient::new(env, &contract_address.address()),
diff --git a/src/min_loan_amount_test.rs b/src/min_loan_amount_test.rs
index d2d11d8..3f4dcb9 100644
--- a/src/min_loan_amount_test.rs
+++ b/src/min_loan_amount_test.rs
@@ -2,7 +2,9 @@
mod min_loan_amount_tests {
use crate::errors::ContractError;
use crate::{QuorumCreditContract, QuorumCreditContractClient};
- use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String, Vec};
+ use soroban_sdk::{
+ testutils::Address as _, token::StellarAssetClient, Address, Env, String, Vec,
+ };
fn setup(env: &Env) -> (Address, Address, Address, Address) {
let deployer = Address::generate(env);
diff --git a/src/multi_asset_test.rs b/src/multi_asset_test.rs
index fa739b6..5671a43 100644
--- a/src/multi_asset_test.rs
+++ b/src/multi_asset_test.rs
@@ -36,7 +36,13 @@ mod multi_asset_tests {
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, xlm: xlm_id.address(), usdc: usdc_id.address(), admin }
+ Setup {
+ env,
+ client,
+ xlm: xlm_id.address(),
+ usdc: usdc_id.address(),
+ admin,
+ }
}
fn purpose(env: &Env) -> String {
@@ -51,7 +57,8 @@ mod multi_asset_tests {
StellarAssetClient::new(&s.env, &s.usdc).mint(&voucher, &1_000_000);
s.client.vouch(&voucher, &borrower, &1_000_000, &s.usdc);
- s.client.request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.usdc);
+ s.client
+ .request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.usdc);
let loan = s.client.get_loan(&borrower).unwrap();
assert_eq!(loan.token_address, s.usdc);
@@ -67,7 +74,9 @@ mod multi_asset_tests {
StellarAssetClient::new(&s.env, &s.xlm).mint(&voucher, &1_000_000);
s.client.vouch(&voucher, &borrower, &1_000_000, &s.xlm);
- let result = s.client.try_request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.usdc);
+ let result =
+ s.client
+ .try_request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.usdc);
assert!(result.is_err());
}
@@ -78,7 +87,9 @@ mod multi_asset_tests {
let borrower = Address::generate(&s.env);
let random_token = Address::generate(&s.env);
- let result = s.client.try_vouch(&voucher, &borrower, &100_000, &random_token);
+ let result = s
+ .client
+ .try_vouch(&voucher, &borrower, &100_000, &random_token);
assert_eq!(result, Err(Ok(ContractError::InvalidToken)));
}
@@ -88,7 +99,13 @@ mod multi_asset_tests {
let borrower = Address::generate(&s.env);
let random_token = Address::generate(&s.env);
- let result = s.client.try_request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &random_token);
+ let result = s.client.try_request_loan(
+ &borrower,
+ &100_000,
+ &500_000,
+ &purpose(&s.env),
+ &random_token,
+ );
assert_eq!(result, Err(Ok(ContractError::InvalidToken)));
}
@@ -112,7 +129,8 @@ mod multi_asset_tests {
StellarAssetClient::new(&s.env, &s.xlm).mint(&voucher, &1_000_000);
s.client.vouch(&voucher, &borrower, &1_000_000, &s.xlm);
- s.client.request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.xlm);
+ s.client
+ .request_loan(&borrower, &100_000, &500_000, &purpose(&s.env), &s.xlm);
let loan = s.client.get_loan(&borrower).unwrap();
assert_eq!(loan.token_address, s.xlm);
diff --git a/src/multi_voucher_stake_test.rs b/src/multi_voucher_stake_test.rs
index af5d726..4c7364f 100644
--- a/src/multi_voucher_stake_test.rs
+++ b/src/multi_voucher_stake_test.rs
@@ -15,7 +15,6 @@ mod multi_voucher_stake_tests {
struct Setup {
env: Env,
client: QuorumCreditContractClient<'static>,
- contract_id: Address,
token_id: Address,
}
@@ -39,7 +38,7 @@ mod multi_voucher_stake_tests {
// Advance time past MIN_VOUCH_AGE (60s) so vouches are eligible.
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, contract_id, token_id: token_id.address() }
+ Setup { env, client, token_id: token_id.address() }
}
fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
@@ -95,8 +94,7 @@ mod multi_voucher_stake_tests {
// Confirm the loan is now active.
let loan = s.client.get_loan(&borrower).expect("loan should exist after request");
assert_eq!(loan.amount, 100_000);
- assert!(!loan.repaid);
- assert!(!loan.defaulted);
+ assert_eq!(loan.status, crate::LoanStatus::Active);
}
/// get_vouches on a fresh address with no vouches should return None (no entry).
diff --git a/src/paused_state_test.rs b/src/paused_state_test.rs
index 4fbc114..cc02f72 100644
--- a/src/paused_state_test.rs
+++ b/src/paused_state_test.rs
@@ -1,6 +1,5 @@
#![cfg(test)]
-use crate::types::{Config, DataKey};
use crate::{ContractError, QuorumCreditContract, QuorumCreditContractClient};
use soroban_sdk::{
testutils::{Address as _, Ledger},
@@ -41,7 +40,15 @@ fn setup_test_env() -> (
token_client.mint(&voucher, &10_000_000);
token_client.mint(&borrower, &1_000_000);
- (env, client, admin, voucher, borrower, token_addr, token_client)
+ (
+ env,
+ client,
+ admin,
+ voucher,
+ borrower,
+ token_addr,
+ token_client,
+ )
}
#[test]
@@ -150,7 +157,10 @@ fn test_request_loan_blocked_when_paused() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
@@ -178,7 +188,10 @@ fn test_repay_blocked_when_paused() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
@@ -208,7 +221,10 @@ fn test_vote_slash_blocked_when_paused() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
@@ -238,7 +254,10 @@ fn test_propose_slash_blocked_when_paused() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
@@ -268,7 +287,10 @@ fn test_execute_slash_proposal_blocked_when_paused() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
@@ -350,7 +372,10 @@ fn test_all_fund_moving_functions_respect_pause() {
client.vouch(&voucher, &borrower, &1_000_000, &token_addr);
// Fund the contract for loan disbursement
- token_client.mint(&env.as_contract(&client.address, || env.current_contract_address()), &5_000_000);
+ token_client.mint(
+ &env.as_contract(&client.address, || env.current_contract_address()),
+ &5_000_000,
+ );
// Advance time to meet MIN_VOUCH_AGE requirement
env.ledger().with_mut(|li| li.timestamp = 100);
diff --git a/src/referral_test.rs b/src/referral_test.rs
index f5b6f88..b27d2a7 100644
--- a/src/referral_test.rs
+++ b/src/referral_test.rs
@@ -1,6 +1,6 @@
#[cfg(test)]
mod referral_tests {
- use crate::{ContractError, QuorumCreditContract, QuorumCreditContractClient};
+ use crate::{QuorumCreditContract, QuorumCreditContractClient};
use soroban_sdk::{
testutils::{Address as _, Ledger},
token::{StellarAssetClient, TokenClient},
@@ -10,7 +10,6 @@ mod referral_tests {
struct Setup {
env: Env,
client: QuorumCreditContractClient<'static>,
- contract_id: Address,
token: Address,
admin: Address,
}
@@ -33,7 +32,12 @@ mod referral_tests {
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, contract_id, token: token_id.address(), admin }
+ Setup {
+ env,
+ client,
+ token: token_id.address(),
+ admin,
+ }
}
fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
@@ -68,8 +72,7 @@ mod referral_tests {
s.client.repay(&borrower, &102_000);
// Referral bonus = 1% of 100_000 = 1_000.
- let referrer_balance = TokenClient::new(&s.env, &s.token)
- .balance(&referrer);
+ let referrer_balance = TokenClient::new(&s.env, &s.token).balance(&referrer);
assert_eq!(referrer_balance, 1_000);
}
@@ -132,8 +135,7 @@ mod referral_tests {
s.client.repay(&borrower, &102_000);
// 2% of 100_000 = 2_000.
- let referrer_balance = TokenClient::new(&s.env, &s.token)
- .balance(&referrer);
+ let referrer_balance = TokenClient::new(&s.env, &s.token).balance(&referrer);
assert_eq!(referrer_balance, 2_000);
}
}
diff --git a/src/repay_multi_voucher_yield_test.rs b/src/repay_multi_voucher_yield_test.rs
index 7172aa0..0a4ea95 100644
--- a/src/repay_multi_voucher_yield_test.rs
+++ b/src/repay_multi_voucher_yield_test.rs
@@ -21,7 +21,6 @@ mod repay_multi_voucher_yield_tests {
struct Setup {
env: Env,
client: QuorumCreditContractClient<'static>,
- contract_id: Address,
token_id: Address,
}
@@ -45,7 +44,7 @@ mod repay_multi_voucher_yield_tests {
// Advance past MIN_VOUCH_AGE (60s)
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, contract_id, token_id: token_id.address() }
+ Setup { env, client, token_id: token_id.address() }
}
fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
@@ -105,7 +104,7 @@ mod repay_multi_voucher_yield_tests {
let loan = s.client.get_loan(&borrower).expect("loan should exist");
assert_eq!(loan.amount, loan_amount);
assert_eq!(loan.total_yield, total_yield);
- assert!(!loan.repaid);
+ assert_eq!(loan.status, crate::LoanStatus::Active);
// 3. Fund borrower and repay
token.mint(&borrower, &total_repay);
@@ -113,7 +112,7 @@ mod repay_multi_voucher_yield_tests {
// 4. Assertions
let repaid_loan = s.client.get_loan(&borrower).expect("loan still exists");
- assert!(repaid_loan.repaid, "loan should be marked repaid");
+ assert_eq!(repaid_loan.status, crate::LoanStatus::Repaid, "loan should be marked repaid");
// Each voucher receives stake + proportional yield
// final_balance = 0 (post-vouch) + stake + yield_i == initial + yield_i
diff --git a/src/request_loan_insufficient_stake_test.rs b/src/request_loan_insufficient_stake_test.rs
index b8dafac..5fd7662 100644
--- a/src/request_loan_insufficient_stake_test.rs
+++ b/src/request_loan_insufficient_stake_test.rs
@@ -2,7 +2,9 @@
mod request_loan_insufficient_stake_tests {
use crate::errors::ContractError;
use crate::{QuorumCreditContract, QuorumCreditContractClient};
- use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, Address, Env, String, Vec};
+ use soroban_sdk::{
+ testutils::Address as _, token::StellarAssetClient, Address, Env, String, Vec,
+ };
fn setup(env: &Env) -> (Address, Address, Address, Address) {
let deployer = Address::generate(env);
diff --git a/src/security_fixes_test.rs b/src/security_fixes_test.rs
index 53181db..4612906 100644
--- a/src/security_fixes_test.rs
+++ b/src/security_fixes_test.rs
@@ -1,5 +1,5 @@
/// Security Fixes Tests
-///
+///
/// Tests for:
/// - Issue 108: Prevent Borrower from Repaying Another Borrower's Loan
/// - Issue 109: Add Slash Proposal Confirmation Window
@@ -7,7 +7,7 @@
/// - Issue 114: Add Invariant Tests — Total Outflow Never Exceeds Total Inflow
#[cfg(test)]
mod security_fixes_tests {
- use crate::{ContractError, DataKey, QuorumCreditContract, QuorumCreditContractClient};
+ use crate::{DataKey, QuorumCreditContract, QuorumCreditContractClient};
use soroban_sdk::{
testutils::{Address as _, Ledger},
token::StellarAssetClient,
@@ -64,38 +64,46 @@ mod security_fixes_tests {
#[test]
fn test_borrower_cannot_repay_another_borrower_loan() {
let s = setup();
-
+
// Create two borrowers
let borrower_a = Address::generate(&s.env);
let borrower_b = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
// Setup vouches for both borrowers
do_vouch(&s, &voucher, &borrower_a, &500_000);
do_vouch(&s, &voucher, &borrower_b, &500_000);
-
+
// Request loans for both
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower_a, &1_000_000);
token.mint(&borrower_b, &1_000_000);
-
+
let purpose = String::from_str(&s.env, "business");
- s.client.request_loan(&borrower_a, &100_000, &500_000, &purpose, &s.token_id);
- s.client.request_loan(&borrower_b, &100_000, &500_000, &purpose, &s.token_id);
-
+ s.client
+ .request_loan(&borrower_a, &100_000, &500_000, &purpose, &s.token_id);
+ s.client
+ .request_loan(&borrower_b, &100_000, &500_000, &purpose, &s.token_id);
+
// Verify both loans exist
assert!(s.client.get_loan(&borrower_a).is_some());
assert!(s.client.get_loan(&borrower_b).is_some());
-
+
// Borrower A tries to repay Borrower B's loan by calling repay with B's address
// This should fail because we try to get A's active loan, not B's
let result = s.client.try_repay(&borrower_a, &50_000);
- assert!(result.is_ok(), "Borrower A should be able to repay their own loan");
-
+ assert!(
+ result.is_ok(),
+ "Borrower A should be able to repay their own loan"
+ );
+
// Now verify the loan was actually repaid (via get_loan showing updated amount_repaid)
let loan_a = s.client.get_loan(&borrower_a);
assert!(loan_a.is_some());
- assert!(loan_a.unwrap().amount_repaid > 0, "Loan should have been repaid");
+ assert!(
+ loan_a.unwrap().amount_repaid > 0,
+ "Loan should have been repaid"
+ );
}
/// Test that cross-loan repayment attempts are blocked.
@@ -103,34 +111,35 @@ mod security_fixes_tests {
#[test]
fn test_cross_borrower_attack_prevented() {
let s = setup();
-
+
let borrower_a = Address::generate(&s.env);
let borrower_b = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
// Setup
do_vouch(&s, &voucher, &borrower_a, &500_000);
do_vouch(&s, &voucher, &borrower_b, &500_000);
-
+
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower_a, &500_000);
token.mint(&borrower_b, &500_000);
-
+
let purpose = String::from_str(&s.env, "test");
- s.client.request_loan(&borrower_a, &50_000, &500_000, &purpose, &s.token_id);
- s.client.request_loan(&borrower_b, &50_000, &500_000, &purpose, &s.token_id);
-
+ s.client
+ .request_loan(&borrower_a, &50_000, &500_000, &purpose, &s.token_id);
+ s.client
+ .request_loan(&borrower_b, &50_000, &500_000, &purpose, &s.token_id);
+
let loan_a_before = s.client.get_loan(&borrower_a).unwrap();
let loan_b_before = s.client.get_loan(&borrower_b).unwrap();
-
+
// Borrower A makes a payment
s.client.repay(&borrower_a, &25_000).ok();
-
+
// Verify B's loan was NOT affected
let loan_b_after = s.client.get_loan(&borrower_b).unwrap();
assert_eq!(
- loan_b_before.amount_repaid,
- loan_b_after.amount_repaid,
+ loan_b_before.amount_repaid, loan_b_after.amount_repaid,
"Borrower B's loan repayment should not be affected by A's payment"
);
}
@@ -141,40 +150,43 @@ mod security_fixes_tests {
#[test]
fn test_slash_balance_prevents_fund_leakage() {
let s = setup();
-
+
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
do_vouch(&s, &voucher, &borrower, &1_000_000);
-
+
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower, &500_000);
-
+
let purpose = String::from_str(&s.env, "test");
- s.client.request_loan(&borrower, &100_000, &500_000, &purpose, &s.token_id);
-
+ s.client
+ .request_loan(&borrower, &100_000, &500_000, &purpose, &s.token_id);
+
// Get initial slash balance
let initial_slash_balance: i128 = s.env.as_contract(&s.contract_id, || {
- s.env.storage()
+ s.env
+ .storage()
.instance()
.get(&DataKey::SlashTreasury)
.unwrap_or(0)
});
-
+
assert_eq!(initial_slash_balance, 0, "Slash balance should start at 0");
-
+
// Vote to slash the loan
let result = s.client.try_vote_slash(&voucher, &borrower, &true);
assert!(result.is_ok() || result.is_err(), "Vote should complete");
-
+
// After slash, balance should be updated
let slash_balance_after: i128 = s.env.as_contract(&s.contract_id, || {
- s.env.storage()
+ s.env
+ .storage()
.instance()
.get(&DataKey::SlashTreasury)
.unwrap_or(0)
});
-
+
assert!(
slash_balance_after >= 0,
"Slash balance should never be negative"
@@ -185,30 +197,28 @@ mod security_fixes_tests {
#[test]
fn test_outflow_never_exceeds_inflow() {
let s = setup();
-
+
// Track inflows and outflows
- let mut total_inflow: i128 = 0;
+ let mut total_inflow: i128 = 100_000_000; // Initial contract funding
let mut total_outflow: i128 = 0;
-
- // Initial contract funding is an inflow
- total_inflow = 100_000_000;
-
+
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
do_vouch(&s, &voucher, &borrower, &1_000_000);
total_inflow += 1_000_000; // Voucher stakes their tokens
-
+
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower, &500_000);
total_inflow += 500_000; // Borrower gets tokens
-
+
// Request a loan (outflow to borrower)
let loan_amount = 100_000;
let purpose = String::from_str(&s.env, "test");
- s.client.request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
+ s.client
+ .request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
total_outflow += loan_amount;
-
+
// Verify invariant: outflow <= inflow
assert!(
total_outflow <= total_inflow,
@@ -222,34 +232,38 @@ mod security_fixes_tests {
#[test]
fn test_yield_distribution_respects_invariant() {
let s = setup();
-
+
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
do_vouch(&s, &voucher, &borrower, &1_000_000);
-
+
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower, &500_000);
-
+
let purpose = String::from_str(&s.env, "test");
let loan_amount = 100_000;
- s.client.request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
-
+ s.client
+ .request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
+
// Get the loan to check yield
let loan = s.client.get_loan(&borrower).unwrap();
let total_obligation = loan.amount + loan.total_yield;
-
+
// Advance time past deadline
- s.env.ledger().with_mut(|l| l.timestamp = 31 * 24 * 60 * 60 + 200);
-
+ s.env
+ .ledger()
+ .with_mut(|l| l.timestamp = 31 * 24 * 60 * 60 + 200);
+
// If we could claim it as defaulted, total_obligation should not exceed contract balance
let contract_balance: i128 = s.env.as_contract(&s.contract_id, || {
- s.env.storage()
+ s.env
+ .storage()
.instance()
.get(&DataKey::SlashTreasury)
.unwrap_or(0)
});
-
+
// This is part of the invariant - total payments should not exceed collected funds
assert!(
total_obligation <= 100_000_000 + 1_000_000,
@@ -258,26 +272,27 @@ mod security_fixes_tests {
}
// ── Issue 109: Add Slash Proposal Confirmation Window ──
-
+
/// Test that slash requires proposal and delay (timelock pattern).
/// Currently this tests the infrastructure; full implementation comes next.
#[test]
fn test_slash_proposal_structure_exists() {
let s = setup();
-
+
// Verify that Timelock data structure exists in types
// This is a compile-time test - if Timelock types are missing, this won't compile
let borrower = Address::generate(&s.env);
-
+
// Once propose_slash is implemented, we should test:
// 1. propose_slash creates a proposal
// 2. Cannot execute before delay
// 3. Can execute after delay
// 4. Proposal can be cancelled
-
+
// For now, verify the data key exists
let _timelock_counter: u64 = s.env.as_contract(&s.contract_id, || {
- s.env.storage()
+ s.env
+ .storage()
.instance()
.get(&DataKey::TimelockCounter)
.unwrap_or(0)
@@ -289,51 +304,53 @@ mod security_fixes_tests {
#[test]
fn test_borrower_cannot_vouch_for_self() {
let s = setup();
-
+
let user = Address::generate(&s.env);
let stake = 500_000;
-
+
// Mint tokens to the user
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&user, &stake);
-
+
// Attempt to vouch for self should panic
let result = s.client.try_vouch(&user, &user, &stake, &s.token_id);
-
+
// The assertion should cause a panic, which results in an error
- assert!(result.is_err(), "Self-vouch should panic and return an error");
+ assert!(
+ result.is_err(),
+ "Self-vouch should panic and return an error"
+ );
}
// ── Issue 114: Add Invariant Tests ──
-
+
/// Property: Total stake in never decreases without explicit withdrawal
#[test]
fn test_stake_conservation_invariant() {
let s = setup();
-
+
let borrower = Address::generate(&s.env);
let voucher1 = Address::generate(&s.env);
let voucher2 = Address::generate(&s.env);
-
+
// Initial stakes
let stake1 = 500_000;
let stake2 = 300_000;
-
+
do_vouch(&s, &voucher1, &borrower, stake1);
do_vouch(&s, &voucher2, &borrower, stake2);
-
+
let total_initial_stake = stake1 + stake2;
-
+
// Verify we can retrieve vouches
let vouches = s.client.get_vouches(&borrower);
let mut retrieved_total: i128 = 0;
for v in vouches.iter() {
retrieved_total += v.stake;
}
-
+
assert_eq!(
- retrieved_total,
- total_initial_stake,
+ retrieved_total, total_initial_stake,
"Total stake retrieved must equal total stake deposited"
);
}
@@ -342,28 +359,29 @@ mod security_fixes_tests {
#[test]
fn test_repayment_obligation_invariant() {
let s = setup();
-
+
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
do_vouch(&s, &voucher, &borrower, &1_000_000);
-
+
let token = StellarAssetClient::new(&s.env, &s.token_id);
token.mint(&borrower, &500_000);
-
+
let loan_amount = 100_000;
let purpose = String::from_str(&s.env, "test");
- s.client.request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
-
+ s.client
+ .request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
+
let loan = s.client.get_loan(&borrower).unwrap();
let total_obligation = loan.amount + loan.total_yield;
-
+
// Repay partially
let payment = 50_000;
s.client.repay(&borrower, &payment).ok();
-
+
let loan_after = s.client.get_loan(&borrower).unwrap();
-
+
// Invariant: amount_repaid should never exceed total_obligation
assert!(
loan_after.amount_repaid <= total_obligation,
@@ -371,7 +389,7 @@ mod security_fixes_tests {
loan_after.amount_repaid,
total_obligation
);
-
+
// Invariant: amount_repaid should be at least the sum of payments made
assert!(
loan_after.amount_repaid >= payment,
@@ -383,32 +401,27 @@ mod security_fixes_tests {
#[test]
fn test_multiple_loans_preserve_invariant() {
let s = setup();
-
+
let mut total_disbursed: i128 = 0;
let token = StellarAssetClient::new(&s.env, &s.token_id);
-
+
// Create multiple borrowers with loans
for i in 0..3 {
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
-
+
do_vouch(&s, &voucher, &borrower, &500_000);
-
+
token.mint(&borrower, &100_000);
-
+
let loan_amount = 50_000 + (i as i128 * 10_000);
let purpose = String::from_str(&s.env, &format!("loan {}", i));
- s.client.request_loan(
- &borrower,
- &loan_amount,
- &500_000,
- &purpose,
- &s.token_id,
- );
-
+ s.client
+ .request_loan(&borrower, &loan_amount, &500_000, &purpose, &s.token_id);
+
total_disbursed += loan_amount;
}
-
+
// Verify invariant: total disbursed is within contract capacity
assert!(
total_disbursed <= 100_000_000,
diff --git a/src/slash_auth_test.rs b/src/slash_auth_test.rs
index 82647da..9c6f03f 100644
--- a/src/slash_auth_test.rs
+++ b/src/slash_auth_test.rs
@@ -1,7 +1,9 @@
/// Slash Authorization Tests
///
-/// Verifies that slash panics when called by a non-admin address,
-/// and succeeds when called by a legitimate admin.
+/// Verifies that slash via governance (vote_slash) requires the caller to be
+/// an active voucher for the borrower, and that a non-voucher is rejected.
+/// Also verifies that a legitimate voucher holding majority stake can trigger
+/// an auto-slash when quorum is reached.
#[cfg(test)]
mod slash_auth_tests {
use crate::{QuorumCreditContract, QuorumCreditContractClient};
@@ -15,6 +17,7 @@ mod slash_auth_tests {
env: Env,
client: QuorumCreditContractClient<'static>,
admin: Address,
+ admin_vec: Vec,
token_id: Address,
}
@@ -37,7 +40,13 @@ mod slash_auth_tests {
// Advance past MIN_VOUCH_AGE so vouches are eligible.
env.ledger().with_mut(|l| l.timestamp = 120);
- Setup { env, client, admin, token_id: token_id.address() }
+ Setup {
+ env,
+ client,
+ admin: admin.clone(),
+ admin_vec: admins,
+ token_id: token_id.address(),
+ }
}
fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
@@ -55,40 +64,45 @@ mod slash_auth_tests {
);
}
- /// Calling slash with a non-admin address must be rejected.
- /// require_admin_approval asserts the signer is a registered admin,
- /// so passing a random address causes a host panic.
+ /// A non-voucher calling vote_slash must be rejected with VoucherNotFound.
#[test]
- fn test_slash_panics_when_called_by_non_admin() {
+ fn test_slash_rejected_when_called_by_non_voucher() {
let s = setup();
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
- let non_admin = Address::generate(&s.env);
+ let outsider = Address::generate(&s.env);
do_vouch(&s, &voucher, &borrower, 1_000_000);
do_loan(&s, &borrower);
- // Pass the non-admin as the sole signer — must fail.
- let non_admin_signers = Vec::from_array(&s.env, [non_admin.clone()]);
- let result = s.client.try_slash(&non_admin_signers, &borrower);
- assert!(result.is_err(), "slash must be rejected when called by a non-admin address");
+ let result = s.client.try_vote_slash(&outsider, &borrower, &true);
+ assert!(
+ result.is_err(),
+ "vote_slash must be rejected when called by a non-voucher"
+ );
}
- /// Calling slash with the registered admin must succeed.
+ /// A voucher holding 100% of stake triggers auto-slash when quorum is met.
+ /// After slash the loan status must be Defaulted.
#[test]
- fn test_slash_succeeds_when_called_by_admin() {
+ fn test_slash_succeeds_when_voucher_reaches_quorum() {
let s = setup();
let borrower = Address::generate(&s.env);
let voucher = Address::generate(&s.env);
+ // Set quorum to 1 bps so a single voucher vote triggers slash immediately.
+ s.client.set_slash_vote_quorum(&s.admin_vec, &1);
+
do_vouch(&s, &voucher, &borrower, 1_000_000);
do_loan(&s, &borrower);
- let admin_signers = Vec::from_array(&s.env, [s.admin.clone()]);
- s.client.slash(&admin_signers, &borrower);
+ s.client.vote_slash(&voucher, &borrower, &true);
// Loan must now be defaulted.
- let loan = s.client.get_loan(&borrower).expect("loan should exist");
- assert!(loan.defaulted, "loan should be marked defaulted after slash");
+ assert_eq!(
+ s.client.loan_status(&borrower),
+ crate::LoanStatus::Defaulted,
+ "loan should be Defaulted after slash quorum reached"
+ );
}
}
diff --git a/src/slash_multi_voucher_test.rs b/src/slash_multi_voucher_test.rs
index beae61c..183e2b7 100644
--- a/src/slash_multi_voucher_test.rs
+++ b/src/slash_multi_voucher_test.rs
@@ -1,128 +1,136 @@
-/// Slash Multi-Voucher Test (Issue #139)
-///
-/// 1. 3 vouchers: stakes 301_000, 200_000, 100_001 (total=601_001)
-/// 2. One voucher votes YES (50% stake >= 50% quorum) → auto-slash
-/// 3. slash_amount = stake * 5000 / 10000 (trunc):
-/// v1: 301000 → slash=150500, remaining=150500
-/// v2: 200000 → 100000, 100000
-/// v3: 100001 → 50000, 50001 (trunc)
-/// 4. treasury += 300500
-/// 5. Each final_balance = initial_mint - slash_amount (net 50% loss)
-
-#[cfg(test)]
-mod slash_multi_voucher_tests {
- use crate::{QuorumCreditContract, QuorumCreditContractClient, LoanStatus};
- use soroban_sdk::{
- testutils::{Address as _, Ledger},
- token::StellarAssetClient,
- Address, Env, String, Vec,
- };
-
- struct Setup {
- env: Env,
- client: QuorumCreditContractClient<'static>,
- contract_id: Address,
- token_id: Address,
- }
-
- fn setup() -> Setup {
- let env = Env::default();
- env.mock_all_auths();
-
- let deployer = Address::generate(&env);
- let admin = Address::generate(&env);
- let admins = Vec::from_array(&env, [admin.clone()]);
-
- let token_id = env.register_stellar_asset_contract_v2(admin.clone());
- let contract_id = env.register_contract(None, QuorumCreditContract);
-
- // Fund contract
- StellarAssetClient::new(&env, &token_id.address()).mint(&contract_id, &10_000_000);
-
- let client = QuorumCreditContractClient::new(&env, &contract_id);
- client.initialize(&deployer, &admins, &1, &token_id.address());
-
- // Advance past MIN_VOUCH_AGE (60s)
- env.ledger().with_mut(|l| l.timestamp = 120);
-
- Setup { env, client, contract_id, token_id: token_id.address() }
- }
-
- fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
- let token = StellarAssetClient::new(&s.env, &s.token_id);
- token.mint(voucher, &stake);
- s.client.vouch(voucher, borrower, &stake, &s.token_id);
- }
-
- fn purpose(env: &Env) -> String {
- String::from_str(env, "slash multi-voucher test")
- }
-
- #[test]
- fn test_slash_multi_voucher_all_lose_50_percent() {
- let s = setup();
- let borrower = Address::generate(&s.env);
- let voucher1 = Address::generate(&s.env); // 301_000 → slash=150_500
- let voucher2 = Address::generate(&s.env); // 200_000 → 100_000
- let voucher3 = Address::generate(&s.env); // 100_001 → 50_000
-
- let stakes = [301_000i128, 200_000i128, 100_001i128];
- let vouchers = [&voucher1, &voucher2, &voucher3];
- let total_stake = stakes.iter().sum::();
- let loan_amount = 100_000i128;
- let slash_bps = 5_000i128;
- let expected_slash1 = stakes[0] * slash_bps / 10_000; // 150500
- let expected_slash2 = stakes[1] * slash_bps / 10_000; // 100000
- let expected_slash3 = stakes[2] * slash_bps / 10_000; // 50000 (trunc)
- let total_slash = expected_slash1 + expected_slash2 + expected_slash3; // 300500
- let token = StellarAssetClient::new(&s.env, &s.token_id);
-
- // Record initial balances (minted stakes)
- let mut initial_bals = [0i128; 3];
- for i in 0..3 {
- token.mint(vouchers[i], &stakes[i]);
- initial_bals[i] = token.balance(vouchers[i]);
- assert_eq!(initial_bals[i], stakes[i]);
- }
-
- // 1. Vouch
- do_vouch(&s, vouchers[0], &borrower, stakes[0]);
- do_vouch(&s, vouchers[1], &borrower, stakes[1]);
- do_vouch(&s, vouchers[2], &borrower, stakes[2]);
-
- // Post-vouch balances = 0
- for i in 0..3 {
- assert_eq!(token.balance(vouchers[i]), 0);
- }
-
- let vouched = s.client.total_vouched(&borrower).unwrap();
- assert_eq!(vouched, total_stake);
-
- // 2. Request loan
- s.client.request_loan(&borrower, &loan_amount, &total_stake, &purpose(&s.env), &s.token_id);
-
- let loan = s.client.get_loan(&borrower).expect("loan exists");
- assert_eq!(loan.amount, loan_amount);
- assert!(!loan.defaulted);
-
- // 3. Voucher1 votes YES (~50% stake >= 50% quorum) → auto-slash
- s.client.vote_slash(&voucher1, &borrower, &true);
-
- // 4. Assertions
- assert_eq!(s.client.loan_status(&borrower), LoanStatus::Defaulted);
-
- let vote = s.client.get_slash_vote(&borrower).unwrap();
- assert!(vote.executed);
-
- assert_eq!(s.client.get_slash_treasury_balance(), total_slash);
-
- // Vouches cleared
- assert!(s.client.get_vouches(&borrower).is_none());
-
- // Each final balance = initial - slash (net 50% loss)
- assert_eq!(token.balance(&voucher1), initial_bals[0] - expected_slash1);
- assert_eq!(token.balance(&voucher2), initial_bals[1] - expected_slash2);
- assert_eq!(token.balance(&voucher3), initial_bals[2] - expected_slash3);
- }
-}
-
+/// Slash Multi-Voucher Test (Issue #139)
+///
+/// 1. 3 vouchers: stakes 301_000, 200_000, 100_001 (total=601_001)
+/// 2. One voucher votes YES (50% stake >= 50% quorum) → auto-slash
+/// 3. slash_amount = stake * 5000 / 10000 (trunc):
+/// v1: 301000 → slash=150500, remaining=150500
+/// v2: 200000 → 100000, 100000
+/// v3: 100001 → 50000, 50001 (trunc)
+/// 4. treasury += 300500
+/// 5. Each final_balance = initial_mint - slash_amount (net 50% loss)
+
+#[cfg(test)]
+mod slash_multi_voucher_tests {
+ use crate::{LoanStatus, QuorumCreditContract, QuorumCreditContractClient};
+ use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ token::StellarAssetClient,
+ Address, Env, String, Vec,
+ };
+
+ struct Setup {
+ env: Env,
+ client: QuorumCreditContractClient<'static>,
+ token_id: Address,
+ }
+
+ fn setup() -> Setup {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let deployer = Address::generate(&env);
+ let admin = Address::generate(&env);
+ let admins = Vec::from_array(&env, [admin.clone()]);
+
+ let token_id = env.register_stellar_asset_contract_v2(admin.clone());
+ let contract_id = env.register_contract(None, QuorumCreditContract);
+
+ // Fund contract
+ StellarAssetClient::new(&env, &token_id.address()).mint(&contract_id, &10_000_000);
+
+ let client = QuorumCreditContractClient::new(&env, &contract_id);
+ client.initialize(&deployer, &admins, &1, &token_id.address());
+
+ // Advance past MIN_VOUCH_AGE (60s)
+ env.ledger().with_mut(|l| l.timestamp = 120);
+
+ Setup {
+ env,
+ client,
+ token_id: token_id.address(),
+ }
+ }
+
+ fn do_vouch(s: &Setup, voucher: &Address, borrower: &Address, stake: i128) {
+ let token = StellarAssetClient::new(&s.env, &s.token_id);
+ token.mint(voucher, &stake);
+ s.client.vouch(voucher, borrower, &stake, &s.token_id);
+ }
+
+ fn purpose(env: &Env) -> String {
+ String::from_str(env, "slash multi-voucher test")
+ }
+
+ #[test]
+ fn test_slash_multi_voucher_all_lose_50_percent() {
+ let s = setup();
+ let borrower = Address::generate(&s.env);
+ let voucher1 = Address::generate(&s.env); // 301_000 → slash=150_500
+ let voucher2 = Address::generate(&s.env); // 200_000 → 100_000
+ let voucher3 = Address::generate(&s.env); // 100_001 → 50_000
+
+ let stakes = [301_000i128, 200_000i128, 100_001i128];
+ let vouchers = [&voucher1, &voucher2, &voucher3];
+ let total_stake = stakes.iter().sum::();
+ let loan_amount = 100_000i128;
+ let slash_bps = 5_000i128;
+ let expected_slash1 = stakes[0] * slash_bps / 10_000; // 150500
+ let expected_slash2 = stakes[1] * slash_bps / 10_000; // 100000
+ let expected_slash3 = stakes[2] * slash_bps / 10_000; // 50000 (trunc)
+ let total_slash = expected_slash1 + expected_slash2 + expected_slash3; // 300500
+ let token = StellarAssetClient::new(&s.env, &s.token_id);
+
+ // Record initial balances (minted stakes)
+ let mut initial_bals = [0i128; 3];
+ for i in 0..3 {
+ token.mint(vouchers[i], &stakes[i]);
+ initial_bals[i] = token.balance(vouchers[i]);
+ assert_eq!(initial_bals[i], stakes[i]);
+ }
+
+ // 1. Vouch
+ do_vouch(&s, vouchers[0], &borrower, stakes[0]);
+ do_vouch(&s, vouchers[1], &borrower, stakes[1]);
+ do_vouch(&s, vouchers[2], &borrower, stakes[2]);
+
+ // Post-vouch balances = 0
+ for i in 0..3 {
+ assert_eq!(token.balance(vouchers[i]), 0);
+ }
+
+ let vouched = s.client.total_vouched(&borrower).unwrap();
+ assert_eq!(vouched, total_stake);
+
+ // 2. Request loan
+ s.client.request_loan(
+ &borrower,
+ &loan_amount,
+ &total_stake,
+ &purpose(&s.env),
+ &s.token_id,
+ );
+
+ let loan = s.client.get_loan(&borrower).expect("loan exists");
+ assert_eq!(loan.amount, loan_amount);
+ assert_eq!(loan.status, crate::LoanStatus::Active);
+
+ // 3. Voucher1 votes YES (~50% stake >= 50% quorum) → auto-slash
+ s.client.vote_slash(&voucher1, &borrower, &true);
+
+ // 4. Assertions
+ assert_eq!(s.client.loan_status(&borrower), LoanStatus::Defaulted);
+
+ let vote = s.client.get_slash_vote(&borrower).unwrap();
+ assert!(vote.executed);
+
+ assert_eq!(s.client.get_slash_treasury_balance(), total_slash);
+
+ // Vouches cleared
+ assert!(s.client.get_vouches(&borrower).is_none());
+
+ // Each final balance = initial - slash (net 50% loss)
+ assert_eq!(token.balance(&voucher1), initial_bals[0] - expected_slash1);
+ assert_eq!(token.balance(&voucher2), initial_bals[1] - expected_slash2);
+ assert_eq!(token.balance(&voucher3), initial_bals[2] - expected_slash3);
+ }
+}
diff --git a/src/slash_precision_small_stake_test.rs b/src/slash_precision_small_stake_test.rs
index 83e6a09..fb9c7fe 100644
--- a/src/slash_precision_small_stake_test.rs
+++ b/src/slash_precision_small_stake_test.rs
@@ -14,7 +14,7 @@
#[cfg(test)]
mod slash_precision_tests {
- use crate::{ContractError, QuorumCreditContract, QuorumCreditContractClient, LoanStatus};
+ use crate::{QuorumCreditContract, QuorumCreditContractClient, LoanStatus};
use soroban_sdk::{
testutils::{Address as _, Ledger},
token::StellarAssetClient,
@@ -25,9 +25,7 @@ mod slash_precision_tests {
struct Setup {
env: Env,
client: QuorumCreditContractClient<'static>,
- admin: Address,
token_id: Address,
- contract_id: Address,
}
fn setup() -> Setup {
@@ -53,9 +51,7 @@ mod slash_precision_tests {
Setup {
env,
client,
- admin,
token_id: token_id.address(),
- contract_id,
}
}
@@ -70,7 +66,7 @@ mod slash_precision_tests {
borrower,
&amount,
&threshold,
- &String::from_str(&s.env, \"precision test\"),
+ &String::from_str(&s.env, "precision test"),
&s.token_id,
);
}
@@ -86,8 +82,6 @@ mod slash_precision_tests {
token_client.mint(&voucher, &initial_balance);
let stake = 1_i128;
- let expected_slash = 0_i128; // 1 * 5000 / 10000 = 0
- let expected_remaining = 1_i128;
let expected_treasury_delta = 0_i128;
// 1. Vouch
@@ -126,7 +120,6 @@ mod slash_precision_tests {
let voucher = Address::generate(&s.env);
let token_client = StellarAssetClient::new(&s.env, &s.token_id);
- let initial_balance = token_client.balance(&voucher); // 0
let stake = 10_i128;
let expected_slash = 5_i128; // 10 * 5000 / 10000 = 5
diff --git a/src/types.rs b/src/types.rs
index 86a3c06..8a39ca7 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -62,11 +62,11 @@ pub enum DataKey {
Blacklisted(Address), // borrower → bool permanently banned
VoucherWhitelist(Address), // voucher → bool allowed to vouch
ExtensionConsents(Address), // borrower → Vec vouchers who consented to extension
- SlashVote(Address), // borrower → SlashVoteRecord
- SlashVoteQuorum, // u32 quorum in basis points (e.g. 5000 = 50%)
- ReferredBy(Address), // borrower → Address of referrer
- ReferralBonusBps, // u32 referral bonus in basis points (default 100 = 1%)
- MaxVouchersPerBorrower, // u32 maximum number of vouchers per borrower (default 50)
+ SlashVote(Address), // borrower → SlashVoteRecord
+ SlashVoteQuorum, // u32 quorum in basis points (e.g. 5000 = 50%)
+ ReferredBy(Address), // borrower → Address of referrer
+ ReferralBonusBps, // u32 referral bonus in basis points (default 100 = 1%)
+ MaxVouchersPerBorrower, // u32 maximum number of vouchers per borrower (default 50)
}
// ── Governance ────────────────────────────────────────────────────────────────
@@ -74,10 +74,10 @@ pub enum DataKey {
#[contracttype]
#[derive(Clone)]
pub struct SlashVoteRecord {
- pub approve_stake: i128, // total stake voting to approve slash
- pub reject_stake: i128, // total stake voting to reject slash
- pub voters: Vec, // addresses that have already voted
- pub executed: bool, // true once slash has been auto-executed
+ pub approve_stake: i128, // total stake voting to approve slash
+ pub reject_stake: i128, // total stake voting to reject slash
+ pub voters: Vec, // addresses that have already voted
+ pub executed: bool, // true once slash has been auto-executed
}
// ── Config ────────────────────────────────────────────────────────────────────
@@ -110,12 +110,12 @@ pub struct LoanRecord {
pub amount_repaid: i128, // cumulative repayments received so far (principal + yield)
pub total_yield: i128, // yield owed to vouchers, locked in at disbursement
pub status: LoanStatus,
- pub created_at: u64, // ledger timestamp
- pub disbursement_timestamp: u64, // ledger timestamp
- pub repayment_timestamp: Option, // set once the loan is fully repaid
- pub deadline: u64, // repayment deadline (ledger timestamp)
+ pub created_at: u64, // ledger timestamp
+ pub disbursement_timestamp: u64, // ledger timestamp
+ pub repayment_timestamp: Option, // set once the loan is fully repaid
+ pub deadline: u64, // repayment deadline (ledger timestamp)
pub loan_purpose: soroban_sdk::String, // borrower-supplied purpose string
- pub token_address: Address, // token used for this loan
+ pub token_address: Address, // token used for this loan
}
#[contracttype]
diff --git a/src/vouch.rs b/src/vouch.rs
index 01f2213..1e3dae2 100644
--- a/src/vouch.rs
+++ b/src/vouch.rs
@@ -1,5 +1,7 @@
use crate::errors::ContractError;
-use crate::helpers::{has_active_loan, require_allowed_token, require_not_paused, require_positive_amount};
+use crate::helpers::{
+ has_active_loan, require_allowed_token, require_not_paused, require_positive_amount,
+};
use crate::types::{DataKey, VouchRecord};
use soroban_sdk::{symbol_short, Address, Env, Vec};
@@ -52,12 +54,7 @@ fn do_vouch(
}
// Rate limiting: enforce cooldown between vouch calls from the same address.
- let _now = env.ledger().timestamp();
- let _last: u64 = env
- .storage()
- .persistent()
- .get(&DataKey::LastVouchTimestamp(voucher.clone()))
- .unwrap_or(0);
+ // (Timestamp recorded at end of function for future cooldown enforcement.)
let mut vouches: Vec = env
.storage()
@@ -78,7 +75,7 @@ fn do_vouch(
.instance()
.get(&DataKey::MaxVouchersPerBorrower)
.unwrap_or(crate::types::DEFAULT_MAX_VOUCHERS_PER_BORROWER);
-
+
if vouches.len() >= max_vouchers_per_borrower {
return Err(ContractError::MaxVouchersPerBorrowerExceeded);
}
@@ -213,7 +210,10 @@ pub fn decrease_stake(
.expect("vouch not found") as u32;
let mut vouch_rec = vouches.get(idx).unwrap();
- assert!(amount <= vouch_rec.stake, "decrease amount exceeds staked amount");
+ assert!(
+ amount <= vouch_rec.stake,
+ "decrease amount exceeds staked amount"
+ );
let token_client = require_allowed_token(&env, &vouch_rec.token)?;
vouch_rec.stake -= amount;
@@ -224,9 +224,13 @@ pub fn decrease_stake(
}
if vouches.is_empty() {
- env.storage().persistent().remove(&DataKey::Vouches(borrower));
+ env.storage()
+ .persistent()
+ .remove(&DataKey::Vouches(borrower));
} else {
- env.storage().persistent().set(&DataKey::Vouches(borrower), &vouches);
+ env.storage()
+ .persistent()
+ .set(&DataKey::Vouches(borrower), &vouches);
}
token_client.transfer(&env.current_contract_address(), &voucher, &amount);
@@ -257,9 +261,13 @@ pub fn withdraw_vouch(env: Env, voucher: Address, borrower: Address) -> Result<(
vouches.remove(idx);
if vouches.is_empty() {
- env.storage().persistent().remove(&DataKey::Vouches(borrower.clone()));
+ env.storage()
+ .persistent()
+ .remove(&DataKey::Vouches(borrower.clone()));
} else {
- env.storage().persistent().set(&DataKey::Vouches(borrower.clone()), &vouches);
+ env.storage()
+ .persistent()
+ .set(&DataKey::Vouches(borrower.clone()), &vouches);
}
let token_client = require_allowed_token(&env, &token_addr)?;
@@ -390,7 +398,6 @@ pub fn voucher_history(env: Env, voucher: Address) -> Vec {
#[cfg(test)]
mod tests {
use super::*;
- use crate::types::DataKey;
use crate::{QuorumCreditContract, QuorumCreditContractClient};
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};
diff --git a/src/vouch_zero_stake_test.rs b/src/vouch_zero_stake_test.rs
index aecae11..5e87a81 100644
--- a/src/vouch_zero_stake_test.rs
+++ b/src/vouch_zero_stake_test.rs
@@ -12,12 +12,7 @@ mod vouch_zero_stake_tests {
.address();
let contract_id = env.register_contract(None, QuorumCreditContract);
let client = QuorumCreditContractClient::new(env, &contract_id);
- client.initialize(
- &deployer,
- &Vec::from_array(env, [admin]),
- &1,
- &token_id,
- );
+ client.initialize(&deployer, &Vec::from_array(env, [admin]), &1, &token_id);
let voucher = Address::generate(env);
StellarAssetClient::new(env, &token_id).mint(&voucher, &1_000_000);
(contract_id, token_id, voucher, Address::generate(env))
diff --git a/src/yield_precision_small_stake_test.rs b/src/yield_precision_small_stake_test.rs
index 73c5089..297fff7 100644
--- a/src/yield_precision_small_stake_test.rs
+++ b/src/yield_precision_small_stake_test.rs
@@ -99,7 +99,7 @@ mod yield_precision_small_stake_tests {
// Loan fully repaid.
let repaid_loan = s.client.get_loan(&borrower).expect("loan should exist after repay");
- assert!(repaid_loan.repaid, "loan should be marked repaid");
+ assert_eq!(repaid_loan.status, crate::LoanStatus::Repaid, "loan should be marked repaid");
// CRITICAL: Voucher receives stake back + 0 yield due to truncation.
// Final balance == initial_balance (stake returned exactly).