diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e10fab90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/contract-fuzz.yml b/.github/workflows/contract-fuzz.yml new file mode 100644 index 00000000..e12ce253 --- /dev/null +++ b/.github/workflows/contract-fuzz.yml @@ -0,0 +1,108 @@ +name: Contract Fuzzing + +on: + pull_request: + branches: [main, dev] + paths: + - "stellar-lend/contracts/**" + - "stellar-lend/fuzz/**" + - "scripts/fuzz/**" + - ".github/workflows/contract-fuzz.yml" + push: + branches: [main, dev] + paths: + - "stellar-lend/contracts/**" + - "stellar-lend/fuzz/**" + - "scripts/fuzz/**" + - ".github/workflows/contract-fuzz.yml" + schedule: + - cron: "23 3 * * 1" + workflow_dispatch: + inputs: + fuzz_seconds: + description: "Seconds per fuzz target" + required: false + default: "1800" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + FUZZ_SECONDS: ${{ github.event.inputs.fuzz_seconds || '1800' }} + +jobs: + fuzz: + name: ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + target: + - lending_critical + - lending_actions + - amm_actions + - bridge_actions + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install clang and llvm + run: | + sudo apt-get update + sudo apt-get install -y clang llvm + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Cache cargo and fuzz artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + stellar-lend/target + stellar-lend/fuzz/target + key: ${{ runner.os }}-contract-fuzz-${{ hashFiles('stellar-lend/Cargo.lock', 'stellar-lend/**/*.toml') }} + restore-keys: | + ${{ runner.os }}-contract-fuzz- + + - name: Install cargo-fuzz + run: | + if ! command -v cargo-fuzz >/dev/null 2>&1; then + cargo +nightly install cargo-fuzz --locked + fi + + - name: Validate corpora + run: bash scripts/fuzz/check_corpus.sh + + - name: List fuzz targets + working-directory: stellar-lend + run: cargo +nightly fuzz list + + - name: Run 30 minute fuzz target + working-directory: stellar-lend + run: | + mkdir -p "fuzz/artifacts/${{ matrix.target }}" + cargo +nightly fuzz run "${{ matrix.target }}" \ + "fuzz/corpus/${{ matrix.target }}" \ + -- \ + -max_total_time="${FUZZ_SECONDS}" \ + -timeout=15 \ + -artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" \ + -print_final_stats=1 + + - name: Generate fuzz coverage report + if: always() + run: bash scripts/fuzz/coverage_report.sh "${{ matrix.target }}" + + - name: Upload fuzz artifacts and coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: fuzz-${{ matrix.target }}-${{ github.run_id }} + path: | + stellar-lend/fuzz/artifacts/${{ matrix.target }} + stellar-lend/fuzz/coverage + if-no-files-found: ignore diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 22383e64..da1537a0 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -1,120 +1,91 @@ -# Smart Contract Fuzzing (Property-Based, Coverage-Guided) +# Smart Contract Fuzzing -This repo uses **coverage-guided fuzzing** (libFuzzer via `cargo-fuzz`) to explore smart-contract edge cases that unit tests rarely hit (state-machine sequencing, time jumps, oracle manipulation, boundary math). +StellarLend uses coverage-guided fuzzing with `cargo-fuzz` and libFuzzer to exercise smart-contract state machines, edge-case accounting, ledger time jumps, oracle manipulation, and protocol invariants. -The fuzz targets live under `stellar-lend/fuzz/`. +The fuzz package lives under `stellar-lend/fuzz/`. -## What is fuzzed +## Targets -Targets (one binary per contract area): +| Target | Scope | +| --- | --- | +| `lending_critical` | Focused lending path coverage for deposit, borrow, repay, liquidate, oracle price shifts, and time jumps | +| `lending_actions` | Broader lending state-machine coverage, including withdraw, pause toggles, oracle writes, and views | +| `amm_actions` | AMM action coverage for swaps, liquidity changes, and pool views | +| `bridge_actions` | Bridge message/action coverage | -- `lending_actions` — state-machine fuzzing for `stellarlend-lending` -- `amm_actions` — action fuzzing for `stellarlend-amm` -- `bridge_actions` — action fuzzing for `bridge` +Each target interprets input as a sequence of fixed-size 32-byte actions defined in `stellar-lend/fuzz/src/encoding.rs`. -Each target interprets the input as a sequence of fixed-size **32-byte actions**. This gives libFuzzer structure to mutate while still keeping the harness lightweight. +## Strategy -## Strategy (high level) +The harnesses map action bytes to protocol calls and assert invariants after state transitions. Lending fuzzing registers a fuzz-only oracle contract so inputs can mutate per-asset prices while the target calls collateral, debt, health-factor, and liquidation paths. -### Action model (protocol-specific) +Performance guardrails: -For performance and coverage, the fuzzer uses a compact action encoding: +- Inputs are capped to a bounded number of actions. +- Ledger time deltas are capped per step. +- Harnesses use `try_*` calls so expected rejections keep exploration moving. +- `lending_critical` uses positive bounded amounts and over-collateralized borrow attempts to reach debt and liquidation states quickly. -- One input file = `N` actions -- One action = 32 bytes (see `stellar-lend/fuzz/src/encoding.rs`) - -Each harness maps those bytes to protocol calls (deposit/borrow/repay/withdraw, swaps/liquidity, bridge operations) and validates basic invariants after the sequence. - -### Time-dependent properties - -The harnesses mutate ledger time via `env.ledger().with_mut(|li| li.timestamp = ...)` to exercise: - -- interest accrual and timestamp math -- deadline / timeout style checks - -### Oracle manipulation during fuzzing - -The lending fuzzer registers a fuzz-only oracle contract (`FuzzOracle`) and can change per-asset prices on the fly. - -This specifically targets view logic (collateral value, debt value, health factor) under adversarial price changes. - -### Large state spaces - -The `*_actions` targets are state-machine fuzzers. Inputs encode *sequences*, not single calls, so the fuzzer can reach deep interleavings: - -- borrow → time jump → repay partial → withdraw → view reads -- pause toggles + retries -- repeated protocol config changes - -### Performance guardrails - -To keep fuzzing fast and CI-friendly: - -- actions per input are bounded -- time deltas are capped per step -- harnesses use `try_*` contract calls where possible to avoid panics and keep exploration going - -## Custom mutators - -`lending_actions` implements a **custom libFuzzer mutator** in `stellar-lend/fuzz/fuzz_targets/lending_actions.rs`: - -- keeps inputs aligned to 32-byte action boundaries -- performs small, field-aware mutations (kind/user/asset selectors, amount bytes, time bytes) -- occasionally grows/shrinks by one full action to explore different sequence lengths - -This is intentionally protocol-aware: it helps libFuzzer spend more time exploring meaningful contract state transitions rather than breaking the input structure. - -## Corpus management +## Corpus Management Seed corpora are checked into git: +- `stellar-lend/fuzz/corpus/lending_critical/` - `stellar-lend/fuzz/corpus/lending_actions/` - `stellar-lend/fuzz/corpus/amm_actions/` - `stellar-lend/fuzz/corpus/bridge_actions/` -A minimum corpus size is enforced by `scripts/fuzz/check_corpus.sh` (default: **10** files per target; configurable via `MIN_CORPUS_FILES`). +`scripts/fuzz/check_corpus.sh` enforces a minimum of 10 non-empty files per target. Override with `MIN_CORPUS_FILES` when needed. -## Running fuzzers locally +## Run Locally -Prereqs: +Prerequisites: -- Rust nightly (`rustup toolchain install nightly`) -- `cargo-fuzz` (`cargo +nightly install cargo-fuzz`) -- LLVM/clang toolchain (required by libFuzzer) +- Rust nightly: `rustup toolchain install nightly` +- cargo-fuzz: `cargo +nightly install cargo-fuzz --locked` +- LLVM/clang for libFuzzer -Run: +Run focused lending fuzzing: ```bash cd stellar-lend -cargo +nightly fuzz run lending_actions -- -runs=50000 -timeout=5 +cargo +nightly fuzz run lending_critical fuzz/corpus/lending_critical -- -max_total_time=1800 -timeout=15 ``` -Other targets: +Run smoke fuzzing for every target: ```bash -cd stellar-lend -cargo +nightly fuzz run amm_actions -- -runs=50000 -timeout=5 -cargo +nightly fuzz run bridge_actions -- -runs=50000 -timeout=5 +bash scripts/fuzz/run_ci_smoke.sh ``` -## Reproducing a crash +## Crash Triage -When a crash is found, libFuzzer stores a reproducer in: +Replay a crash: -`stellar-lend/fuzz/artifacts//` +```bash +./scripts/fuzz/repro.sh lending_critical stellar-lend/fuzz/artifacts/lending_critical/crash-* -- -runs=1 +``` -Use: +Promote a crash to a regression corpus fixture and write replay notes: ```bash -./scripts/fuzz/repro.sh lending_actions stellar-lend/fuzz/artifacts/lending_actions/crash-* -- -runs=1 +./scripts/fuzz/triage_crash.sh lending_critical stellar-lend/fuzz/artifacts/lending_critical/crash-* ``` -## CI integration +The triage script copies the input into `stellar-lend/fuzz/corpus//regression_` and writes a markdown report under `stellar-lend/fuzz/regressions//`. + +## Coverage Reports + +Generate coverage logs and a summary table: + +```bash +./scripts/fuzz/coverage_report.sh lending_critical lending_actions +``` -CI runs a smoke fuzz pass (bounded number of executions per target) via: +The report is written to `stellar-lend/fuzz/coverage/` and uploaded by CI with fuzz artifacts. -- `scripts/fuzz/check_corpus.sh` -- `scripts/fuzz/run_ci_smoke.sh` +## CI -This keeps the pipeline deterministic while still exercising the fuzz harnesses continuously. +The regular CI pipeline keeps a quick smoke fuzz pass in `scripts/fuzz/run_ci_smoke.sh`. +The dedicated long-running workflow is `.github/workflows/contract-fuzz.yml`. It runs each target with `-max_total_time=1800`, uploads crash artifacts, and generates per-target coverage reports. diff --git a/scripts/fuzz/check_corpus.sh b/scripts/fuzz/check_corpus.sh old mode 100644 new mode 100755 index 599df787..f1e80d4b --- a/scripts/fuzz/check_corpus.sh +++ b/scripts/fuzz/check_corpus.sh @@ -6,7 +6,7 @@ FUZZ_DIR="$ROOT_DIR/stellar-lend/fuzz" MIN_FILES="${MIN_CORPUS_FILES:-10}" -targets=(lending_actions amm_actions bridge_actions) +targets=(lending_critical lending_actions amm_actions bridge_actions) for t in "${targets[@]}"; do d="$FUZZ_DIR/corpus/$t" diff --git a/scripts/fuzz/coverage_report.sh b/scripts/fuzz/coverage_report.sh new file mode 100755 index 00000000..fd213096 --- /dev/null +++ b/scripts/fuzz/coverage_report.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FUZZ_DIR="$ROOT_DIR/stellar-lend/fuzz" +COVERAGE_DIR="$FUZZ_DIR/coverage" + +if [[ $# -gt 0 ]]; then + targets=("$@") +else + targets=(lending_critical lending_actions amm_actions bridge_actions) +fi + +mkdir -p "$COVERAGE_DIR" + +cd "$ROOT_DIR/stellar-lend" + +summary="$COVERAGE_DIR/summary.md" +{ + echo "# Fuzz Coverage Report" + echo + echo "| Target | Corpus files | Coverage command |" + echo "| --- | ---: | --- |" +} > "$summary" + +for target in "${targets[@]}"; do + corpus_dir="$FUZZ_DIR/corpus/$target" + count="$(find "$corpus_dir" -maxdepth 1 -type f | wc -l | tr -d ' ')" + target_log="$COVERAGE_DIR/$target.log" + + set +e + cargo +nightly fuzz coverage "$target" "fuzz/corpus/$target" > "$target_log" 2>&1 + status=$? + set -e + + if [[ "$status" -ne 0 ]]; then + echo "::warning::coverage generation failed for $target; see $target_log" + fi + + echo "| \`$target\` | $count | \`cargo +nightly fuzz coverage $target fuzz/corpus/$target\` |" >> "$summary" +done + +echo "Coverage summary written to $summary" diff --git a/scripts/fuzz/repro.sh b/scripts/fuzz/repro.sh old mode 100644 new mode 100755 diff --git a/scripts/fuzz/run_ci_smoke.sh b/scripts/fuzz/run_ci_smoke.sh old mode 100644 new mode 100755 index f4afb971..ac718cf8 --- a/scripts/fuzz/run_ci_smoke.sh +++ b/scripts/fuzz/run_ci_smoke.sh @@ -11,7 +11,7 @@ bash "$ROOT_DIR/scripts/fuzz/check_corpus.sh" cd "$ROOT_DIR/stellar-lend" -targets=(lending_actions amm_actions bridge_actions) +targets=(lending_critical lending_actions amm_actions bridge_actions) for t in "${targets[@]}"; do echo "Running fuzz smoke: $t (runs=$RUNS timeout=${TIMEOUT}s)" diff --git a/scripts/fuzz/triage_crash.sh b/scripts/fuzz/triage_crash.sh new file mode 100755 index 00000000..c8b46341 --- /dev/null +++ b/scripts/fuzz/triage_crash.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + echo "Example: $0 lending_critical stellar-lend/fuzz/artifacts/lending_critical/crash-*" + exit 2 +fi + +TARGET="$1" +CRASH_FILE="$2" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FUZZ_DIR="$ROOT_DIR/stellar-lend/fuzz" + +if [[ ! -f "$CRASH_FILE" ]]; then + echo "Crash file not found: $CRASH_FILE" + exit 1 +fi + +SHA="$(sha256sum "$CRASH_FILE" | awk '{print substr($1,1,12)}')" +REGRESSION_DIR="$FUZZ_DIR/corpus/$TARGET" +REPORT_DIR="$FUZZ_DIR/regressions/$TARGET" +REGRESSION_FILE="$REGRESSION_DIR/regression_$SHA" +REPORT_FILE="$REPORT_DIR/regression_$SHA.md" + +mkdir -p "$REGRESSION_DIR" "$REPORT_DIR" +cp "$CRASH_FILE" "$REGRESSION_FILE" + +cat > "$REPORT_FILE" < i128 { + i128::from(raw.unsigned_abs() % 1_000_000_000).max(1) + } + + fn liquidate_from_action(&self, client: &LendingContractClient<'_>, action: ActionBytes) { + let liquidator = self.user(action.user()); + let borrower = self.user(action.asset_a()); + let debt_asset = self.asset(action.asset_b()); + let collateral_asset = self.asset(action.u32_param() as u8); + let amt = Self::bounded_amount(action.i64_a()); + let _ = client.try_liquidate(&liquidator, &borrower, &debt_asset, &collateral_asset, &amt); + } + fn act(&self, client: &LendingContractClient<'_>, action: ActionBytes) { - match action.kind() % 10 { + match action.kind() % 11 { // 0: deposit(vault) 0 => { let user = self.user(action.user()); @@ -184,13 +197,67 @@ impl LendingHarness { self.set_time_delta(delta); } // 9: call views/getters - _ => { + 9 => { let user = self.user(action.user()); let _ = client.get_user_position(&user); let _ = client.get_health_factor(&user); let _ = client.get_collateral_value(&user); let _ = client.get_debt_value(&user); } + // 10: liquidate + _ => self.liquidate_from_action(client, action), + } + } + + fn act_critical(&self, client: &LendingContractClient<'_>, action: ActionBytes) { + match action.kind() % 6 { + // 0: deposit vault collateral + 0 => { + let user = self.user(action.user()); + let asset = self.asset(action.asset_a()); + let amt = Self::bounded_amount(action.i64_a()); + let _ = client.try_deposit(&user, &asset, &amt); + } + // 1: borrow with over-collateralized input so the fuzzer reaches debt states. + 1 => { + let user = self.user(action.user()); + let debt_asset = self.asset(action.asset_a()); + let collateral_asset = self.asset(action.asset_b()); + let borrow_amt = Self::bounded_amount(action.i64_a()); + let extra_collateral = Self::bounded_amount(action.i64_b()); + let collateral_amt = borrow_amt + .saturating_mul(2) + .saturating_add(extra_collateral); + let _ = client.try_borrow( + &user, + &debt_asset, + &borrow_amt, + &collateral_asset, + &collateral_amt, + ); + } + // 2: repay debt. + 2 => { + let user = self.user(action.user()); + let asset = self.asset(action.asset_a()); + let amt = Self::bounded_amount(action.i64_a()); + let _ = client.try_repay(&user, &asset, &amt); + } + // 3: liquidate debt positions. + 3 => self.liquidate_from_action(client, action), + // 4: mutate oracle prices to stress collateral and liquidation views. + 4 => { + let asset = self.asset(action.asset_a()); + self.set_oracle_price(&asset, action.i64_a()); + } + // 5: advance ledger time and query the accounting views. + _ => { + self.set_time_delta(action.u64_tail() % 604_800); + let user = self.user(action.user()); + let _ = client.get_user_debt(&user); + let _ = client.get_user_position(&user); + let _ = client.get_health_factor(&user); + } } } @@ -224,3 +291,12 @@ pub fn run(data: &[u8]) { } h.assert_invariants(&client); } + +pub fn run_critical(data: &[u8]) { + let h = LendingHarness::new(); + let client = LendingContractClient::new(&h.env, &h.contract_id); + for action in parse_actions(data, MAX_ACTIONS) { + h.act_critical(&client, action); + h.assert_invariants(&client); + } +}