diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f4b5128f93b..cedf8209c800 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -318,6 +318,17 @@ 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: [check-skip, container, 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.outputs.path }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + 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..6101073e13c5 --- /dev/null +++ b/.github/workflows/test-fuzz.yml @@ -0,0 +1,237 @@ +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 + + BITCOIN_QA_ASSETS_SHA=ef5d7720f2a1ac20607bd5fba16137f4bfbcfec6 + DASH_QA_ASSETS_SHA=b4d1a7ce41a9a21d522381944ba84b60b1ad5b60 + + # Fail the job if neither external qa-assets source is reachable — otherwise we + # silently degrade to synthetic-only/empty-corpus smoke runs and CI goes green on + # no signal. Synthetic seeds are additive only; they do not substitute for the + # curated external corpora. + loaded_external=0 + + fetch_pinned_repo() { + local repo_url="$1" + local repo_sha="$2" + local dest_dir="$3" + local resolved_sha + + rm -rf "$dest_dir" + git init --quiet "$dest_dir" + git -C "$dest_dir" remote add origin "$repo_url" + git -C "$dest_dir" fetch --depth=1 origin "$repo_sha" || return 1 + git -C "$dest_dir" checkout --quiet --detach FETCH_HEAD || return 1 + resolved_sha=$(git -C "$dest_dir" rev-parse HEAD) || return 1 + echo "Resolved ${repo_url} to ${resolved_sha}" + [ "$resolved_sha" = "$repo_sha" ] + } + + # Layer 1: bitcoin-core inherited corpus + if fetch_pinned_repo https://github.com/bitcoin-core/qa-assets "$BITCOIN_QA_ASSETS_SHA" /tmp/qa-assets; then + if [ -d "/tmp/qa-assets/fuzz_seed_corpus" ]; then + cp -r /tmp/qa-assets/fuzz_seed_corpus/. /tmp/fuzz_corpus/ + echo "Loaded bitcoin-core corpus" + loaded_external=1 + fi + else + echo "::warning::Failed to fetch bitcoin-core/qa-assets at ${BITCOIN_QA_ASSETS_SHA}" + fi + + # Layer 2: Dash-specific corpus (overlays on top) + if fetch_pinned_repo https://github.com/dashpay/qa-assets "$DASH_QA_ASSETS_SHA" /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" + loaded_external=1 + fi + else + echo "::warning::Failed to fetch dashpay/qa-assets at ${DASH_QA_ASSETS_SHA}" + fi + + if [ "$loaded_external" -eq 0 ]; then + echo "::error::No external corpus sources reachable - refusing to run synthetic-only/empty-corpus smoke tests that produce no signal" + exit 1 + fi + + # Layer 3: Generate synthetic seeds for Dash-specific targets (additive only — + # gated on external corpus being present so we never pass on synthetic-only runs). + 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 + + # Leak detection stays enabled; known-noisy dependency leaks are filtered via + # LSAN_OPTIONS suppressions instead of being globally disabled. + export ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1" + 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="" + # libFuzzer writes crash-/leak-/oom-/timeout- files to this directory on failure + # via -artifact_prefix, so the "Upload crash artifacts" step below can collect them. + ARTIFACT_DIR=/tmp/fuzz_artifacts + mkdir -p "$ARTIFACT_DIR" + + while IFS= read -r target; do + [ -z "$target" ] && continue + corpus_dir="/tmp/fuzz_corpus/${target}" + artifact_prefix="${ARTIFACT_DIR}/${target}-" + + # Classify a non-zero exit code from `timeout`/libFuzzer. timeout(1) reports + # 124 when the time budget elapsed, and 128+SIGNAL when the child was killed + # (137 = SIGKILL, 143 = SIGTERM). Treat those as "timeout/kill" and any other + # non-zero status as a generic crash. Both still fail the job. + classify_exit() { + case "$1" in + 124|137|143) echo "timeout" ;; + *) echo "crash" ;; + esac + } + + 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 \ + -artifact_prefix="$artifact_prefix" \ + "$corpus_dir" 2>&1; then + echo "PASS: $target (empty corpus)" + PASSED=$((PASSED + 1)) + else + EXIT_CODE=$? + KIND=$(classify_exit "$EXIT_CODE") + echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})" + FAILED=$((FAILED + 1)) + FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, 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" timeout 3600 "$FUZZ_BIN" \ + -rss_limit_mb=4000 \ + -runs=1 \ + -artifact_prefix="$artifact_prefix" \ + "$corpus_dir" 2>&1; then + echo "PASS: $target" + PASSED=$((PASSED + 1)) + else + EXIT_CODE=$? + KIND=$(classify_exit "$EXIT_CODE") + echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})" + FAILED=$((FAILED + 1)) + FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, exit code ${EXIT_CODE})\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 + + - name: Upload crash artifacts + if: failure() && steps.fuzz-test.conclusion == 'failure' + uses: actions/upload-artifact@v4 + with: + name: fuzz-crashes-${{ inputs.build-target }} + path: /tmp/fuzz_artifacts + if-no-files-found: ignore + retention-days: 30 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..fedeefa8d870 --- /dev/null +++ b/contrib/fuzz/continuous_fuzz_daemon.sh @@ -0,0 +1,316 @@ +#!/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 + +# Resolve repo root from the script location so defaults work from any cwd. +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" + +# --- 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 +# Keep leak detection enabled so the daemon surfaces leak findings the same way +# .github/workflows/test-fuzz.yml does; noisy dependency leaks are filtered via the +# LSAN suppressions file rather than globally disabled. +DAEMON_ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1" +DAEMON_LSAN_OPTIONS="suppressions=${REPO_ROOT}/test/sanitizer_suppressions/lsan" + +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 +} + +count_crash_artifacts() { + local crash_dir="$1" + find "$crash_dir" -type f \( -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' -o -name 'leak-*' \) 2>/dev/null | wc -l | tr -d '[:space:]' +} + +# --- 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]+$ ]] || (( TIME_PER_TARGET < 1 )); 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]+$ ]] || (( RSS_LIMIT_MB < 1 )); 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>/dev/null || 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 artifacts_before + artifacts_before=$(count_crash_artifacts "$target_crashes") + + local exit_code=0 + if [[ -n "$TIMEOUT_BIN" ]]; then + FUZZ="$target" \ + ASAN_OPTIONS="${ASAN_OPTIONS:+${ASAN_OPTIONS}:}${DAEMON_ASAN_OPTIONS}" \ + LSAN_OPTIONS="${LSAN_OPTIONS:+${LSAN_OPTIONS}:}${DAEMON_LSAN_OPTIONS}" \ + UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:report_error_type=1" \ + "$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="${ASAN_OPTIONS:+${ASAN_OPTIONS}:}${DAEMON_ASAN_OPTIONS}" \ + LSAN_OPTIONS="${LSAN_OPTIONS:+${LSAN_OPTIONS}:}${DAEMON_LSAN_OPTIONS}" \ + UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:report_error_type=1" \ + "$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 + + local artifacts_after + artifacts_after=$(count_crash_artifacts "$target_crashes") + local new_artifacts=$((artifacts_after - artifacts_before)) + + if [[ "$new_artifacts" -gt 0 ]]; then + log "CRASH" "Target '${target}' produced ${new_artifacts} new crash artifact(s)!" + log "CRASH" "Artifacts saved to: ${target_crashes}/" + + # Extract crash details from log + while IFS= read -r line; do + log "CRASH" " $line" + done < <(grep -E "SUMMARY|ERROR|BINGO|LeakSanitizer|crash-|timeout-|oom-|leak-" "$target_log" 2>/dev/null || true) + 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 + + [[ "$new_artifacts" -eq 0 && "$exit_code" -eq 0 ]] +} + +# --- 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=$(count_crash_artifacts "$CRASHES_DIR") + local cycle_failures=0 + + # Shuffle targets each cycle for variety + local shuffled + shuffled=$(echo "$targets" | shuffle_lines) + + while IFS= read -r target; do + [[ -z "$target" ]] && continue + if ! run_target "$target"; then + cycle_failures=$((cycle_failures + 1)) + fi + done <<< "$shuffled" + + # Cycle summary + local total_corpus + total_corpus=$(du -sh "$CORPUS_DIR" 2>/dev/null | cut -f1) + local total_crashes + total_crashes=$(count_crash_artifacts "$CRASHES_DIR") + local new_crashes=$((total_crashes - crashes_before)) + log "INFO" "=== Cycle ${cycle} complete: total corpus=${total_corpus}, new crashes=${new_crashes}, total crashes=${total_crashes}, failed targets=${cycle_failures} ===" + + if $SINGLE_CYCLE; then + if [[ "$cycle_failures" -gt 0 ]]; then + log "WARN" "Single-cycle mode — exiting with ${cycle_failures} failed target(s)" + 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..e45f9425b31c --- /dev/null +++ b/contrib/fuzz/seed_corpus_from_chain.py @@ -0,0 +1,1435 @@ +#!/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 os +import re +import subprocess +import sys +import tempfile +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 _read_protocol_version(): + """Read PROTOCOL_VERSION from src/version.h.""" + version_header = Path(__file__).resolve().parents[2] / "src" / "version.h" + match = re.search( + r"static\s+const\s+int\s+PROTOCOL_VERSION\s*=\s*(\d+)\s*;", + version_header.read_text(encoding="utf-8"), + ) + if not match: + raise RuntimeError(f"Could not parse PROTOCOL_VERSION from {version_header}") + return int(match.group(1)) + + +# The stream version is needed by several fuzz harnesses that read a 4-byte +# little-endian int from the start of the buffer and use it as the stream +# version before deserializing the object: +# * The Dash-specific helpers DashDeserializeFromFuzzingInput / +# DashRoundtripFromFuzzingInput (src/test/fuzz/deserialize_dash.cpp, +# src/test/fuzz/roundtrip_dash.cpp), used by dash_*_deserialize and +# dash_*_roundtrip targets. +# * The upstream block target (src/test/fuzz/block.cpp), which reads the +# version int inline before deserializing CBlock. +# * The upstream block_deserialize target (src/test/fuzz/deserialize.cpp), +# via DeserializeFromFuzzingInput when no explicit protocol_version is +# passed. +# Chain data we extract is serialized at PROTOCOL_VERSION, so we prepend +# that value to seeds for those targets. The lookup is deferred to first use +# so `--help` and callers that supply --stream-version / DASH_FUZZ_STREAM_VERSION +# don't require an in-tree src/version.h. +_STREAM_VERSION_OVERRIDE = None +_STREAM_VERSION_CACHE = None + + +def _resolve_stream_version(): + """Return the stream version, preferring an explicit override and falling back + to parsing src/version.h. Cached after first successful resolution.""" + global _STREAM_VERSION_CACHE + if _STREAM_VERSION_CACHE is not None: + return _STREAM_VERSION_CACHE + if _STREAM_VERSION_OVERRIDE is not None: + _STREAM_VERSION_CACHE = _STREAM_VERSION_OVERRIDE + return _STREAM_VERSION_CACHE + env_override = os.environ.get("DASH_FUZZ_STREAM_VERSION") + if env_override: + try: + _STREAM_VERSION_CACHE = int(env_override) + except ValueError as e: + raise RuntimeError( + f"DASH_FUZZ_STREAM_VERSION must be an integer, got {env_override!r}" + ) from e + return _STREAM_VERSION_CACHE + _STREAM_VERSION_CACHE = _read_protocol_version() + return _STREAM_VERSION_CACHE + + +def _stream_version_prefix(): + return _resolve_stream_version().to_bytes(4, byteorder="little", signed=False) + +# Non-Dash targets (outside the dash_* naming convention) whose harnesses +# also consume the 4-byte stream version prefix described above. +_NON_DASH_STREAM_VERSION_TARGETS = frozenset({"block", "block_deserialize", "blockmerkleroot"}) +_DESERIALIZE_ONLY_DASH_TARGETS = frozenset( + { + "dash_governance_vote_deserialize", + "dash_vote_instance_deserialize", + "dash_vote_rec_deserialize", + "dash_governance_vote_file_deserialize", + "dash_mnhf_tx_deserialize", + } +) + + +def _needs_stream_version_prefix(target_name): + """Return True if this target's harness consumes a 4-byte stream version prefix. + + Matches the DashDeserializeFromFuzzingInput / DashRoundtripFromFuzzingInput + helpers used by every dash_*_deserialize and dash_*_roundtrip target, plus + the upstream ``block`` and ``block_deserialize`` targets which do the same + (see src/test/fuzz/block.cpp and src/test/fuzz/deserialize.cpp). Other + non-Dash targets (decode_tx, ...) don't consume such a prefix and must be + left untouched. + """ + if target_name in _NON_DASH_STREAM_VERSION_TARGETS: + return True + return target_name.startswith("dash_") and ( + target_name.endswith("_deserialize") or target_name.endswith("_roundtrip") + ) + + +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 + + if _needs_stream_version_prefix(target_name): + raw_bytes = _stream_version_prefix() + raw_bytes + + 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 _compact_size(n): + """Encode an unsigned integer as a Bitcoin/Dash CompactSize (aka VarInt).""" + if n < 253: + return bytes([n]) + if n < 0x10000: + return b"\xfd" + n.to_bytes(2, "little") + if n < 0x100000000: + return b"\xfe" + n.to_bytes(4, "little") + return b"\xff" + n.to_bytes(8, "little") + + +def _var_bytes(b): + """CompactSize length prefix + the bytes themselves (vector).""" + return _compact_size(len(b)) + b + + +def _var_list(items): + """CompactSize length prefix + concatenated serialized entries.""" + return _compact_size(len(items)) + b"".join(items) + + +def _serialize_int32(value): + return int(value).to_bytes(4, "little", signed=True) + + +def _serialize_uint32(value): + return int(value).to_bytes(4, "little", signed=False) + + +def _serialize_int64(value): + return int(value).to_bytes(8, "little", signed=True) + + +def _serialize_uint64(value): + return int(value).to_bytes(8, "little", signed=False) + + +def _serialize_bool(value): + return bytes([1 if value else 0]) + + +def _uint256_from_hex(h): + """Convert an RPC-form uint256 hex string (big-endian display) to its 32-byte + wire representation (little-endian internal). Empty/missing input -> zero hash. + """ + if not h: + return b"\x00" * 32 + raw = bytes.fromhex(h) + if len(raw) != 32: + raise ValueError(f"uint256 hex must be 32 bytes, got {len(raw)}") + return raw[::-1] + + +def _parse_outpoint_short(s): + """Parse a masternode-outpoint short form 'txid-n' into wire bytes + (uint256 hash + uint32 n). Returns zeroed outpoint on missing/invalid input. + RPC exposes the signing masternode outpoint as "txid-n" (COutPoint::ToStringShort).""" + if not s or "-" not in s: + return b"\x00" * 32 + (0).to_bytes(4, "little") + txid, _, idx = s.rpartition("-") + try: + n = int(idx) + except ValueError: + return b"\x00" * 32 + (0).to_bytes(4, "little") + try: + return _uint256_from_hex(txid) + n.to_bytes(4, "little") + except ValueError: + return b"\x00" * 32 + (0).to_bytes(4, "little") + + +def _serialize_outpoint(txid_hex="", n=0): + return _uint256_from_hex(txid_hex) + _serialize_uint32(n) + + +def _serialize_dynbitset(bits): + count = len(bits) + nbytes = (count + 7) // 8 + buf = bytearray(nbytes) + for i, value in enumerate(bits): + if value: + buf[i // 8] |= 1 << (i % 8) + return _compact_size(count) + bytes(buf) + + +def _serialize_string(value): + return _var_bytes(value.encode("utf-8")) + + +def _serialize_txin(prev_txid_hex="", prev_n=0, script_sig=b"", sequence=0xFFFFFFFF): + return _serialize_outpoint(prev_txid_hex, prev_n) + _var_bytes(script_sig) + _serialize_uint32(sequence) + + +def _serialize_txout(value=0, script_pubkey=b""): + return int(value).to_bytes(8, "little", signed=True) + _var_bytes(script_pubkey) + + +def _serialize_transaction(vin=None, vout=None, n_version=2, n_type=0, n_lock_time=0, extra_payload=b""): + vin = list(vin or []) + vout = list(vout or []) + n32bit_version = (int(n_version) & 0xFFFF) | ((int(n_type) & 0xFFFF) << 16) + out = _serialize_uint32(n32bit_version) + out += _var_list(vin) + out += _var_list(vout) + out += _serialize_uint32(n_lock_time) + if n_version >= 3 and n_type != 0: + out += _var_bytes(extra_payload) + return out + + +def _final_commitment_from_tx_payload(payload_hex): + """Extract the embedded CFinalCommitment from a CFinalCommitmentTxPayload.""" + try: + payload = bytes.fromhex(payload_hex) + except ValueError as e: + raise ValueError("payload is not valid hex") from e + if len(payload) < 6: + raise ValueError("payload too short for CFinalCommitmentTxPayload header") + return payload[6:] + + +_GOVERNANCE_OUTCOME_NAME_TO_INT = { + "none": 0, + "yes": 1, + "no": 2, + "abstain": 3, +} + +_GOVERNANCE_SIGNAL_NAME_TO_INT = { + "none": 0, + "funding": 1, + "valid": 2, + "delete": 3, + "endorsed": 4, +} + + +def parse_governance_vote_record(vote_record, parent_hash_hex): + """Parse one `gobject getcurrentvotes` string into CGovernanceVote fields.""" + if not isinstance(vote_record, str): + raise ValueError("vote record must be a string") + parts = vote_record.split(":") + if len(parts) != 5: + raise ValueError(f"unexpected governance vote format: {vote_record!r}") + outpoint_text, timestamp_text, outcome_text, signal_text, _vote_weight = parts + if "-" not in outpoint_text: + raise ValueError(f"unexpected masternode outpoint format: {outpoint_text!r}") + txid_text, _, n_text = outpoint_text.rpartition("-") + try: + timestamp = int(timestamp_text) + except ValueError as e: + raise ValueError(f"invalid governance vote timestamp: {timestamp_text!r}") from e + outcome = _GOVERNANCE_OUTCOME_NAME_TO_INT.get(outcome_text.lower()) + if outcome is None: + raise ValueError(f"unknown governance vote outcome: {outcome_text!r}") + signal = _GOVERNANCE_SIGNAL_NAME_TO_INT.get(signal_text.lower()) + if signal is None: + raise ValueError(f"unknown governance vote signal: {signal_text!r}") + return { + "masternode_txid": txid_text, + "masternode_n": int(n_text), + "parent_hash": parent_hash_hex, + "outcome": outcome, + "signal": signal, + "timestamp": timestamp, + "signature": b"", + } + + +def serialize_governance_vote(parsed_vote): + """Serialize CGovernanceVote exactly as in src/governance/vote.h.""" + return ( + _serialize_outpoint(parsed_vote["masternode_txid"], parsed_vote["masternode_n"]) + + _uint256_from_hex(parsed_vote["parent_hash"]) + + _serialize_int32(parsed_vote["outcome"]) + + _serialize_int32(parsed_vote["signal"]) + + _serialize_int64(parsed_vote["timestamp"]) + + _var_bytes(parsed_vote.get("signature", b"")) + ) + + +def serialize_vote_instance(outcome, updated_time, creation_time): + """Serialize vote_instance_t exactly as in src/governance/object.h.""" + return _serialize_int32(outcome) + _serialize_int64(updated_time) + _serialize_int64(creation_time) + + +def serialize_vote_rec(signal_to_instance): + """Serialize vote_rec_t (std::map).""" + items = [] + for signal, instance in sorted(signal_to_instance.items()): + items.append(_serialize_int32(signal) + instance) + return _var_list(items) + + +def serialize_governance_vote_file(votes): + """Serialize CGovernanceObjectVoteFile from a list of serialized CGovernanceVote entries.""" + return _serialize_int32(len(votes)) + _var_list(votes) + + +def serialize_governance_object(obj_data): + """Serialize a structurally valid Governance::Object from a `gobject list` RPC entry. + + The fuzz target ``dash_governance_object_common_deserialize`` reads a full + Governance::Object (see src/governance/common.h SERIALIZE_METHODS). The RPC + exposes only a subset of the fields; the rest are filled with documented + best-effort defaults so the seed is structurally sound rather than random bytes. + + Field sources: + * ``hashParent`` — not exposed by RPC (root-object semantics); zeroed. + * ``revision`` — not exposed by RPC; defaulted to 1. + * ``time`` — from ``CreationTime`` (int64 seconds). + * ``collateralHash`` — from ``CollateralHash`` (hex, RPC display order). + * ``vchData`` — from ``DataHex`` (raw payload bytes). + * ``type`` — from ``ObjectType`` (int: UNKNOWN=0, PROPOSAL=1, TRIGGER=2). + * ``masternodeOutpoint`` — from ``SigningMasternode`` ("txid-n"), if present. + * ``vchSig`` — not exposed by RPC; left empty. + """ + hash_parent = b"\x00" * 32 + revision = 1 + try: + time_ = int(obj_data.get("CreationTime", 0)) + except (TypeError, ValueError): + time_ = 0 + try: + collateral_hash = _uint256_from_hex(obj_data.get("CollateralHash", "")) + except ValueError: + collateral_hash = b"\x00" * 32 + + data_hex = obj_data.get("DataHex", "") or "" + try: + vch_data = bytes.fromhex(data_hex) + except ValueError: + vch_data = b"" + + try: + obj_type = int(obj_data.get("ObjectType", 0)) + except (TypeError, ValueError): + obj_type = 0 + + outpoint = _parse_outpoint_short(obj_data.get("SigningMasternode", "")) + vch_sig = b"" + + return ( + hash_parent + + revision.to_bytes(4, "little", signed=True) + + time_.to_bytes(8, "little", signed=True) + + collateral_hash + + _var_bytes(vch_data) + + obj_type.to_bytes(4, "little", signed=True) + + outpoint + + _var_bytes(vch_sig) + ) + + +def serialize_quorum_data_request(llmq_type, quorum_hash_hex, pro_tx_hash_hex="", n_data_mask=3): + """Serialize a llmq::CQuorumDataRequest (src/llmq/quorums.h). + + Wire layout: llmqType (uint8) + quorumHash (uint256) + nDataMask (uint16 LE) + + proTxHash (uint256) + nError (uint8, NONE=0). nError is optional per the + SERIALIZE_METHODS (try/catch on read, conditional on write) — we include it + with NONE so the seed round-trips cleanly. + + nDataMask defaults to QUORUM_VERIFICATION_VECTOR|ENCRYPTED_CONTRIBUTIONS (0x3) + for maximal coverage; RPC doesn't expose a concrete in-flight request. + """ + return ( + bytes([llmq_type & 0xFF]) + + _uint256_from_hex(quorum_hash_hex) + + (n_data_mask & 0xFFFF).to_bytes(2, "little") + + _uint256_from_hex(pro_tx_hash_hex) + + bytes([0]) # nError = NONE + ) + + +def serialize_get_quorum_rotation_info(block_request_hash_hex, base_block_hashes_hex, extra_share=False): + """Serialize a llmq::CGetQuorumRotationInfo (src/llmq/snapshot.h). + + Wire layout: vector baseBlockHashes + uint256 blockRequestHash + bool extraShare. + """ + out = _compact_size(len(base_block_hashes_hex)) + for h in base_block_hashes_hex: + out += _uint256_from_hex(h) + out += _uint256_from_hex(block_request_hash_hex) + out += bytes([1 if extra_share else 0]) + return out + + +def serialize_quorum_snapshot(active_quorum_members, skip_list, mn_skip_list_mode=0): + """Serialize a llmq::CQuorumSnapshot (src/llmq/snapshot.h). + + Wire layout: mnSkipListMode (int, 4 bytes LE — SnapshotSkipMode enum underlying type is int) + + CompactSize(len(activeQuorumMembers)) + WriteFixedBitSet(activeQuorumMembers) + + CompactSize(len(mnSkipList)) + int32 LE for each skip-list entry. + """ + count = len(active_quorum_members) + out = mn_skip_list_mode.to_bytes(4, "little", signed=True) + out += _compact_size(count) + + nbytes = (count + 7) // 8 + buf = bytearray(nbytes) + for i, v in enumerate(active_quorum_members): + if v: + buf[i // 8] |= 1 << (i % 8) + out += bytes(buf) + + out += _compact_size(len(skip_list)) + for x in skip_list: + out += int(x).to_bytes(4, "little", signed=True) + return out + + +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 + if save_corpus_input(output_dir, "blockmerkleroot", 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, "false", block_hash, 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 + + if tx_type == 6: + try: + commitment_hex = _final_commitment_from_tx_payload(payload_hex).hex() + except ValueError as e: + print( + f"WARNING: Skipping final commitment seed for tx {txid}: {e}", + file=sys.stderr, + ) + else: + for suffix in ["_deserialize", "_roundtrip"]: + if save_corpus_input(output_dir, f"dash_final_commitment{suffix}", commitment_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) into structurally valid seeds. + + The fuzz target deserializes a full Governance::Object, not raw payload bytes, + so we reconstruct the object from the RPC fields (CollateralHash, CreationTime, + ObjectType, SigningMasternode, DataHex) and synthesize best-effort defaults for + fields the RPC doesn't expose (hashParent, revision, vchSig). CGovernanceObject's + non-disk serialization is just ``m_obj`` (see src/governance/object.h:248) so the + same bytes are valid seeds for dash_governance_object_{deserialize,roundtrip}. + """ + print("Extracting governance objects...") + result = dash_cli("gobject", "list", "all", datadir=datadir) + if not result: + return 0 + + saved = 0 + try: + objects = json.loads(result) + except (json.JSONDecodeError, AttributeError): + return 0 + + if not isinstance(objects, dict): + return 0 + + targets = [ + "dash_governance_object_common_deserialize", + "dash_governance_object_deserialize", + "dash_governance_object_roundtrip", + ] + vote_targets = ["dash_governance_vote_deserialize"] + vote_instance_targets = ["dash_vote_instance_deserialize"] + vote_rec_targets = ["dash_vote_rec_deserialize"] + vote_file_targets = ["dash_governance_vote_file_deserialize"] + + for obj_hash, obj_data in objects.items(): + if not isinstance(obj_data, dict): + continue + try: + serialized = serialize_governance_object(obj_data) + except (ValueError, OverflowError) as e: + print(f"WARNING: Skipping governance object {obj_hash}: {e}", file=sys.stderr) + continue + seed_hex = serialized.hex() + for target in targets: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + + votes_result = dash_cli("gobject", "getcurrentvotes", obj_hash, datadir=datadir) + if not votes_result: + continue + try: + votes_map = json.loads(votes_result) + except (json.JSONDecodeError, AttributeError): + continue + if not isinstance(votes_map, dict): + continue + + serialized_votes = [] + signal_to_instance = {} + for vote_hash, vote_record in votes_map.items(): + del vote_hash # Key is informational only; serialized vote recomputes its own hash. + try: + parsed_vote = parse_governance_vote_record(vote_record, obj_hash) + serialized_vote = serialize_governance_vote(parsed_vote) + except (ValueError, OverflowError) as e: + print(f"WARNING: Skipping governance vote for {obj_hash}: {e}", file=sys.stderr) + continue + + serialized_votes.append(serialized_vote) + vote_hex = serialized_vote.hex() + for target in vote_targets: + if save_corpus_input(output_dir, target, vote_hex): + saved += 1 + + updated_time = parsed_vote["timestamp"] + instance = serialize_vote_instance( + parsed_vote["outcome"], updated_time, updated_time + ) + signal_to_instance[parsed_vote["signal"]] = instance + instance_hex = instance.hex() + for target in vote_instance_targets: + if save_corpus_input(output_dir, target, instance_hex): + saved += 1 + + if signal_to_instance: + vote_rec_hex = serialize_vote_rec(signal_to_instance).hex() + for target in vote_rec_targets: + if save_corpus_input(output_dir, target, vote_rec_hex): + saved += 1 + + if serialized_votes: + vote_file_hex = serialize_governance_vote_file(serialized_votes).hex() + for target in vote_file_targets: + if save_corpus_input(output_dir, target, vote_file_hex): + saved += 1 + + 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 + + state = mn.get("state") + if not isinstance(state, dict): + print(f"WARNING: Missing state for protx {protx_hash}, skipping", file=sys.stderr) + continue + + registered_height = state.get("registeredHeight") + try: + registered_height = int(registered_height) + except (TypeError, ValueError): + print( + f"WARNING: Invalid registeredHeight for protx {protx_hash}: {registered_height!r}", + file=sys.stderr, + ) + continue + + block_hash = dash_cli("getblockhash", str(registered_height), datadir=datadir) + if not block_hash: + continue + + block_json = dash_cli("getblock", block_hash, "1", datadir=datadir) + if not block_json: + continue + + try: + block = json.loads(block_json) + except json.JSONDecodeError: + continue + + if protx_hash not in block.get("tx", []): + print( + f"WARNING: ProRegTx {protx_hash} not found in registeredHeight block {registered_height} ({block_hash}), skipping", + file=sys.stderr, + ) + continue + + raw_tx = dash_cli("getrawtransaction", protx_hash, "false", block_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", block_hash, 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): + """Seed the quorum-specific P2P/message fuzz targets from live chain data. + + Drives three fuzz-target families (each with _deserialize and _roundtrip): + * dash_quorum_data_request_* — CQuorumDataRequest (QGETDATA message) + * dash_get_quorum_rotation_info_* — CGetQuorumRotationInfo (QGETRTINFO message) + * dash_quorum_snapshot_* — CQuorumSnapshot (QRINFO payload piece) + + We derive inputs from ``quorum list`` + ``quorum info`` + ``quorum rotationinfo``: + + * CQuorumDataRequest: if ``quorum info`` exposes a real member proTxHash we + use it with nDataMask=3 (VV|EC); otherwise we fall back to a zero proTxHash + with nDataMask=1 (verification-vector only). Both shapes are structurally + valid per the protocol. + * CQuorumSnapshot: prefer snapshots returned by ``quorum rotationinfo`` + (``quorumSnapshotAtHMinus{C,2C,3C}`` and ``quorumSnapshotList``), whose + ``activeQuorumMembers`` / ``mnSkipListMode`` / ``mnSkipList`` fields are + serialized verbatim. Fall back to the ``quorum info`` member-validity + bitmap with an empty skip list + MODE_NO_SKIPPING if rotationinfo is + unavailable for that quorum. + * CGetQuorumRotationInfo: serialize the exact requests that rotationinfo + actually answered (blockRequestHash == quorumHash, empty baseBlockHashes, + extraShare=false). Only fall back to a tip-hash + quorum-hash composite + when no rotationinfo request succeeded. + """ + print("Extracting quorum data...") + result = dash_cli("quorum", "list", datadir=datadir) + if not result: + return 0 + + # quorum info expects a numeric llmqType, but quorum list returns string keys + llmq_type_map = { + "llmq_50_60": 1, + "llmq_400_60": 2, + "llmq_400_85": 3, + "llmq_100_67": 4, + "llmq_60_75": 5, + "llmq_25_67": 6, + "llmq_test": 100, + "llmq_devnet": 101, + "llmq_test_v17": 102, + "llmq_test_dip0024": 103, + "llmq_test_instantsend": 104, + "llmq_devnet_dip0024": 105, + "llmq_test_platform": 106, + "llmq_devnet_platform": 107, + } + + saved = 0 + try: + quorum_list = json.loads(result) + except (json.JSONDecodeError, AttributeError): + return 0 + + if not isinstance(quorum_list, dict): + return 0 + + # Collect quorum hashes across types (used as a fallback for + # CGetQuorumRotationInfo seeds if no rotationinfo request succeeds). + all_quorum_hashes = [] + # Track the exact (blockRequestHash, baseBlockHashes, extraShare) tuples + # for which we successfully invoked `quorum rotationinfo` — these become + # the preferred CGetQuorumRotationInfo seeds. + successful_rotation_requests = [] + + for qtype, hashes in quorum_list.items(): + numeric_type = llmq_type_map.get(qtype) + if numeric_type is None: + print(f"WARNING: Unknown quorum type '{qtype}', skipping", file=sys.stderr) + continue + if not isinstance(hashes, list): + continue + + for qhash in hashes[:5]: # Limit per type + if not isinstance(qhash, str) or not qhash: + continue + all_quorum_hashes.append(qhash) + + # Fetch quorum info once up-front so we can both pull a real + # member proTxHash for CQuorumDataRequest and use its + # member-validity bitmap as a CQuorumSnapshot fallback. + qinfo = None + qinfo_str = dash_cli("quorum", "info", str(numeric_type), qhash, datadir=datadir) + if qinfo_str: + try: + parsed = json.loads(qinfo_str) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + qinfo = parsed + + # --- Seed dash_quorum_data_request_{deserialize,roundtrip} --- + # If we have a real member proTxHash, emit a VV|EC request (mask=3); + # otherwise emit a zero-proTxHash VV-only request (mask=1). Both + # match the CQuorumDataRequest protocol surface. + member_protx = "" + if qinfo: + members = qinfo.get("members", []) + if isinstance(members, list): + for m in members: + if not isinstance(m, dict): + continue + p = m.get("proTxHash", "") + if isinstance(p, str) and p: + member_protx = p + break + n_data_mask = 3 if member_protx else 1 + try: + qdr = serialize_quorum_data_request( + numeric_type, qhash, member_protx, n_data_mask=n_data_mask + ) + except ValueError as e: + print(f"WARNING: Skipping CQuorumDataRequest seed for {qhash}: {e}", file=sys.stderr) + else: + seed_hex = qdr.hex() + for target in [ + "dash_quorum_data_request_deserialize", + "dash_quorum_data_request_roundtrip", + ]: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + + # --- Seed dash_quorum_snapshot_{deserialize,roundtrip} --- + # Prefer real rotationinfo snapshots when available. + snapshot_seeded = False + rot_str = dash_cli("quorum", "rotationinfo", qhash, "false", datadir=datadir) + rot_info = None + if rot_str: + try: + parsed = json.loads(rot_str) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + rot_info = parsed + successful_rotation_requests.append((qhash, [], False)) + + if rot_info is not None: + snapshot_candidates = [] + for key in ( + "quorumSnapshotAtHMinusC", + "quorumSnapshotAtHMinus2C", + "quorumSnapshotAtHMinus3C", + ): + s = rot_info.get(key) + if isinstance(s, dict): + snapshot_candidates.append(s) + snap_list = rot_info.get("quorumSnapshotList", []) + if isinstance(snap_list, list): + snapshot_candidates.extend(s for s in snap_list if isinstance(s, dict)) + + for snap in snapshot_candidates: + active_raw = snap.get("activeQuorumMembers", []) + skip_list_raw = snap.get("mnSkipList", []) + skip_mode_raw = snap.get("mnSkipListMode", 0) + if not isinstance(active_raw, list): + continue + if not isinstance(skip_list_raw, list): + skip_list_raw = [] + try: + skip_mode_int = int(skip_mode_raw) + except (TypeError, ValueError): + skip_mode_int = 0 + try: + snap_bytes = serialize_quorum_snapshot( + [bool(x) for x in active_raw], + [int(x) for x in skip_list_raw], + mn_skip_list_mode=skip_mode_int, + ) + except (ValueError, OverflowError, TypeError) as e: + print( + f"WARNING: Skipping rotationinfo CQuorumSnapshot seed for {qhash}: {e}", + file=sys.stderr, + ) + continue + seed_hex = snap_bytes.hex() + for target in [ + "dash_quorum_snapshot_deserialize", + "dash_quorum_snapshot_roundtrip", + ]: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + snapshot_seeded = True + + # Fall back to the quorum-info-derived approximation only when + # rotationinfo didn't yield a usable snapshot. + if not snapshot_seeded and qinfo: + members = qinfo.get("members", []) + if isinstance(members, list) and members: + active = [bool(m.get("valid", False)) for m in members if isinstance(m, dict)] + try: + snap = serialize_quorum_snapshot(active, []) + except (ValueError, OverflowError) as e: + print( + f"WARNING: Skipping CQuorumSnapshot seed for {qhash}: {e}", + file=sys.stderr, + ) + else: + seed_hex = snap.hex() + for target in [ + "dash_quorum_snapshot_deserialize", + "dash_quorum_snapshot_roundtrip", + ]: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + + # --- Seed dash_get_quorum_rotation_info_{deserialize,roundtrip} --- + # Prefer the exact requests rotationinfo actually answered. Each is a + # minimal (blockRequestHash, [], extraShare=false) tuple matching the + # CLI call we issued above. + variants = [] + if successful_rotation_requests: + for block_request_hash, base_hashes, extra_share in successful_rotation_requests[:8]: + try: + variants.append( + serialize_get_quorum_rotation_info( + block_request_hash, base_hashes, extra_share=extra_share + ) + ) + except ValueError as e: + print(f"WARNING: Skipping CGetQuorumRotationInfo seed: {e}", file=sys.stderr) + elif all_quorum_hashes: + # Fallback when no rotationinfo call succeeded: synthesize tip-hash + # + quorum-hash variants so the corpus isn't empty. + tip_hash = dash_cli("getbestblockhash", datadir=datadir) + if tip_hash: + try: + variants.append( + serialize_get_quorum_rotation_info( + tip_hash, all_quorum_hashes[:8], extra_share=False + ) + ) + variants.append( + serialize_get_quorum_rotation_info( + tip_hash, all_quorum_hashes[:1], extra_share=True + ) + ) + except ValueError as e: + print(f"WARNING: Skipping CGetQuorumRotationInfo fallback seeds: {e}", file=sys.stderr) + variants = [] + + for seed in variants: + seed_hex = seed.hex() + for target in [ + "dash_get_quorum_rotation_info_deserialize", + "dash_get_quorum_rotation_info_roundtrip", + ]: + if save_corpus_input(output_dir, target, seed_hex): + saved += 1 + + 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 + + minimal_tx = _serialize_transaction() + minimal_txin = _serialize_txin() + minimal_txout = _serialize_txout() + minimal_final_commitment = ( + (3).to_bytes(2, "little") + + bytes([1]) + + b"\x00" * 32 + + _serialize_dynbitset([]) + + _serialize_dynbitset([]) + + b"\x00" * 48 + + b"\x00" * 32 + + b"\x00" * 96 + + b"\x00" * 96 + ) + minimal_governance_vote = serialize_governance_vote( + { + "masternode_txid": "00" * 32, + "masternode_n": 0, + "parent_hash": "00" * 32, + "outcome": 1, + "signal": 1, + "timestamp": 0, + "signature": b"", + } + ) + minimal_vote_instance = serialize_vote_instance(1, 0, 0) + minimal_vote_rec = serialize_vote_rec({1: minimal_vote_instance}) + minimal_vote_file = serialize_governance_vote_file([minimal_governance_vote]) + minimal_bls_ies_blob = b"\x00" * 48 + b"\x00" * 32 + _var_bytes(b"seed") + minimal_bls_ies_multi = b"\x00" * 48 + b"\x00" * 32 + _var_list([_var_bytes(b"seed0"), _var_bytes(b"seed1")]) + minimal_coinjoin_entry = _var_list([minimal_txin]) + minimal_tx + _var_list([minimal_txout]) + minimal_coinjoin_broadcast_tx = minimal_tx + b"\x00" * 32 + _var_bytes(b"") + _serialize_int64(0) + minimal_premature_commitment = ( + bytes([1]) + + b"\x00" * 32 + + b"\x00" * 32 + + _serialize_dynbitset([True]) + + b"\x00" * 48 + + b"\x00" * 32 + + b"\x00" * 96 + + b"\x00" * 96 + ) + + # Targets that need synthetic seeds (serialized structs with known formats) + synthetic_seeds = { + # CoinJoin messages — minimal valid-ish payloads + "dash_coinjoin_accept_deserialize": [ + (_serialize_int32(0) + minimal_tx).hex(), # nDenom + txCollateral + ], + "dash_coinjoin_entry_deserialize": [ + minimal_coinjoin_entry.hex(), + ], + "dash_coinjoin_queue_deserialize": [ + ( + _serialize_int32(0) + + b"\x00" * 32 + + _serialize_int64(0) + + _serialize_bool(False) + + _var_bytes(b"") + ).hex(), + ], + "dash_coinjoin_status_update_deserialize": [ + (_serialize_int32(0) + _serialize_int32(0) + _serialize_int32(0) + _serialize_int32(0)).hex(), + ], + "dash_coinjoin_broadcast_tx_deserialize": [ + minimal_coinjoin_broadcast_tx.hex(), + ], + # LLMQ messages + "dash_final_commitment_deserialize": [ + minimal_final_commitment.hex(), + ], + "dash_final_commitment_tx_payload_deserialize": [ + ((1).to_bytes(2, "little") + _serialize_uint32(0) + minimal_final_commitment).hex(), + ], + "dash_recovered_sig_deserialize": [ + # CRecoveredSig: llmqType (uint8) + quorumHash (32) + id (32) + msgHash (32) + sig (96) + "64" + "00" * 32 + "00" * 32 + "00" * 32 + "00" * 96, + ], + "dash_sig_ses_ann_deserialize": [ + # CSigSesAnn: VARINT(sessionId=0) + llmqType (uint8) + quorumHash (32) + id (32) + msgHash (32) + "00" + "64" + "00" * 32 + "00" * 32 + "00" * 32, + ], + "dash_sig_share_deserialize": [ + # CSigShare: llmqType (uint8) + quorumHash (32) + quorumMember (uint16 LE) + # + id (32) + msgHash (32) + sigShare (96, BLS lazy) + "64" + "00" * 32 + "0000" + "00" * 32 + "00" * 32 + "00" * 96, + ], + # MNAuth + "dash_mnauth_deserialize": [ + "00" * 32 + "00" * 32 + "00" * 96, # proRegTxHash + signChallenge + sig + ], + # DKG messages + "dash_dkg_complaint_deserialize": [ + # CDKGComplaint: llmqType + quorumHash + proTxHash + DYNBITSET(badMembers) + # + DYNBITSET(complainForMembers) + sig (96 bytes) + "64" + "00" * 32 + "00" * 32 + "00" + "00" + "00" * 96, + ], + "dash_dkg_justification_deserialize": [ + # llmqType (uint8) + quorumHash (32) + proTxHash (32) + + # CompactSize(0) for contributions vector + CBLSSignature (96) + ("64" + "00" * 32 + "00" * 32 + "00" + "00" * 96), + ], + "dash_dkg_premature_commitment_deserialize": [ + minimal_premature_commitment.hex(), + ], + # Sig-share inventory / batched messages + "dash_sig_shares_inv_deserialize": [ + # VARINT(sessionId=0) + CompactSize(invSize=0) + AUTOBITSET selector=0 + "000000", + ], + "dash_batched_sig_shares_deserialize": [ + # VARINT(sessionId=0) + CompactSize(0) for empty sigShares vector + "0000", + ], + # MN HF signal + "dash_mnhf_tx_deserialize": [ + # versionBit (uint8) + quorumHash (32) + CBLSSignature non-legacy (96) + ("00" + "00" * 32 + "00" * 96), + ], + # Governance + "dash_governance_vote_deserialize": [ + minimal_governance_vote.hex(), + ], + "dash_vote_instance_deserialize": [ + minimal_vote_instance.hex(), + ], + "dash_vote_rec_deserialize": [ + minimal_vote_rec.hex(), + ], + "dash_governance_vote_file_deserialize": [ + minimal_vote_file.hex(), + ], + # BLS IES + "dash_bls_ies_encrypted_blob_deserialize": [ + minimal_bls_ies_blob.hex(), + ], + "dash_bls_ies_multi_recipient_blobs_deserialize": [ + minimal_bls_ies_multi.hex(), + ], + } + + 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 target not in _DESERIALIZE_ONLY_DASH_TARGETS and save_corpus_input( + output_dir, roundtrip_target, seed_hex + ): + saved += 1 + + print(f" Created {saved} synthetic seed inputs") + return saved + + +def _run_helper_self_checks(): + """Deterministic checks for new governance/final-commitment helpers.""" + parsed_vote = parse_governance_vote_record( + "11" * 32 + "-2:1700000000:yes:funding:1", + "22" * 32, + ) + assert parsed_vote["masternode_txid"] == "11" * 32 + assert parsed_vote["masternode_n"] == 2 + assert parsed_vote["signal"] == 1 + assert parsed_vote["outcome"] == 1 + vote_bytes = serialize_governance_vote(parsed_vote) + assert len(vote_bytes) == 32 + 4 + 32 + 4 + 4 + 8 + 1 + + vote_instance = serialize_vote_instance(1, 1700000000, 1690000000) + assert len(vote_instance) == 20 + vote_rec = serialize_vote_rec({1: vote_instance, 2: serialize_vote_instance(2, 1700000100, 1690000000)}) + assert vote_rec.startswith(b"\x02") + vote_file = serialize_governance_vote_file([vote_bytes]) + assert vote_file[:4] == _serialize_int32(1) + + payload = bytes.fromhex("0100") + _serialize_uint32(42) + b"\xaa\xbb\xcc" + assert _final_commitment_from_tx_payload(payload.hex()) == b"\xaa\xbb\xcc" + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + create_synthetic_seeds(tmp_path) + required_targets = [ + "dash_final_commitment_deserialize", + "dash_final_commitment_roundtrip", + "dash_governance_vote_deserialize", + "dash_vote_instance_deserialize", + "dash_vote_rec_deserialize", + "dash_governance_vote_file_deserialize", + "dash_bls_ies_encrypted_blob_deserialize", + "dash_bls_ies_encrypted_blob_roundtrip", + "dash_bls_ies_multi_recipient_blobs_deserialize", + "dash_bls_ies_multi_recipient_blobs_roundtrip", + "dash_coinjoin_entry_deserialize", + "dash_coinjoin_entry_roundtrip", + "dash_dkg_complaint_deserialize", + "dash_dkg_complaint_roundtrip", + "dash_dkg_justification_deserialize", + "dash_dkg_justification_roundtrip", + "dash_sig_shares_inv_deserialize", + "dash_sig_shares_inv_roundtrip", + "dash_batched_sig_shares_deserialize", + "dash_batched_sig_shares_roundtrip", + "dash_recovered_sig_deserialize", + "dash_recovered_sig_roundtrip", + "dash_sig_ses_ann_deserialize", + "dash_sig_ses_ann_roundtrip", + "dash_sig_share_deserialize", + "dash_sig_share_roundtrip", + "dash_mnauth_deserialize", + "dash_mnauth_roundtrip", + "dash_mnhf_tx_deserialize", + ] + missing = [target for target in required_targets if not any((tmp_path / target).iterdir())] + assert not missing, f"missing synthetic seeds for: {', '.join(missing)}" + + # Assert the LLMQ seed sizes match the C++ serialization layouts so the + # synthetic seeds aren't silently truncated again (regression guard). + def _seed_bytes(target): + files = list((tmp_path / target).iterdir()) + assert len(files) == 1, f"{target}: expected one synthetic seed, got {len(files)}" + return files[0].read_bytes() + + # The Dash deserialize/roundtrip wrappers prepend a 4-byte stream version. + prefix = len(_stream_version_prefix()) + # CRecoveredSig: 1 + 32 + 32 + 32 + 96 = 193 + assert len(_seed_bytes("dash_recovered_sig_deserialize")) == prefix + 193 + # CSigSesAnn (VARINT(0)=1 byte): 1 + 1 + 32 + 32 + 32 = 98 + assert len(_seed_bytes("dash_sig_ses_ann_deserialize")) == prefix + 98 + # CSigShare: 1 + 32 + 2 + 32 + 32 + 96 = 195 + assert len(_seed_bytes("dash_sig_share_deserialize")) == prefix + 195 + # CDKGComplaint with empty bitsets: 1 + 32 + 32 + 1 + 1 + 96 = 163 + assert len(_seed_bytes("dash_dkg_complaint_deserialize")) == prefix + 163 + + # --stream-version override path: setting the override must not require + # src/version.h, and _stream_version_prefix must round-trip the value. + global _STREAM_VERSION_OVERRIDE, _STREAM_VERSION_CACHE + saved_override, saved_cache = _STREAM_VERSION_OVERRIDE, _STREAM_VERSION_CACHE + try: + _STREAM_VERSION_OVERRIDE = 0x12345678 + _STREAM_VERSION_CACHE = None + assert _stream_version_prefix() == b"\x78\x56\x34\x12" + finally: + _STREAM_VERSION_OVERRIDE = saved_override + _STREAM_VERSION_CACHE = saved_cache + + +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)" + ) + parser.add_argument( + "--stream-version", + type=int, + default=None, + help=( + "Stream version (4-byte LE prefix) to use for harnesses that consume one. " + "Overrides DASH_FUZZ_STREAM_VERSION and the src/version.h fallback. " + "Useful when running outside an in-tree source checkout." + ), + ) + args = parser.parse_args() + + if args.stream_version is not None: + global _STREAM_VERSION_OVERRIDE + _STREAM_VERSION_OVERRIDE = args.stream_version + + 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 375c9940d059..39ace6522236 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -278,11 +278,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 \ @@ -290,6 +292,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 \ @@ -305,18 +308,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 \ @@ -342,11 +350,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 \ @@ -361,6 +371,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/coinjoin/coinjoin.cpp b/src/coinjoin/coinjoin.cpp index 010a81c877b7..1f1d10f0463b 100644 --- a/src/coinjoin/coinjoin.cpp +++ b/src/coinjoin/coinjoin.cpp @@ -57,6 +57,7 @@ bool CCoinJoinQueue::CheckSignature(const CBLSPublicKey& blsPubKey) const bool CCoinJoinQueue::IsTimeOutOfBounds(int64_t current_time) const { + // Both operands are gated to be non-negative, so the differences cannot overflow int64_t. if (current_time < 0 || nTime < 0) return true; return current_time - nTime > COINJOIN_QUEUE_TIMEOUT || nTime - current_time > COINJOIN_QUEUE_TIMEOUT; diff --git a/src/coinjoin/common.h b/src/coinjoin/common.h index 3d97d639434d..9f0963afe1c2 100644 --- a/src/coinjoin/common.h +++ b/src/coinjoin/common.h @@ -128,6 +128,8 @@ constexpr int CalculateAmountPriority(CAmount nInputAmount) } //nondenom return largest first + // nInputAmount is bounded to [0, MAX_MONEY] by the guard at the top of this function, + // so (nInputAmount / COIN) fits comfortably in int. return -1 * (nInputAmount / COIN); } diff --git a/src/llmq/snapshot.h b/src/llmq/snapshot.h index 6877366f2e6d..60e5bf35dff2 100644 --- a/src/llmq/snapshot.h +++ b/src/llmq/snapshot.h @@ -78,6 +78,12 @@ class CQuorumSnapshot size_t cnt = ReadCompactSize(s); ReadFixedBitSet(s, activeQuorumMembers, cnt); cnt = ReadCompactSize(s); + // Reset the destination so in-place re-deserialization (e.g. via CDBWrapper::Read + // reusing an existing snapshot object) doesn't append onto stale entries. + // Note: intentionally no reserve(cnt) — cnt comes from an unbounded ReadCompactSize + // on a P2P-reachable path (QRINFO), and eager reservation would let a peer force + // large allocations from a tiny message. + mnSkipList.clear(); for ([[maybe_unused]] const auto _ : util::irange(cnt)) { int obj; s >> obj; diff --git a/src/test/fuzz/addrman.cpp b/src/test/fuzz/addrman.cpp index 4847ce01581e..d1769c811426 100644 --- a/src/test/fuzz/addrman.cpp +++ b/src/test/fuzz/addrman.cpp @@ -247,6 +247,8 @@ FUZZ_TARGET(addrman, .init = initialize_addrman) ds >> *addr_man_ptr; } catch (const std::ios_base::failure&) { addr_man_ptr = std::make_unique(netgroupman, fuzzed_data_provider); + } catch (const DbInconsistentError&) { + addr_man_ptr = std::make_unique(netgroupman, fuzzed_data_provider); } } AddrManDeterministic& addr_man = *addr_man_ptr; 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