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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
108 changes: 108 additions & 0 deletions .github/workflows/contract-fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
129 changes: 50 additions & 79 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -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/<target>/`
```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/<target>/regression_<sha>` and writes a markdown report under `stellar-lend/fuzz/regressions/<target>/`.

## 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.
2 changes: 1 addition & 1 deletion scripts/fuzz/check_corpus.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions scripts/fuzz/coverage_report.sh
Original file line number Diff line number Diff line change
@@ -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"
Empty file modified scripts/fuzz/repro.sh
100644 → 100755
Empty file.
2 changes: 1 addition & 1 deletion scripts/fuzz/run_ci_smoke.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
53 changes: 53 additions & 0 deletions scripts/fuzz/triage_crash.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 2 ]]; then
echo "Usage: $0 <target> <crash_file>"
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" <<EOF
# Fuzz Regression $SHA

- Target: \`$TARGET\`
- Corpus file: \`stellar-lend/fuzz/corpus/$TARGET/regression_$SHA\`
- Source crash: \`$CRASH_FILE\`

Replay:

\`\`\`bash
cd stellar-lend
cargo +nightly fuzz run $TARGET fuzz/corpus/$TARGET/regression_$SHA -- -runs=1
\`\`\`

Minimize before committing if the file is large:

\`\`\`bash
cd stellar-lend
cargo +nightly fuzz tmin $TARGET fuzz/corpus/$TARGET/regression_$SHA
\`\`\`
EOF

echo "Regression corpus copied to: $REGRESSION_FILE"
echo "Triage report written to: $REPORT_FILE"
Loading