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).