Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
.env
.env.example
test_snapshots/

# Windows installer binaries
*.exe
22 changes: 22 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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! 🚀*
Expand Down
5 changes: 4 additions & 1 deletion src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>, 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);
Expand Down
19 changes: 16 additions & 3 deletions src/double_slash_panic_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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"
);
}
}
12 changes: 10 additions & 2 deletions src/duplicate_loan_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/get_loan_none_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 10 additions & 14 deletions src/governance.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -337,4 +334,3 @@ pub fn get_timelock_proposal(env: Env, proposal_id: u64) -> Option<TimelockPropo
.instance()
.get(&DataKey::Timelock(proposal_id))
}

Loading
Loading