Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ jobs:
depends-host: ${{ needs.depends-win64.outputs.host }}
depends-dep-opts: ${{ needs.depends-win64.outputs.dep-opts }}

test-linux64_fuzz:
name: linux64_fuzz-test
uses: ./.github/workflows/test-fuzz.yml
needs: [container-slim, src-linux64_fuzz]
if: ${{ vars.SKIP_LINUX64_FUZZ == '' }}
with:
bundle-key: ${{ needs.src-linux64_fuzz.outputs.key }}
build-target: linux64_fuzz
container-path: ${{ needs.container-slim.outputs.path }}

test-linux64:
name: linux64-test
uses: ./.github/workflows/test-src.yml
Expand Down
171 changes: 171 additions & 0 deletions .github/workflows/test-fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
name: Fuzz regression

on:
workflow_call:
inputs:
bundle-key:
description: "Key needed to access bundle of fuzz build artifacts"
required: true
type: string
build-target:
description: "Target name as defined by inputs.sh"
required: true
type: string
container-path:
description: "Path to built container at registry"
required: true
type: string
runs-on:
description: "Runner label to use"
required: false
default: ubuntu-24.04
type: string

jobs:
fuzz-regression:
name: Fuzz regression
runs-on: ${{ inputs.runs-on }}
container:
image: ${{ inputs.container-path }}
options: --user root
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1

- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: ${{ inputs.bundle-key }}

- name: Extract build artifacts
run: |
git config --global --add safe.directory "$PWD"
export BUILD_TARGET="${{ inputs.build-target }}"
export BUNDLE_KEY="${{ inputs.bundle-key }}"
./ci/dash/bundle-artifacts.sh extract
shell: bash

- name: Download corpus
run: |
mkdir -p /tmp/fuzz_corpus

# Layer 1: bitcoin-core inherited corpus
if git clone --depth=1 https://github.com/bitcoin-core/qa-assets /tmp/qa-assets; then
if [ -d "/tmp/qa-assets/fuzz_corpora" ]; then
cp -r /tmp/qa-assets/fuzz_corpora/. /tmp/fuzz_corpus/
echo "Loaded bitcoin-core corpus"
fi
else
echo "WARNING: Failed to clone bitcoin-core/qa-assets (non-fatal)"
fi

# Layer 2: Dash-specific corpus (overlays on top)
if git clone --depth=1 https://github.com/thepastaclaw/qa-assets /tmp/dash-qa-assets; then
if [ -d "/tmp/dash-qa-assets/fuzz_corpora" ]; then
cp -r /tmp/dash-qa-assets/fuzz_corpora/. /tmp/fuzz_corpus/
echo "Loaded Dash-specific corpus"
fi
else
echo "WARNING: Failed to clone thepastaclaw/qa-assets (non-fatal)"
fi

# Layer 3: Generate synthetic seeds for Dash-specific targets
if [ -f "contrib/fuzz/seed_corpus_from_chain.py" ]; then
python3 contrib/fuzz/seed_corpus_from_chain.py --synthetic-only -o /tmp/fuzz_corpus
fi
shell: bash

- name: Run fuzz regression tests
id: fuzz-test
run: |
export BUILD_TARGET="${{ inputs.build-target }}"
source ./ci/dash/matrix.sh

BUILD_DIR="build-ci/dashcore-${BUILD_TARGET}"
FUZZ_BIN="${BUILD_DIR}/src/test/fuzz/fuzz"

if [ ! -x "$FUZZ_BIN" ]; then
echo "ERROR: Fuzz binary not found at $FUZZ_BIN"
exit 1
fi

# detect_leaks=0 is intentional for fuzz regression due to libFuzzer/LSan noise.
export ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_leaks=0"
export LSAN_OPTIONS="suppressions=${BUILD_DIR}/test/sanitizer_suppressions/lsan"
export UBSAN_OPTIONS="suppressions=${BUILD_DIR}/test/sanitizer_suppressions/ubsan:print_stacktrace=1:halt_on_error=1:report_error_type=1"

# Get list of all targets
TARGETS=$(PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>&1 || true)
TARGET_COUNT=$(echo "$TARGETS" | wc -l)
echo "Found $TARGET_COUNT fuzz targets"
Comment on lines +101 to +103
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Binary startup failures produce a vacuous pass.

