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
65 changes: 65 additions & 0 deletions .github/workflows/echidna.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Echidna

on:
pull_request:
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: echidna-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
NODEJS_VERSION: '20'
ECHIDNA_IMAGE: ghcr.io/crytic/echidna/echidna:v2.3.1

jobs:
echidna:
name: Echidna / ${{ matrix.contract }}
runs-on: ubuntu-latest
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
contract:
- EchidnaStakeRegistryHarness
- EchidnaPriceOracleHarness
- EchidnaPostageStampHarness
- EchidnaRedistributionHarness
- EchidnaRedistributionClaimHarness
- EchidnaSystemHarness

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js and Yarn
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODEJS_VERSION }}
cache: yarn

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Run Echidna
env:
ECHIDNA_CONTRACT: ${{ matrix.contract }}
# 9000s leaves ~30 min for checkout, yarn, compile, and image pull
# inside the 180-minute job cap so a timeout still uploads artifacts.
ECHIDNA_TIMEOUT: '9000'
run: yarn echidna

- name: Upload reproducers and logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: echidna-${{ matrix.contract }}
path: |
echidna/logs/
echidna/corpus/by-contract/
crytic-export/
if-no-files-found: warn
retention-days: 14
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ tenderly.log

# Echidna fuzzing
echidna/corpus/
echidna/logs/
crytic-export/

