diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16cf62b9ba20..9b809f20ddbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -290,6 +290,16 @@ jobs: depends-dep-opts: ${{ needs.depends-win64.outputs.dep-opts }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + 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 diff --git a/.github/workflows/test-fuzz.yml b/.github/workflows/test-fuzz.yml new file mode 100644 index 000000000000..def543ee5c1e --- /dev/null +++ b/.github/workflows/test-fuzz.yml @@ -0,0 +1,173 @@ +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/dashpay/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 dashpay/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>/tmp/fuzz_target_discovery.err || true) + TARGET_COUNT=$(echo "$TARGETS" | grep -c '[^[:space:]]' || true) + if [ "$TARGET_COUNT" -eq 0 ]; then + if [ -s /tmp/fuzz_target_discovery.err ]; then + cat /tmp/fuzz_target_discovery.err + fi + echo "::error::No fuzz targets found — binary may have failed to start" + exit 1 + fi + echo "Found $TARGET_COUNT fuzz targets" + + 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=$? + echo "::error::FAIL: $target exited with code $EXIT_CODE" + FAILED=$((FAILED + 1)) + FAILED_TARGETS="${FAILED_TARGETS} - ${target} (exit code ${EXIT_CODE})\n" + 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 + 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 ===" + echo "Passed: $PASSED" + echo "Failed: $FAILED" + echo "Total: $TARGET_COUNT" + + if [ $FAILED -gt 0 ]; then + echo "" + echo "=== Failed Targets ===" + printf '%b' "$FAILED_TARGETS" + echo "::error::$FAILED fuzz target(s) failed regression testing" + exit 1 + fi + shell: bash diff --git a/contrib/fuzz/README.md b/contrib/fuzz/README.md new file mode 100644 index 000000000000..ac746bbb4447 --- /dev/null +++ b/contrib/fuzz/README.md @@ -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//` — persistent corpus per target +- `~/fuzz_crashes//` — 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= +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// +``` + +Crash-reproducing inputs are especially valuable — they become regression tests. diff --git a/contrib/fuzz/continuous_fuzz_daemon.sh b/contrib/fuzz/continuous_fuzz_daemon.sh new file mode 100755 index 000000000000..ad172026afac --- /dev/null +++ b/contrib/fuzz/continuous_fuzz_daemon.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# +# Continuous fuzzing daemon — cycles through all fuzz targets with +# persistent corpus storage, crash detection, and logging. +# +# Usage: +# ./continuous_fuzz_daemon.sh [options] +# +# Options: +# --fuzz-bin Path to the fuzz binary (default: auto-detect) +# --corpus-dir Base directory for corpus storage (default: ~/fuzz_corpus) +# --crashes-dir Directory for crash artifacts (default: ~/fuzz_crashes) +# --log-dir Directory for log files (default: ~/fuzz_logs) +# --time-per-target Seconds to fuzz each target per cycle (default: 600) +# --rss-limit RSS memory limit in MB (default: 4000) +# --targets Comma-separated list of targets to fuzz (default: all) +# --exclude Comma-separated list of targets to exclude +# --single-cycle Run one cycle and exit (for cron usage) +# --dry-run List targets and exit without fuzzing + +export LC_ALL=C +set -euo pipefail + +# --- Configuration defaults --- +FUZZ_BIN="" +TIMEOUT_BIN="" +CORPUS_DIR="${HOME}/fuzz_corpus" +CRASHES_DIR="${HOME}/fuzz_crashes" +LOG_DIR="${HOME}/fuzz_logs" +TIME_PER_TARGET=600 +RSS_LIMIT_MB=4000 +TARGET_LIST="" +EXCLUDE_LIST="" +SINGLE_CYCLE=false +DRY_RUN=false + +shuffle_lines() { + if command -v shuf >/dev/null 2>&1; then + shuf + else + awk 'BEGIN{srand()} {print rand() "\t" $0}' | sort -k1,1n | cut -f2- + fi +} + +# --- Parse arguments --- +while [[ $# -gt 0 ]]; do + case "$1" in + --fuzz-bin) [[ $# -ge 2 ]] || { echo "ERROR: --fuzz-bin requires a value" >&2; exit 1; }; FUZZ_BIN="$2"; shift 2;; + --corpus-dir) [[ $# -ge 2 ]] || { echo "ERROR: --corpus-dir requires a value" >&2; exit 1; }; CORPUS_DIR="$2"; shift 2;; + --crashes-dir) [[ $# -ge 2 ]] || { echo "ERROR: --crashes-dir requires a value" >&2; exit 1; }; CRASHES_DIR="$2"; shift 2;; + --log-dir) [[ $# -ge 2 ]] || { echo "ERROR: --log-dir requires a value" >&2; exit 1; }; LOG_DIR="$2"; shift 2;; + --time-per-target) [[ $# -ge 2 ]] || { echo "ERROR: --time-per-target requires a value" >&2; exit 1; }; TIME_PER_TARGET="$2"; shift 2;; + --rss-limit) [[ $# -ge 2 ]] || { echo "ERROR: --rss-limit requires a value" >&2; exit 1; }; RSS_LIMIT_MB="$2"; shift 2;; + --targets) [[ $# -ge 2 ]] || { echo "ERROR: --targets requires a value" >&2; exit 1; }; TARGET_LIST="$2"; shift 2;; + --exclude) [[ $# -ge 2 ]] || { echo "ERROR: --exclude requires a value" >&2; exit 1; }; EXCLUDE_LIST="$2"; shift 2;; + --single-cycle) SINGLE_CYCLE=true; shift;; + --dry-run) DRY_RUN=true; shift;; + -h|--help) + sed -n '2,/^$/s/^# \?//p' "$0" + exit 0 + ;; + *) echo "Unknown option: $1" >&2; exit 1;; + esac +done + +# --- Validate numeric arguments --- +if ! [[ "$TIME_PER_TARGET" =~ ^[0-9]+$ ]]; then + echo "ERROR: --time-per-target must be a positive integer, got '$TIME_PER_TARGET'" >&2 + exit 1 +fi +if ! [[ "$RSS_LIMIT_MB" =~ ^[0-9]+$ ]]; then + echo "ERROR: --rss-limit must be a positive integer, got '$RSS_LIMIT_MB'" >&2 + exit 1 +fi + +# --- Auto-detect fuzz binary --- +if [[ -z "$FUZZ_BIN" ]]; then + for candidate in \ + "${HOME}/dash/src/test/fuzz/fuzz" \ + "${HOME}/dash/build_fuzz/src/test/fuzz/fuzz" \ + "$(command -v fuzz 2>/dev/null || true)"; do + if [[ -x "$candidate" ]]; then + FUZZ_BIN="$candidate" + break + fi + done + if [[ -z "$FUZZ_BIN" ]]; then + echo "ERROR: Could not find fuzz binary. Use --fuzz-bin to specify." >&2 + exit 1 + fi +fi + +if command -v timeout >/dev/null 2>&1; then + TIMEOUT_BIN="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT_BIN="gtimeout" +else + echo "WARNING: timeout command not found; external hang protection disabled" >&2 +fi + +# --- Setup directories --- +mkdir -p "$CORPUS_DIR" "$CRASHES_DIR" "$LOG_DIR" + +# --- Discover targets --- +get_all_targets() { + PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>&1 || true +} + +filter_targets() { + local all_targets="$1" + local result=() + + if [[ -n "$TARGET_LIST" ]]; then + # Use only specified targets + IFS=',' read -ra wanted <<< "$TARGET_LIST" + for t in "${wanted[@]}"; do + if echo "$all_targets" | grep -qx "$t"; then + result+=("$t") + else + echo "WARNING: Target '$t' not found in fuzz binary" >&2 + fi + done + else + # Use all targets + while IFS= read -r t; do + [[ -n "$t" ]] && result+=("$t") + done <<< "$all_targets" + fi + + # Apply exclusions + if [[ -n "$EXCLUDE_LIST" ]]; then + IFS=',' read -ra excluded <<< "$EXCLUDE_LIST" + local filtered=() + for t in "${result[@]}"; do + local skip=false + for ex in "${excluded[@]}"; do + [[ "$t" == "$ex" ]] && skip=true && break + done + $skip || filtered+=("$t") + done + result=("${filtered[@]}") + fi + + printf '%s\n' "${result[@]}" +} + +# --- Logging --- +log() { + local level="$1"; shift + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "${LOG_DIR}/daemon.log" +} + +trap 'log "INFO" "Caught signal — shutting down"; exit 0' SIGTERM SIGINT + +# --- Run one fuzz target --- +run_target() { + local target="$1" + local target_corpus="${CORPUS_DIR}/${target}" + local target_crashes="${CRASHES_DIR}/${target}" + local target_log="${LOG_DIR}/${target}.log" + + mkdir -p "$target_corpus" "$target_crashes" + + log "INFO" "Fuzzing target: ${target} for ${TIME_PER_TARGET}s" + + local exit_code=0 + if [[ -n "$TIMEOUT_BIN" ]]; then + FUZZ="$target" \ + ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_leaks=0" \ + "$TIMEOUT_BIN" $((TIME_PER_TARGET + 30)) "$FUZZ_BIN" \ + -rss_limit_mb="$RSS_LIMIT_MB" \ + -max_total_time="$TIME_PER_TARGET" \ + -reload=0 \ + -print_final_stats=1 \ + -artifact_prefix="${target_crashes}/" \ + "$target_corpus" \ + > "$target_log" 2>&1 || exit_code=$? + else + FUZZ="$target" \ + ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_leaks=0" \ + "$FUZZ_BIN" \ + -rss_limit_mb="$RSS_LIMIT_MB" \ + -max_total_time="$TIME_PER_TARGET" \ + -reload=0 \ + -print_final_stats=1 \ + -artifact_prefix="${target_crashes}/" \ + "$target_corpus" \ + > "$target_log" 2>&1 || exit_code=$? + fi + + # Check for crashes + local crash_count + crash_count=$(find "$target_crashes" -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' 2>/dev/null | wc -l) + + if [[ "$crash_count" -gt 0 ]]; then + log "CRASH" "Target '${target}' produced ${crash_count} crash artifact(s)!" + log "CRASH" "Artifacts saved to: ${target_crashes}/" + + # Extract crash details from log + grep -E "SUMMARY|ERROR|BINGO|crash-|timeout-|oom-" "$target_log" 2>/dev/null | while IFS= read -r line; do + log "CRASH" " $line" + done + fi + + # Log stats + local corpus_size + corpus_size=$(find "$target_corpus" -type f | wc -l) + local corpus_bytes + corpus_bytes=$(du -sh "$target_corpus" 2>/dev/null | cut -f1) + + if [[ $exit_code -eq 0 ]]; then + log "INFO" "Target '${target}' completed: corpus=${corpus_size} files (${corpus_bytes}), exit=${exit_code}" + else + log "WARN" "Target '${target}' exited with code ${exit_code}: corpus=${corpus_size} files (${corpus_bytes})" + fi + + return 0 # Don't fail the daemon on individual target failures +} + +# --- Main loop --- +main() { + log "INFO" "=== Continuous Fuzzing Daemon Starting ===" + log "INFO" "Fuzz binary: ${FUZZ_BIN}" + log "INFO" "Corpus dir: ${CORPUS_DIR}" + log "INFO" "Crashes dir: ${CRASHES_DIR}" + log "INFO" "Time per target: ${TIME_PER_TARGET}s" + log "INFO" "RSS limit: ${RSS_LIMIT_MB}MB" + + local all_targets + all_targets=$(get_all_targets) + local targets + targets=$(filter_targets "$all_targets") + if [[ -z "$targets" ]]; then + log "ERROR" "No matching fuzz targets found" + exit 1 + fi + local target_count + target_count=$(echo "$targets" | wc -l) + + log "INFO" "Found ${target_count} fuzz target(s)" + + if $DRY_RUN; then + log "INFO" "DRY RUN — targets that would be fuzzed:" + echo "$targets" + exit 0 + fi + + local cycle=0 + while true; do + cycle=$((cycle + 1)) + log "INFO" "=== Starting cycle ${cycle} (${target_count} targets × ${TIME_PER_TARGET}s) ===" + + # Snapshot crash count before this cycle + local crashes_before + crashes_before=$(find "$CRASHES_DIR" -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' 2>/dev/null | wc -l) + + # Shuffle targets each cycle for variety + local shuffled + shuffled=$(echo "$targets" | shuffle_lines) + + while IFS= read -r target; do + [[ -z "$target" ]] && continue + run_target "$target" + done <<< "$shuffled" + + # Cycle summary + local total_corpus + total_corpus=$(du -sh "$CORPUS_DIR" 2>/dev/null | cut -f1) + local total_crashes + total_crashes=$(find "$CRASHES_DIR" -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' 2>/dev/null | wc -l) + local new_crashes=$((total_crashes - crashes_before)) + log "INFO" "=== Cycle ${cycle} complete: total corpus=${total_corpus}, new crashes=${new_crashes}, total crashes=${total_crashes} ===" + + if $SINGLE_CYCLE; then + if [[ "$new_crashes" -gt 0 ]]; then + log "WARN" "Single-cycle mode — exiting with ${new_crashes} new crash(es) found" + exit 1 + fi + log "INFO" "Single-cycle mode — exiting" + break + fi + + # Brief pause between cycles + log "INFO" "Sleeping 60s before next cycle..." + sleep 60 + done +} + +main diff --git a/contrib/fuzz/seed_corpus_from_chain.py b/contrib/fuzz/seed_corpus_from_chain.py new file mode 100755 index 000000000000..7ded936df481 --- /dev/null +++ b/contrib/fuzz/seed_corpus_from_chain.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Extract seed corpus inputs from a running Dash node for fuzz testing. + +Connects to a local dashd via RPC and extracts real-world serialized data +(transactions, blocks, special transactions, governance objects, etc.) +into fuzzer-consumable corpus files. + +Usage: + ./seed_corpus_from_chain.py --output-dir /path/to/corpus [options] + +Requirements: + - Running dashd with RPC enabled + - python-bitcoinrpc or compatible RPC library (or uses subprocess + dash-cli) +""" + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path + + +def dash_cli(*args, datadir=None): + """Call dash-cli and return the result.""" + cmd = ["dash-cli"] + if datadir: + cmd.append(f"-datadir={datadir}") + cmd.extend(args) + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: + print(f"WARNING: dash-cli {' '.join(args)} failed: {e}", file=sys.stderr) + return None + + +def save_corpus_input(output_dir, target_name, data_hex): + """Save a hex-encoded blob as a corpus input file.""" + target_dir = output_dir / target_name + target_dir.mkdir(parents=True, exist_ok=True) + + try: + raw_bytes = bytes.fromhex(data_hex) + except ValueError: + print(f"WARNING: Invalid hex data for target {target_name}, skipping", file=sys.stderr) + return False + + filename = hashlib.sha256(raw_bytes).hexdigest()[:16] + filepath = target_dir / filename + + if not filepath.exists(): + filepath.write_bytes(raw_bytes) + return True + return False + + +def read_compact_size(raw, offset): + """Decode a CompactSize integer from raw bytes at offset.""" + if offset >= len(raw): + raise ValueError("truncated CompactSize") + + first = raw[offset] + offset += 1 + if first < 253: + return first, offset + if first == 253: + if offset + 2 > len(raw): + raise ValueError("truncated CompactSize (uint16)") + return int.from_bytes(raw[offset:offset + 2], byteorder="little"), offset + 2 + if first == 254: + if offset + 4 > len(raw): + raise ValueError("truncated CompactSize (uint32)") + return int.from_bytes(raw[offset:offset + 4], byteorder="little"), offset + 4 + if offset + 8 > len(raw): + raise ValueError("truncated CompactSize (uint64)") + return int.from_bytes(raw[offset:offset + 8], byteorder="little"), offset + 8 + + +def extract_extra_payload_hex(raw_tx_hex, extra_payload_size): + """Extract extra payload bytes by parsing a raw special transaction.""" + try: + raw_tx = bytes.fromhex(raw_tx_hex) + except ValueError: + return None, "raw transaction is not valid hex" + + if extra_payload_size <= 0: + return None, "extraPayloadSize must be > 0" + + try: + offset = 0 + if len(raw_tx) < 4: + return None, "raw transaction too short for nVersion/nType" + + n32bit_version = int.from_bytes(raw_tx[offset:offset + 4], byteorder="little") + n_version = n32bit_version & 0xFFFF + n_type = (n32bit_version >> 16) & 0xFFFF + offset += 4 + + if n_version < 3 or n_type == 0: + return None, f"transaction is not a special tx (version={n_version}, type={n_type})" + + vin_count, offset = read_compact_size(raw_tx, offset) + for _ in range(vin_count): + # CTxIn: prevout hash (32), prevout index (4), scriptSig, sequence (4) + if offset + 36 > len(raw_tx): + return None, "truncated tx input prevout" + offset += 36 + + script_len, offset = read_compact_size(raw_tx, offset) + if offset + script_len + 4 > len(raw_tx): + return None, "truncated tx input scriptSig/sequence" + offset += script_len + 4 + + vout_count, offset = read_compact_size(raw_tx, offset) + for _ in range(vout_count): + # CTxOut: amount (8), scriptPubKey + if offset + 8 > len(raw_tx): + return None, "truncated tx output amount" + offset += 8 + + script_len, offset = read_compact_size(raw_tx, offset) + if offset + script_len > len(raw_tx): + return None, "truncated tx output scriptPubKey" + offset += script_len + + if offset + 4 > len(raw_tx): + return None, "truncated nLockTime" + offset += 4 + + payload_len, offset = read_compact_size(raw_tx, offset) + if payload_len != extra_payload_size: + return None, f"extra payload size mismatch (expected {extra_payload_size}, parsed {payload_len})" + if offset + payload_len > len(raw_tx): + return None, "truncated extra payload" + + payload = raw_tx[offset:offset + payload_len] + offset += payload_len + if offset != len(raw_tx): + return None, f"unexpected trailing bytes after payload ({len(raw_tx) - offset} bytes)" + + return payload.hex(), None + except ValueError as e: + return None, str(e) + + +def extract_blocks(output_dir, count=20, datadir=None): + """Extract recent blocks as corpus inputs.""" + print(f"Extracting {count} recent blocks...") + height_str = dash_cli("getblockcount", datadir=datadir) + if not height_str: + return 0 + + height = int(height_str) + saved = 0 + + for h in range(max(0, height - count), height + 1): + block_hash = dash_cli("getblockhash", str(h), datadir=datadir) + if not block_hash: + continue + + # Get serialized block + block_hex = dash_cli("getblock", block_hash, "0", datadir=datadir) + if block_hex: + if save_corpus_input(output_dir, "block_deserialize", block_hex): + saved += 1 + if save_corpus_input(output_dir, "block", block_hex): + saved += 1 + + print(f" Saved {saved} block corpus inputs") + return saved + + +def extract_special_txs(output_dir, count=100, datadir=None): + """Extract special transactions (ProTx, etc.) from recent blocks.""" + print(f"Scanning {count} recent blocks for special transactions...") + height_str = dash_cli("getblockcount", datadir=datadir) + if not height_str: + return 0 + + height = int(height_str) + saved = 0 + + # Map special tx types to fuzz target names + type_map = { + 1: "dash_proreg_tx", # ProRegTx + 2: "dash_proupserv_tx", # ProUpServTx + 3: "dash_proupreg_tx", # ProUpRegTx + 4: "dash_prouprev_tx", # ProUpRevTx + 5: "dash_cbtx", # CbTx (coinbase) + 6: "dash_final_commitment_tx_payload", # Quorum commitment + 7: "dash_mnhf_tx_payload", # MN HF signal + 8: "dash_asset_lock_payload", # Asset Lock + 9: "dash_asset_unlock_payload", # Asset Unlock + } + + for h in range(max(0, height - count), height + 1): + block_hash = dash_cli("getblockhash", str(h), datadir=datadir) + if not block_hash: + continue + + block_json = dash_cli("getblock", block_hash, "2", datadir=datadir) + if not block_json: + continue + + try: + block = json.loads(block_json) + except json.JSONDecodeError: + continue + + for tx in block.get("tx", []): + tx_type = tx.get("type", 0) + if tx_type == 0: + continue + + # Get raw transaction + txid = tx.get("txid", "") + raw_tx = dash_cli("getrawtransaction", txid, datadir=datadir) + if not raw_tx: + continue + + # Save full transaction + if save_corpus_input(output_dir, "decode_tx", raw_tx): + saved += 1 + + # Extract special payload if we know the target + extra_payload_size = tx.get("extraPayloadSize", 0) + try: + extra_payload_size = int(extra_payload_size) + except (TypeError, ValueError): + extra_payload_size = 0 + + if extra_payload_size > 0 and tx_type in type_map: + payload_hex, err = extract_extra_payload_hex(raw_tx, extra_payload_size) + if not payload_hex: + print( + f"WARNING: Skipping special payload for tx {txid}: {err}", + file=sys.stderr, + ) + continue + + target = type_map[tx_type] + # Save payload bytes for both deserialize and roundtrip variants. + for suffix in ["_deserialize", "_roundtrip"]: + if save_corpus_input(output_dir, f"{target}{suffix}", payload_hex): + saved += 1 + + print(f" Saved {saved} special transaction corpus inputs") + return saved + + +def extract_governance_objects(output_dir, datadir=None): + """Extract governance objects (proposals, triggers).""" + print("Extracting governance objects...") + result = dash_cli("gobject", "list", "all", datadir=datadir) + if not result: + return 0 + + saved = 0 + try: + objects = json.loads(result) + for _obj_hash, obj_data in objects.items(): + data_hex = obj_data.get("DataHex", "") + if data_hex: + if save_corpus_input(output_dir, "dash_governance_object_deserialize", data_hex): + saved += 1 + if save_corpus_input(output_dir, "dash_governance_object_roundtrip", data_hex): + saved += 1 + except (json.JSONDecodeError, AttributeError): + pass + + print(f" Saved {saved} governance corpus inputs") + return saved + + +def extract_masternode_list(output_dir, datadir=None): + """Extract masternode list entries.""" + print("Extracting masternode list data...") + result = dash_cli("protx", "list", "registered", "true", datadir=datadir) + if not result: + return 0 + + saved = 0 + try: + mn_list = json.loads(result) + for mn in mn_list: + protx_hash = mn.get("proTxHash", "") + if not protx_hash: + continue + + raw_tx = dash_cli("getrawtransaction", protx_hash, datadir=datadir) + if not raw_tx: + continue + + # Save full raw tx for full-transaction targets + if save_corpus_input(output_dir, "decode_tx", raw_tx): + saved += 1 + + # Extract the special payload for payload-specific targets + # ProRegTx type is 1, get extraPayloadSize from verbose tx + verbose_tx = dash_cli("getrawtransaction", protx_hash, "true", datadir=datadir) + if not verbose_tx: + continue + try: + tx_info = json.loads(verbose_tx) + except json.JSONDecodeError: + continue + + extra_payload_size = tx_info.get("extraPayloadSize", 0) + try: + extra_payload_size = int(extra_payload_size) + except (TypeError, ValueError): + extra_payload_size = 0 + + if extra_payload_size > 0: + payload_hex, err = extract_extra_payload_hex(raw_tx, extra_payload_size) + if payload_hex: + for target in ["dash_proreg_tx_deserialize", "dash_proreg_tx_roundtrip"]: + if save_corpus_input(output_dir, target, payload_hex): + saved += 1 + else: + print(f"WARNING: Could not extract payload from protx {protx_hash}: {err}", file=sys.stderr) + except (json.JSONDecodeError, AttributeError): + pass + + print(f" Saved {saved} masternode corpus inputs") + return saved + + +def extract_quorum_info(output_dir, datadir=None): + """Extract quorum-related data from the chain. + + Note: quorum snapshot deserialize targets expect binary-serialized + CQuorumSnapshot data, not JSON. We extract final commitment transactions + from blocks instead, which are already captured by extract_special_txs() + for type 6 (TRANSACTION_QUORUM_COMMITMENT). This function focuses on + extracting quorum memberof data as raw bytes for other quorum targets. + """ + print("Extracting quorum data...") + result = dash_cli("quorum", "list", datadir=datadir) + if not result: + return 0 + + saved = 0 + try: + quorum_list = json.loads(result) + for qtype, hashes in quorum_list.items(): + for qhash in hashes[:5]: # Limit per type + # Get the quorum commitment transaction via selectquorum + # which gives us the quorumHash we can look up in blocks + qinfo_str = dash_cli("quorum", "info", qtype, qhash, datadir=datadir) + if not qinfo_str: + continue + try: + qinfo = json.loads(qinfo_str) + except json.JSONDecodeError: + continue + # Extract the commitment tx if available + mining_hash = qinfo.get("minedBlock", "") + if mining_hash: + block_hex = dash_cli("getblock", mining_hash, "0", datadir=datadir) + if block_hex and save_corpus_input(output_dir, "block_deserialize", block_hex): + saved += 1 + except (json.JSONDecodeError, AttributeError): + pass + + print(f" Saved {saved} quorum corpus inputs") + return saved + + +# +# These Dash-specific target names are forward-looking: corresponding fuzz targets +# are planned for a future PR. We pre-generate seeds now so coverage is ready as +# soon as those targets land. +def create_synthetic_seeds(output_dir): + """Create minimal synthetic seed inputs for targets without chain data.""" + print("Creating synthetic seed inputs...") + saved = 0 + + # Targets that need synthetic seeds (serialized structs with known formats) + synthetic_seeds = { + # CoinJoin messages — minimal valid-ish payloads + "dash_coinjoin_accept_deserialize": [ + "00000000" + "00" * 4, # nDenom(4) + txCollateral + ], + "dash_coinjoin_queue_deserialize": [ + "00000000" + "00" * 48 + "00" * 96 + "0000000000000000", # nDenom + proTxHash + vchSig + nTime + ], + "dash_coinjoin_status_update_deserialize": [ + "00000000" + "00000000" + "00000000", # nSessionID + nState + nStatusUpdate + ], + # LLMQ messages + "dash_recovered_sig_deserialize": [ + "64" + "00" * 32 + "00" * 32 + "00" * 96, # llmqType + quorumHash + id + sig + ], + "dash_sig_ses_ann_deserialize": [ + "64" + "00" * 32 + "00000000" + "00" * 32, # llmqType + quorumHash + nSessionId + id + ], + "dash_sig_share_deserialize": [ + "64" + "00" * 32 + "00000000" + "00" * 32 + "0000" + "00" * 96, + ], + # MNAuth + "dash_mnauth_deserialize": [ + "00" * 32 + "00" * 32 + "00" * 96, # proRegTxHash + signChallenge + sig + ], + # DKG messages + "dash_dkg_complaint_deserialize": [ + "64" + "00" * 32 + "00" * 32 + "0000" + "00", # minimal + ], + } + + for target, seeds in synthetic_seeds.items(): + for seed_hex in seeds: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + # Also save roundtrip variant + roundtrip_target = target.replace("_deserialize", "_roundtrip") + if save_corpus_input(output_dir, roundtrip_target, seed_hex): + saved += 1 + + print(f" Created {saved} synthetic seed inputs") + return saved + + +def main(): + parser = argparse.ArgumentParser( + description="Extract seed corpus from a running Dash node for fuzz testing" + ) + parser.add_argument( + "--output-dir", "-o", + required=True, + help="Output directory for corpus files" + ) + parser.add_argument( + "--datadir", + help="Dash data directory (passed to dash-cli)" + ) + parser.add_argument( + "--blocks", type=int, default=100, + help="Number of recent blocks to scan (default: 100)" + ) + parser.add_argument( + "--synthetic-only", + action="store_true", + help="Only generate synthetic seeds (no RPC required)" + ) + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + total = 0 + + if not args.synthetic_only: + total += extract_blocks(output_dir, count=args.blocks, datadir=args.datadir) + total += extract_special_txs(output_dir, count=args.blocks, datadir=args.datadir) + total += extract_governance_objects(output_dir, datadir=args.datadir) + total += extract_masternode_list(output_dir, datadir=args.datadir) + total += extract_quorum_info(output_dir, datadir=args.datadir) + + total += create_synthetic_seeds(output_dir) + + print(f"\nTotal: {total} corpus inputs saved to {output_dir}") + + # Print summary + print("\nCorpus directory summary:") + for target_dir in sorted(output_dir.iterdir()): + if target_dir.is_dir(): + file_count = len(list(target_dir.iterdir())) + print(f" {target_dir.name}: {file_count} files") + + +if __name__ == "__main__": + main() diff --git a/src/Makefile.test.include b/src/Makefile.test.include index dd6dda7178c3..c888417529b9 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -279,11 +279,13 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/addrman.cpp \ test/fuzz/asmap.cpp \ test/fuzz/asmap_direct.cpp \ + test/fuzz/asset_lock_unlock.cpp \ test/fuzz/autofile.cpp \ test/fuzz/banman.cpp \ test/fuzz/base_encode_decode.cpp \ test/fuzz/bech32.cpp \ test/fuzz/bip324.cpp \ + test/fuzz/bls_operations.cpp \ test/fuzz/block.cpp \ test/fuzz/block_header.cpp \ test/fuzz/blockfilter.cpp \ @@ -291,6 +293,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/buffered_file.cpp \ test/fuzz/chain.cpp \ test/fuzz/checkqueue.cpp \ + test/fuzz/coinjoin.cpp \ test/fuzz/coins_view.cpp \ test/fuzz/coinscache_sim.cpp \ test/fuzz/connman.cpp \ @@ -306,18 +309,23 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/decode_tx.cpp \ test/fuzz/descriptor_parse.cpp \ test/fuzz/deserialize.cpp \ + test/fuzz/deserialize_dash.cpp \ + test/fuzz/deterministic_mn_list_diff.cpp \ + test/fuzz/simplified_mn_list_diff.cpp \ test/fuzz/eval_script.cpp \ test/fuzz/fee_rate.cpp \ test/fuzz/fees.cpp \ test/fuzz/flatfile.cpp \ test/fuzz/float.cpp \ test/fuzz/golomb_rice.cpp \ + test/fuzz/governance_proposal_validator.cpp \ test/fuzz/hex.cpp \ test/fuzz/http_request.cpp \ test/fuzz/integer.cpp \ test/fuzz/key.cpp \ test/fuzz/key_io.cpp \ test/fuzz/kitchen_sink.cpp \ + test/fuzz/llmq_messages.cpp \ test/fuzz/load_external_block_file.cpp \ test/fuzz/locale.cpp \ test/fuzz/merkleblock.cpp \ @@ -343,11 +351,13 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/prevector.cpp \ test/fuzz/primitives_transaction.cpp \ test/fuzz/process_message.cpp \ + test/fuzz/process_message_dash.cpp \ test/fuzz/process_messages.cpp \ test/fuzz/protocol.cpp \ test/fuzz/psbt.cpp \ test/fuzz/random.cpp \ test/fuzz/rolling_bloom_filter.cpp \ + test/fuzz/roundtrip_dash.cpp \ test/fuzz/rpc.cpp \ test/fuzz/script.cpp \ test/fuzz/script_bitcoin_consensus.cpp \ @@ -362,6 +372,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/secp256k1_ec_seckey_import_export_der.cpp \ test/fuzz/secp256k1_ecdsa_signature_parse_der_lax.cpp \ test/fuzz/signature_checker.cpp \ + test/fuzz/special_tx_validation.cpp \ test/fuzz/socks5.cpp \ test/fuzz/span.cpp \ test/fuzz/spanparsing.cpp \ diff --git a/src/Makefile.test_fuzz.include b/src/Makefile.test_fuzz.include index 143917357d78..cf33aa7e1577 100644 --- a/src/Makefile.test_fuzz.include +++ b/src/Makefile.test_fuzz.include @@ -11,6 +11,7 @@ TEST_FUZZ_H = \ test/fuzz/fuzz.h \ test/fuzz/FuzzedDataProvider.h \ test/fuzz/util.h \ + test/fuzz/util_dash.h \ test/util/mining.h \ test/fuzz/util/net.h diff --git a/src/test/fuzz/asset_lock_unlock.cpp b/src/test/fuzz/asset_lock_unlock.cpp new file mode 100644 index 000000000000..32e5e00bd625 --- /dev/null +++ b/src/test/fuzz/asset_lock_unlock.cpp @@ -0,0 +1,195 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include