|| true is required because the fuzz binary exits non-zero when PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1, but it also suppresses genuine startup failures (missing shared libraries, wrong architecture, etc.). In that case TARGETS is an empty string, TARGET_COUNT becomes 1 (off-by-one from echo "" | wc -l), the loop skips the single empty line, FAILED stays 0, and the step exits 0 — reporting success with zero targets tested.

Add an explicit guard after the enumeration:

🛡️ Proposed fix
  TARGETS=$(PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>&1 || true)
+ if [ -z "$TARGETS" ]; then
+   echo "ERROR: No fuzz targets found; fuzz binary may not have started correctly"
+   exit 1
+ fi
  TARGET_COUNT=$(echo "$TARGETS" | wc -l)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TARGETS=$(PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>&1 || true)
TARGET_COUNT=$(echo "$TARGETS" | wc -l)
echo "Found $TARGET_COUNT fuzz targets"
TARGETS=$(PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>&1 || true)
if [ -z "$TARGETS" ]; then
echo "ERROR: No fuzz targets found; fuzz binary may not have started correctly"
exit 1
fi
TARGET_COUNT=$(echo "$TARGETS" | wc -l)
echo "Found $TARGET_COUNT fuzz targets"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test-fuzz.yml around lines 93 - 95, The TARGETS
enumeration swallows real startup failures due to the "|| true" and yields an
empty string which makes TARGET_COUNT incorrectly report 1; after populating
TARGETS (from PRINT_ALL_FUZZ_TARGETS_AND_ABORT via FUZZ_BIN) add an explicit
guard that checks whether TARGETS is empty or contains an error marker and
aborts with a non-zero exit and an explanatory error log if so; use the
variables TARGETS, TARGET_COUNT, and FUZZ_BIN in this check (e.g., test for
empty string or for common loader/architecture errors in TARGETS) so the
workflow fails fast on genuine startup problems rather than reporting a vacuous
pass.


FAILED=0
PASSED=0
FAILED_TARGETS=""

while IFS= read -r target; do
[ -z "$target" ] && continue
corpus_dir="/tmp/fuzz_corpus/${target}"

if [ ! -d "$corpus_dir" ] || [ -z "$(ls -A "$corpus_dir" 2>/dev/null)" ]; then
# No corpus for this target — run with empty input for 10s
# This catches basic initialization crashes
echo "::group::${target} (empty corpus, 10s run)"
mkdir -p "$corpus_dir"
# timeout(30) intentionally exceeds -max_total_time=10 to absorb startup/teardown jitter
# while still terminating genuinely hung processes.
if FUZZ="$target" timeout 30 "$FUZZ_BIN" \
-rss_limit_mb=4000 \
-max_total_time=10 \
-reload=0 \
"$corpus_dir" 2>&1; then
echo "PASS: $target (empty corpus)"
PASSED=$((PASSED + 1))
else
EXIT_CODE=$?
if [ $EXIT_CODE -eq 124 ]; then
echo "PASS: $target (timeout — expected for empty corpus)"
PASSED=$((PASSED + 1))
else
echo "::error::FAIL: $target exited with code $EXIT_CODE"
FAILED=$((FAILED + 1))
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (exit code ${EXIT_CODE})\n"
fi
fi
echo "::endgroup::"
continue
fi

# Run corpus regression (replay all inputs)
echo "::group::${target} ($(find "$corpus_dir" -maxdepth 1 -type f | wc -l) inputs)"
if FUZZ="$target" "$FUZZ_BIN" \
-rss_limit_mb=4000 \
-runs=0 \
"$corpus_dir" 2>&1; then
Comment on lines +144 to +147
Copy link

@coderabbitai coderabbitai bot Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing -runs=0 causes the corpus replay to run indefinitely, hanging CI.

By default, the fuzzing process will continue indefinitely after replaying the existing corpus. Without -runs=0, libFuzzer will replay all corpus inputs and then switch to mutation-based fuzzing with no time bound. If you want to re-execute a directory of test cases without actually fuzzing, you can run with -runs=0. The corpus replay step has neither -runs=0 nor a -max_total_time guard, so any target with a non-trivial corpus will stall the CI job indefinitely.

🐛 Proposed fix
  if FUZZ="$target" "$FUZZ_BIN" \
      -rss_limit_mb=4000 \