# Cursor
.cursor/rules/audit-code.mdc
.cursor/rules/audit-code.mdc
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ Harness layout, properties, and troubleshooting are documented in [echidna/READM
yarn echidna
```

CI runs the full Echidna campaign on every pull request. See [echidna/README.md](./echidna/README.md#ci).

## Run

### [Tests](./test)
Expand Down
18 changes: 17 additions & 1 deletion echidna/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,26 @@ yarn echidna # all harnesses; needs Docker
| `seqLen` | 320 | `ECHIDNA_SEQ_LEN` |
| `maxBlockDelay` | 152 | — |
| workers | yaml | `ECHIDNA_WORKERS` |
| seed | random | `ECHIDNA_SEED` |
| timeout (seconds) | none | `ECHIDNA_TIMEOUT` |

Single harness: `ECHIDNA_CONTRACT=EchidnaRedistributionHarness yarn echidna` (also: `EchidnaStakeRegistryHarness`, `EchidnaPriceOracleHarness`, `EchidnaPostageStampHarness`, `EchidnaRedistributionClaimHarness`, `EchidnaSystemHarness`).

Config: `echidna/echidna.yaml` (`ECHIDNA_CONFIG` to override). Corpus/coverage: `echidna/corpus/by-contract/<HarnessName>/` (gitignored). Crytic: `crytic-export/`.
Config: `echidna/echidna.yaml` (`ECHIDNA_CONFIG` to override). Corpus/coverage: `echidna/corpus/by-contract/<HarnessName>/` (gitignored). Logs: `echidna/logs/` (gitignored). Crytic: `crytic-export/`.

## CI

Workflow: [`.github/workflows/echidna.yml`](../.github/workflows/echidna.yml). Runs on every pull request (and manual `workflow_dispatch`).

One matrix job per harness, using the same campaign as local `yarn echidna` (`echidna/echidna.yaml`: `testLimit` 60000, `seqLen` 320). Each job has a 180-minute cap and Echidna `--timeout` 9000s so setup plus fuzzing fit under the GitHub Actions limit and a timeout still uploads artifacts. On failure the workflow uploads `echidna/logs/`, corpus reproducers under `echidna/corpus/by-contract/`, and `crytic-export/` as artifacts.

CI does not pin `ECHIDNA_SEED` (a new seed each run). To replay a CI counterexample, copy `Seed: N` from the uploaded log and pass it locally:

```bash
ECHIDNA_CONTRACT=EchidnaStakeRegistryHarness \
ECHIDNA_SEED=N \
yarn echidna
```

## Extend

Expand Down
30 changes: 29 additions & 1 deletion scripts/echidna.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cd "$ROOT_DIR"
IMAGE="${ECHIDNA_IMAGE:-ghcr.io/crytic/echidna/echidna:latest}"
CONTRACT="${ECHIDNA_CONTRACT:-}"
CONFIG="${ECHIDNA_CONFIG:-echidna/echidna.yaml}"
LOG_DIR="${ECHIDNA_LOG_DIR:-echidna/logs}"

# Crytic-compile reads artifacts/build-info when using --hardhat-ignore-compile inside Docker (no Node/npx).
# Stale build-info from deleted Solidity sources causes "Unknown file" failures.
Expand Down Expand Up @@ -40,6 +41,7 @@ fi
# echidna/echidna.yaml: testLimit 60000, seqLen 320). Examples:
# ECHIDNA_TEST_LIMIT=20000 ECHIDNA_SEQ_LEN=200 yarn echidna # faster smoke
# ECHIDNA_WORKERS=8 ECHIDNA_CONTRACT=EchidnaSystemHarness yarn echidna
# ECHIDNA_SEED=1 ECHIDNA_TIMEOUT=600 yarn echidna # reproducible / time-boxed CI run
# Use a string (not an array) so `set -u` never trips on empty `${arr[*]}` on older Bash.
ECHIDNA_EXTRA_CLI=""
if [[ -n "${ECHIDNA_TEST_LIMIT:-}" ]]; then
Expand All @@ -51,26 +53,52 @@ fi
if [[ -n "${ECHIDNA_WORKERS:-}" ]]; then
ECHIDNA_EXTRA_CLI+=" --workers ${ECHIDNA_WORKERS}"
fi
if [[ -n "${ECHIDNA_SEED:-}" ]]; then
ECHIDNA_EXTRA_CLI+=" --seed ${ECHIDNA_SEED}"
fi
if [[ -n "${ECHIDNA_TIMEOUT:-}" ]]; then
ECHIDNA_EXTRA_CLI+=" --timeout ${ECHIDNA_TIMEOUT}"
fi
if [[ -n "${ECHIDNA_ARGS:-}" ]]; then
ECHIDNA_EXTRA_CLI+=" ${ECHIDNA_ARGS}"
fi

mkdir -p "${ROOT_DIR}/${LOG_DIR}"

for c in "${CONTRACTS_TO_RUN[@]}"; do
echo "==> echidna: running contract $c" >&2

# One corpus + coverage tree per harness so saved sequences stay relevant to
# that contract (shared corpus mixed unrelated call shapes and diluted learning).
CORPUS_DIR="echidna/corpus/by-contract/${c}"
if [[ -n "${ECHIDNA_SEED:-}" ]]; then
CORPUS_DIR="${CORPUS_DIR}/seed-${ECHIDNA_SEED}"
fi
mkdir -p "${ROOT_DIR}/${CORPUS_DIR}"

LOG_FILE="${ROOT_DIR}/${LOG_DIR}/${c}.log"

# Drop stale Crytic output inside Docker (same uid as container root). A host
# `rm -rf crytic-export` often fails after Docker created the dir as root.
# Capture the fuzzer exit code while still teeing logs for CI artifacts.
set +e
docker run --rm \
--entrypoint sh \
-v "$ROOT_DIR":/src \
-w /src \
"$IMAGE" \
-c "rm -rf crytic-export && echidna-test . --contract ${c} --config ${CONFIG} \
--corpus-dir ${CORPUS_DIR} --coverage-dir ${CORPUS_DIR}/coverage${ECHIDNA_EXTRA_CLI} \
--crytic-args '--hardhat-ignore-compile'"
--crytic-args '--hardhat-ignore-compile'" \
2>&1 | tee "${LOG_FILE}"
ec=${PIPESTATUS[0]}
set -e

yarn -s ts-node "${ROOT_DIR}/scripts/echidna-coverage-summary.ts" "${c}" \
--coverage-dir "${ROOT_DIR}/${CORPUS_DIR}/coverage" || true

if [[ "${ec}" -ne 0 ]]; then
echo "==> echidna: ${c} failed (exit ${ec}); log: ${LOG_FILE}" >&2
exit "${ec}"
fi
done
Loading