diff --git a/.github/workflows/echidna.yml b/.github/workflows/echidna.yml new file mode 100644 index 00000000..ba5d31f6 --- /dev/null +++ b/.github/workflows/echidna.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9062f884..26493b77 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,8 @@ tenderly.log # Echidna fuzzing echidna/corpus/ +echidna/logs/ crytic-export/ # Cursor -.cursor/rules/audit-code.mdc \ No newline at end of file +.cursor/rules/audit-code.mdc diff --git a/README.md b/README.md index a53378e5..d0be0008 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/echidna/README.md b/echidna/README.md index 130bb3f0..27ea447c 100644 --- a/echidna/README.md +++ b/echidna/README.md @@ -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//` (gitignored). Crytic: `crytic-export/`. +Config: `echidna/echidna.yaml` (`ECHIDNA_CONFIG` to override). Corpus/coverage: `echidna/corpus/by-contract//` (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 diff --git a/scripts/echidna.sh b/scripts/echidna.sh index 50d454b4..aad8e8db 100755 --- a/scripts/echidna.sh +++ b/scripts/echidna.sh @@ -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. @@ -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 @@ -51,6 +53,17 @@ 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 @@ -58,10 +71,17 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do # 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 \ @@ -69,8 +89,16 @@ for c in "${CONTRACTS_TO_RUN[@]}"; do "$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