+     -runs=0 \
      "$corpus_dir" 2>&1; then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test-fuzz.yml around lines 134 - 136, The corpus replay
invocation using FUZZ="$target" "$FUZZ_BIN" -rss_limit_mb=4000 "$corpus_dir" can
run indefinitely because it lacks a termination flag; modify the call that
constructs the fuzzer command (the line invoking FUZZ, "$FUZZ_BIN", and
"$corpus_dir") to include a termination option such as -runs=0 (or alternatively
-max_total_time=<seconds>) so libFuzzer will only replay the corpus and exit
instead of entering unlimited mutation mode.

✅ Confirmed as addressed by @UdjinM6

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proof: https://github.com/UdjinM6/dash/actions/runs/22323052205/job/64588995007

Suggested change
if FUZZ="$target" "$FUZZ_BIN" \
-rss_limit_mb=4000 \
"$corpus_dir" 2>&1; then
-rss_limit_mb=4000 \
-runs=0 \

With this patch applied it can finally finish: https://github.com/UdjinM6/dash/actions/runs/22329270486/job/64608898233

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UdjinM6, excellent catch and thank you for the confirmation! The evidence clearly shows the issue—the CI job was hanging indefinitely without -runs=0, and your test run proves the fix resolves it completely. The corpus replay now properly terminates after replaying all inputs instead of entering mutation mode.


🧠 Learnings used
Learnt from: PastaPastaPasta
Repo: dashpay/dash PR: 6804
File: src/qt/proposalwizard.cpp:40-42
Timestamp: 2025-08-11T17:16:36.654Z
Learning: In the Dash repository, when a PR adds new files that are not from Bitcoin backports, these files must be added to the list in test/util/data/non-backported.txt. This applies to newly created files like qt/proposalwizard.{h,cpp} and forms/proposalwizard.ui. Limited exemptions may exist for subtrees and similar cases.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why @coderabbitai marked this one as resolved, it's still not fixed

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UdjinM6, you're absolutely right—I apologize for the confusion. I incorrectly marked the issue as resolved when you provided proof that the fix works in your test branch. That was only validation of the solution, not confirmation that you'd applied it to this PR.

Looking at the current code at lines 134-136, the -runs=0 flag is indeed still missing:

if FUZZ="$target" "$FUZZ_BIN" \
    -rss_limit_mb=4000 \
    "$corpus_dir" 2>&1; then

The original issue remains: without -runs=0, libFuzzer will replay the corpus and then enter indefinite mutation mode, hanging the CI. Your test run at https://github.com/UdjinM6/dash/actions/runs/22329270486/job/64608898233 proved that adding -runs=0 resolves this completely.

To fix this in the PR, please add the flag:

if FUZZ="$target" "$FUZZ_BIN" \
    -rss_limit_mb=4000 \
    -runs=0 \
    "$corpus_dir" 2>&1; then

Sorry for the premature closure!

  /\_/\
 ( o.o )
  > ^ <  _oops!_
 /|   |\
(_|   |_)

🧠 Learnings used
Learnt from: UdjinM6
Repo: dashpay/dash PR: 6786
File: ci/test/04_install.sh:99-101
Timestamp: 2025-08-01T07:46:37.840Z
Learning: In backport PRs like `#6786`, UdjinM6 prefers to defer non-critical fixes (such as shell command expansion issues) to separate commits/PRs to maintain focus on the primary backport objectives, consistent with the project's pattern of avoiding scope creep.

Learnt from: PastaPastaPasta
Repo: dashpay/dash PR: 6804
File: src/qt/proposalwizard.cpp:40-42
Timestamp: 2025-08-11T17:16:36.654Z
Learning: In the Dash repository, when a PR adds new files that are not from Bitcoin backports, these files must be added to the list in test/util/data/non-backported.txt. This applies to newly created files like qt/proposalwizard.{h,cpp} and forms/proposalwizard.ui. Limited exemptions may exist for subtrees and similar cases.

Learnt from: kwvg
Repo: dashpay/dash PR: 6543
File: src/wallet/receive.cpp:240-251
Timestamp: 2025-02-06T14:34:30.466Z
Learning: Pull request `#6543` is focused on move-only changes and refactoring, specifically backporting from Bitcoin. Behavior changes should be proposed in separate PRs.

echo "PASS: $target"
PASSED=$((PASSED + 1))
else
echo "::error::FAIL: $target"
FAILED=$((FAILED + 1))
FAILED_TARGETS="${FAILED_TARGETS} - ${target}\n"
fi
echo "::endgroup::"
done <<< "$TARGETS"

echo ""
echo "=== Fuzz Regression Summary ==="
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have a list of failed targets here.

echo "Passed: $PASSED"
echo "Failed: $FAILED"
echo "Total: $TARGET_COUNT"

if [ $FAILED -gt 0 ]; then
echo ""
echo "=== Failed Targets ==="
echo -e "$FAILED_TARGETS"
echo "::error::$FAILED fuzz target(s) failed regression testing"
exit 1
fi
shell: bash
103 changes: 103 additions & 0 deletions contrib/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Dash Core Fuzz Testing Tools

This directory contains tools for continuous fuzz testing of Dash Core.

## Overview

Dash Core inherits ~100 fuzz targets from Bitcoin Core and adds Dash-specific
targets for:
- Special transaction serialization (ProTx, CoinJoin, Asset Lock/Unlock, etc.)
- BLS operations and IES encryption
- LLMQ/DKG message handling
- Governance object validation
- Masternode list management

Some Dash-specific fuzz targets are planned/in-progress. Corpus tooling
pre-generates synthetic seeds for those target names so coverage is ready when
the targets are added.

## Tools

### `continuous_fuzz_daemon.sh`

A daemon script that continuously cycles through all fuzz targets with persistent
corpus storage and crash detection.

```bash
# Run all targets, 10 minutes each, indefinitely
./continuous_fuzz_daemon.sh --fuzz-bin /path/to/fuzz --time-per-target 600

# Run specific targets only
./continuous_fuzz_daemon.sh --targets bls_operations,bls_ies --time-per-target 3600

# Single cycle (good for cron)
./continuous_fuzz_daemon.sh --single-cycle --time-per-target 300

# Dry run — list targets
./continuous_fuzz_daemon.sh --dry-run
```

**Output directories:**
- `~/fuzz_corpus/<target>/` — persistent corpus per target
- `~/fuzz_crashes/<target>/` — crash artifacts (crash-*, timeout-*, oom-*)
- `~/fuzz_logs/` — per-target logs and daemon log

### `seed_corpus_from_chain.py`

Extracts real-world data from a running Dash node into fuzzer-consumable corpus
files. Connects via `dash-cli` RPC.

```bash
# Extract from a running node
./seed_corpus_from_chain.py -o /path/to/corpus --blocks 500

# Generate only synthetic seeds (no running node required)
./seed_corpus_from_chain.py -o /path/to/corpus --synthetic-only
```

**What it extracts:**
- Serialized blocks and block headers
- Special transactions (ProRegTx, ProUpServTx, CoinJoin, Asset Lock, etc.)
- Governance objects and votes
- Masternode list entries
- Quorum commitment data

## CI Integration

The `test-fuzz.yml` workflow runs fuzz regression tests on every PR:

1. Builds fuzz targets with sanitizers (ASan + UBSan + libFuzzer)
2. Downloads seed corpus from `bitcoin-core/qa-assets` + synthetic Dash seeds
3. Replays all corpus inputs against every fuzz target
4. Reports failures as CI errors

This catches regressions in seconds — any code change that causes a previously-
working input to crash will be caught.

## Building Fuzz Targets

```bash
# Configure with fuzzing + sanitizers
./configure --enable-fuzz --with-sanitizers=fuzzer,address,undefined \
CC='clang -ftrivial-auto-var-init=pattern' \
CXX='clang++ -ftrivial-auto-var-init=pattern'

# Build
make -j$(nproc)

# The fuzz binary is at src/test/fuzz/fuzz
# Select target with FUZZ=<target_name>
FUZZ=bls_operations ./src/test/fuzz/fuzz corpus_dir/
```

## Contributing Corpus Inputs

Found an interesting input? Add it to the appropriate corpus directory:

```bash
# The filename should be the sha256 of the content (for dedup)
sha256sum input_file
cp input_file fuzz_corpus/<target_name>/<sha256_prefix>
```

Crash-reproducing inputs are especially valuable — they become regression tests.
Loading
Loading