From d044a83ede163ca2a0d17233b8ce0d42500d7b5d Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:24:22 +0530 Subject: [PATCH 01/21] fix(build): define schema to meet new `bear` validation rules --- src/.bear-tidy-config | 1 + 1 file changed, 1 insertion(+) diff --git a/src/.bear-tidy-config b/src/.bear-tidy-config index fd12f3d130ba..b4a22b96a696 100644 --- a/src/.bear-tidy-config +++ b/src/.bear-tidy-config @@ -1,4 +1,5 @@ { + "schema": "4.0", "output": { "content": { "include_only_existing_source": true, From 3dfa8ed36dd7097ad1aa0168ed888ae8b3faba87 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:24:44 +0530 Subject: [PATCH 02/21] fix: sync and pin dependencies between `ci-slim` and `04-install.sh` --- ci/lint/04_install.sh | 2 ++ contrib/containers/ci/ci-slim.Dockerfile | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ci/lint/04_install.sh b/ci/lint/04_install.sh index 9ca5ebe37216..64740093ccf9 100755 --- a/ci/lint/04_install.sh +++ b/ci/lint/04_install.sh @@ -36,7 +36,9 @@ fi # NOTE: BUMP ALSO contrib/containers/ci/ci-slim.Dockerfile ${CI_RETRY_EXE} pip3 install codespell==2.2.1 ${CI_RETRY_EXE} pip3 install flake8==5.0.4 +${CI_RETRY_EXE} pip3 install jinja2==3.1.6 ${CI_RETRY_EXE} pip3 install lief==0.13.2 +${CI_RETRY_EXE} pip3 install multiprocess==0.70.19 ${CI_RETRY_EXE} pip3 install mypy==0.981 ${CI_RETRY_EXE} pip3 install pyzmq==24.0.1 ${CI_RETRY_EXE} pip3 install vulture==2.6 diff --git a/contrib/containers/ci/ci-slim.Dockerfile b/contrib/containers/ci/ci-slim.Dockerfile index 5332f8504a57..1ed0eec9e0e0 100644 --- a/contrib/containers/ci/ci-slim.Dockerfile +++ b/contrib/containers/ci/ci-slim.Dockerfile @@ -79,9 +79,9 @@ ENV UV_SYSTEM_PYTHON=1 RUN uv pip install --system --break-system-packages \ codespell==2.2.1 \ flake8==5.0.4 \ - jinja2 \ + jinja2==3.1.6 \ lief==0.13.2 \ - multiprocess \ + multiprocess==0.70.19 \ mypy==0.981 \ pyzmq==24.0.1 \ vulture==2.6 From c9465e8a495893df28f3cf9e94977b231879b994 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:25:44 +0530 Subject: [PATCH 03/21] build: add pinned `aiohttp` and `tabulate` Python dependencies --- ci/lint/04_install.sh | 2 ++ contrib/containers/ci/ci-slim.Dockerfile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/ci/lint/04_install.sh b/ci/lint/04_install.sh index 64740093ccf9..0d8ac8312e53 100755 --- a/ci/lint/04_install.sh +++ b/ci/lint/04_install.sh @@ -34,6 +34,7 @@ if [ -z "${SKIP_PYTHON_INSTALL}" ]; then fi # NOTE: BUMP ALSO contrib/containers/ci/ci-slim.Dockerfile +${CI_RETRY_EXE} pip3 install aiohttp==3.13.3 ${CI_RETRY_EXE} pip3 install codespell==2.2.1 ${CI_RETRY_EXE} pip3 install flake8==5.0.4 ${CI_RETRY_EXE} pip3 install jinja2==3.1.6 @@ -41,6 +42,7 @@ ${CI_RETRY_EXE} pip3 install lief==0.13.2 ${CI_RETRY_EXE} pip3 install multiprocess==0.70.19 ${CI_RETRY_EXE} pip3 install mypy==0.981 ${CI_RETRY_EXE} pip3 install pyzmq==24.0.1 +${CI_RETRY_EXE} pip3 install tabulate==0.10.0 ${CI_RETRY_EXE} pip3 install vulture==2.6 SHELLCHECK_VERSION=v0.8.0 diff --git a/contrib/containers/ci/ci-slim.Dockerfile b/contrib/containers/ci/ci-slim.Dockerfile index 1ed0eec9e0e0..a2f13d28ea69 100644 --- a/contrib/containers/ci/ci-slim.Dockerfile +++ b/contrib/containers/ci/ci-slim.Dockerfile @@ -77,6 +77,7 @@ ENV UV_SYSTEM_PYTHON=1 # Install Python packages # NOTE: if versions are changed, update ci/lint/04_install.sh RUN uv pip install --system --break-system-packages \ + aiohttp==3.13.3 \ codespell==2.2.1 \ flake8==5.0.4 \ jinja2==3.1.6 \ @@ -84,6 +85,7 @@ RUN uv pip install --system --break-system-packages \ multiprocess==0.70.19 \ mypy==0.981 \ pyzmq==24.0.1 \ + tabulate==0.10.0 \ vulture==2.6 # Install packages relied on by tests From 9e2f44f1b4b9402e20fc904c82d825719e86b70e Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:51:12 +0530 Subject: [PATCH 04/21] test: add bench framework skeleton and example benchmark --- Makefile.am | 3 +- test/bench/bench_framework.py | 126 ++++++++++++++++++++++++++++++++++ test/bench/example_bench.py | 44 ++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100755 test/bench/bench_framework.py create mode 100755 test/bench/example_bench.py diff --git a/Makefile.am b/Makefile.am index ea2cba9fd2fa..051fca740ee1 100644 --- a/Makefile.am +++ b/Makefile.am @@ -252,6 +252,7 @@ dist_noinst_SCRIPTS = autogen.sh EXTRA_DIST = $(DIST_SHARE) $(DIST_CONTRIB) $(WINDOWS_PACKAGING) $(OSX_PACKAGING) $(BIN_CHECKS) EXTRA_DIST += \ + test/bench \ test/functional \ test/fuzz @@ -319,5 +320,5 @@ clean-docs: clean-local: clean-docs rm -rf coverage_percent.txt test_dash.coverage/ total.coverage/ fuzz.coverage/ test/tmp/ cache/ $(OSX_APP) - rm -rf test/functional/__pycache__ test/functional/test_framework/__pycache__ test/cache share/rpcauth/__pycache__ + rm -rf test/bench/__pycache__ test/functional/__pycache__ test/functional/test_framework/__pycache__ test/cache share/rpcauth/__pycache__ rm -rf dist/ diff --git a/test/bench/bench_framework.py b/test/bench/bench_framework.py new file mode 100755 index 000000000000..687656019a26 --- /dev/null +++ b/test/bench/bench_framework.py @@ -0,0 +1,126 @@ +#!/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. + +import os +import sys +import time +from typing import Dict, List, Optional + +# Allow imports from the functional test framework. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'functional')) + +from test_framework.test_framework import BitcoinTestFramework # noqa: E402 + + +class BenchFramework(BitcoinTestFramework): + + def set_test_params(self) -> None: + """Initialise benchmark state, then delegate to ``set_bench_params``.""" + self.warmup_iterations: int = 0 + self.bench_iterations: int = 1 + self.bench_name: str = type(self).__name__ + # Raw latency samples keyed by measurement name. + self._samples: Dict[str, List[float]] = {} + self._timer_start: Optional[float] = None + self.set_bench_params() + + def setup_nodes(self) -> None: + """Merge --daemon-args into extra_args just before nodes start.""" + raw = getattr(self.options, "daemon_args", None) or "" + daemon_args = raw.split() if raw else [] + if daemon_args and self.extra_args is not None: + for node_args in self.extra_args: + node_args.extend(daemon_args) + super().setup_nodes() + + def run_test(self) -> None: + """Execute warmup, timed iterations, then report.""" + self.results_file = getattr(self.options, "results_file", None) + if self.warmup_iterations > 0: + self.log.info( + "Warming up (%d iteration%s)...", + self.warmup_iterations, + "s" if self.warmup_iterations != 1 else "", + ) + for i in range(self.warmup_iterations): + self.log.debug(" warmup %d/%d", i + 1, self.warmup_iterations) + self.run_bench() + self._samples.clear() + + self.log.info( + "Running benchmark (%d iteration%s)...", + self.bench_iterations, + "s" if self.bench_iterations != 1 else "", + ) + for i in range(self.bench_iterations): + self.log.debug(" iteration %d/%d", i + 1, self.bench_iterations) + self.run_bench() + + self._report_results() + + def add_options(self, parser) -> None: # type: ignore[override] + """Adds bench-specific args. Subclasses should call super first.""" + parser.add_argument( + "--daemon-args", + dest="daemon_args", + default=None, + help="Extra daemon arguments as a single string " + "(e.g. --daemon-args=\"-rpcworkqueue=1024 -rpcthreads=8\")", + ) + + def set_bench_params(self) -> None: + """Benchmarks must override this to set ``num_nodes``, etc.""" + raise NotImplementedError + + def run_bench(self) -> None: + """Benchmarks must override this to define the workload.""" + raise NotImplementedError + + def start_timer(self) -> None: + """Mark the beginning of a timed section.""" + if self._timer_start is not None: + self.log.warning("start_timer() called twice without stop_timer()") + self._timer_start = time.perf_counter() + + def stop_timer(self, name: str) -> float: + """Record elapsed time (ms) since the last ``start_timer()`` call, returns in ms.""" + if self._timer_start is None: + raise RuntimeError("stop_timer() called without start_timer()") + elapsed_ms = (time.perf_counter() - self._timer_start) * 1000.0 + self._timer_start = None + self._samples.setdefault(name, []).append(elapsed_ms) + return elapsed_ms + + def record_sample(self, name: str, value_ms: float) -> None: + """Directly record a latency sample (ms) without using the timer.""" + self._samples.setdefault(name, []).append(value_ms) + + def _report_results(self) -> None: + """Print a summary of all recorded measurements.""" + self.log.info("=" * 60) + self.log.info("Benchmark: %s", self.bench_name) + self.log.info("=" * 60) + for name, samples in self._samples.items(): + n = len(samples) + if n == 0: + continue + samples_sorted = sorted(samples) + total = sum(samples_sorted) + mean = total / n + p50 = samples_sorted[n // 2] + p99_idx = min(int(n * 0.99), n - 1) + p99 = samples_sorted[p99_idx] + self.log.info( + " %-30s n=%-6d mean=%8.2fms p50=%8.2fms " + "p99=%8.2fms min=%8.2fms max=%8.2fms", + name, n, mean, p50, p99, + samples_sorted[0], samples_sorted[-1], + ) + self.log.info("=" * 60) + + @property + def samples(self) -> Dict[str, List[float]]: + """Access the raw sample data.""" + return self._samples diff --git a/test/bench/example_bench.py b/test/bench/example_bench.py new file mode 100755 index 000000000000..928593653c94 --- /dev/null +++ b/test/bench/example_bench.py @@ -0,0 +1,44 @@ +#!/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. + +from bench_framework import BenchFramework + + +class ExampleBench(BenchFramework): + + def set_test_params(self) -> None: + super().set_test_params() + + def run_test(self) -> None: + super().run_test() + + def set_bench_params(self) -> None: + self.bench_iterations = 3 + self.bench_name = "example_rpc" + self.num_nodes = 1 + self.setup_clean_chain = False + self.warmup_iterations = 1 + + def run_bench(self) -> None: + node = self.nodes[0] + + for _ in range(100): + self.start_timer() + node.getblockcount() + self.stop_timer("getblockcount") + + for _ in range(100): + self.start_timer() + node.getbestblockhash() + self.stop_timer("getbestblockhash") + + for _ in range(50): + self.start_timer() + node.getblockchaininfo() + self.stop_timer("getblockchaininfo") + + +if __name__ == '__main__': + ExampleBench().main() From 9a71d8e6528b0435def9421a17ac5607d155744d Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:51:32 +0530 Subject: [PATCH 05/21] test: implement bench runner with pretty-printing --- configure.ac | 1 + test/bench/bench_framework.py | 39 +++++-- test/bench/bench_results.py | 97 ++++++++++++++++ test/bench/bench_runner.py | 208 ++++++++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 12 deletions(-) create mode 100755 test/bench/bench_results.py create mode 100755 test/bench/bench_runner.py diff --git a/configure.ac b/configure.ac index 31951e1f7c47..1ead2d6806a4 100644 --- a/configure.ac +++ b/configure.ac @@ -2062,6 +2062,7 @@ AC_CONFIG_LINKS([src/.bear-tidy-config:src/.bear-tidy-config]) AC_CONFIG_LINKS([src/.clang-tidy:src/.clang-tidy]) AC_CONFIG_LINKS([src/ipc/.clang-tidy:src/ipc/.clang-tidy]) AC_CONFIG_LINKS([src/test/.clang-tidy:src/test/.clang-tidy]) +AC_CONFIG_LINKS([test/bench/bench_runner.py:test/bench/bench_runner.py]) AC_CONFIG_LINKS([test/functional/test_runner.py:test/functional/test_runner.py]) AC_CONFIG_LINKS([test/fuzz/test_runner.py:test/fuzz/test_runner.py]) AC_CONFIG_LINKS([test/util/test_runner.py:test/util/test_runner.py]) diff --git a/test/bench/bench_framework.py b/test/bench/bench_framework.py index 687656019a26..82a9b36dd3d8 100755 --- a/test/bench/bench_framework.py +++ b/test/bench/bench_framework.py @@ -11,6 +11,10 @@ # Allow imports from the functional test framework. sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'functional')) +from bench_results import ( # noqa: E402 + BenchResult, + save_results, +) from test_framework.test_framework import BitcoinTestFramework # noqa: E402 @@ -21,6 +25,7 @@ def set_test_params(self) -> None: self.warmup_iterations: int = 0 self.bench_iterations: int = 1 self.bench_name: str = type(self).__name__ + self.results_file: Optional[str] = None # Raw latency samples keyed by measurement name. self._samples: Dict[str, List[float]] = {} self._timer_start: Optional[float] = None @@ -69,6 +74,12 @@ def add_options(self, parser) -> None: # type: ignore[override] help="Extra daemon arguments as a single string " "(e.g. --daemon-args=\"-rpcworkqueue=1024 -rpcthreads=8\")", ) + parser.add_argument( + "--results-file", + dest="results_file", + default=None, + help="Save results to a JSON file", + ) def set_bench_params(self) -> None: """Benchmarks must override this to set ``num_nodes``, etc.""" @@ -97,29 +108,33 @@ def record_sample(self, name: str, value_ms: float) -> None: """Directly record a latency sample (ms) without using the timer.""" self._samples.setdefault(name, []).append(value_ms) + def _build_results(self) -> List[BenchResult]: + """Convert raw samples into a list of ``BenchResult`` objects.""" + return [ + BenchResult.from_samples(name, samples) + for name, samples in self._samples.items() + if samples + ] + def _report_results(self) -> None: """Print a summary of all recorded measurements.""" + results = self._build_results() self.log.info("=" * 60) self.log.info("Benchmark: %s", self.bench_name) self.log.info("=" * 60) - for name, samples in self._samples.items(): - n = len(samples) - if n == 0: - continue - samples_sorted = sorted(samples) - total = sum(samples_sorted) - mean = total / n - p50 = samples_sorted[n // 2] - p99_idx = min(int(n * 0.99), n - 1) - p99 = samples_sorted[p99_idx] + for r in results: self.log.info( " %-30s n=%-6d mean=%8.2fms p50=%8.2fms " "p99=%8.2fms min=%8.2fms max=%8.2fms", - name, n, mean, p50, p99, - samples_sorted[0], samples_sorted[-1], + r.name, r.sample_count, r.mean_ms, r.p50_ms, + r.p99_ms, r.min_ms, r.max_ms, ) self.log.info("=" * 60) + if self.results_file: + save_results(results, self.results_file, label=self.bench_name) + self.log.info("Results saved to %s", self.results_file) + @property def samples(self) -> Dict[str, List[float]]: """Access the raw sample data.""" diff --git a/test/bench/bench_results.py b/test/bench/bench_results.py new file mode 100755 index 000000000000..ed14df1a7430 --- /dev/null +++ b/test/bench/bench_results.py @@ -0,0 +1,97 @@ +#!/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. + +import json +import math +import statistics +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + + +def _percentile(data: List[float], p: float) -> float: + """Return the *p*-th percentile (0–100) of a **sorted** list.""" + if not data: + return 0.0 + k = (len(data) - 1) * p / 100.0 + f = math.floor(k) + c = math.ceil(k) + if f == c: + return data[int(k)] + return data[f] * (c - k) + data[c] * (k - f) + + +@dataclass +class BenchResult: + """Statistics computed from a set of latency samples.""" + + name: str + sample_count: int = 0 + mean_ms: float = 0.0 + stddev_ms: float = 0.0 + min_ms: float = 0.0 + p50_ms: float = 0.0 + p90_ms: float = 0.0 + p99_ms: float = 0.0 + p999_ms: float = 0.0 + max_ms: float = 0.0 + total_ms: float = 0.0 + ops_per_sec: float = 0.0 + extra: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_samples( + cls, + name: str, + samples_ms: List[float], + extra: Optional[Dict[str, Any]] = None, + ) -> "BenchResult": + """Compute statistics from a list of latency values in ms.""" + r = cls(name=name) + if not samples_ms: + return r + + data = sorted(samples_ms) + r.sample_count = len(data) + r.total_ms = sum(data) + r.mean_ms = round(r.total_ms / r.sample_count, 3) + r.min_ms = round(data[0], 3) + r.p50_ms = round(_percentile(data, 50), 3) + r.p90_ms = round(_percentile(data, 90), 3) + r.p99_ms = round(_percentile(data, 99), 3) + r.p999_ms = round(_percentile(data, 99.9), 3) + r.max_ms = round(data[-1], 3) + r.total_ms = round(r.total_ms, 3) + if r.sample_count > 1: + r.stddev_ms = round(statistics.stdev(data), 3) + if r.total_ms > 0: + r.ops_per_sec = round(r.sample_count / (r.total_ms / 1000.0), 1) + if extra: + r.extra = extra + return r + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "BenchResult": + return cls(**d) + + +def save_results( + results: List[BenchResult], + path: str, + label: str = "", + metadata: Optional[Dict[str, Any]] = None, +) -> None: + """Write results to a JSON file.""" + data: Dict[str, Any] = { + "label": label, + "results": [r.to_dict() for r in results], + } + if metadata: + data["metadata"] = metadata + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + diff --git a/test/bench/bench_runner.py b/test/bench/bench_runner.py new file mode 100755 index 000000000000..b36170171445 --- /dev/null +++ b/test/bench/bench_runner.py @@ -0,0 +1,208 @@ +#!/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. + +import argparse +import configparser +import os +import subprocess +import sys +import time +from glob import glob +from typing import List, Tuple + +TEST_EXIT_SKIPPED = 77 + +DEFAULT, BOLD, GREEN, RED, SKIP = ("", ""), ("", ""), ("", ""), ("", ""), ("", "") +if os.name != "nt" and sys.stdout.isatty(): + DEFAULT = ("\033[0m", "\033[0m") + BOLD = ("\033[0m", "\033[1m") + GREEN = ("\033[0m", "\033[0;32m") + RED = ("\033[0m", "\033[0;31m") + SKIP = ("\033[0m", "\033[0;33m") + +TICK, CROSS = "P ", "x" +try: + "\u2713".encode("utf_8").decode(sys.stdout.encoding) + TICK = "\u2713 " + CROSS = "\u2716 " +except Exception: + pass # Do nothing + + +def _get_build_id() -> str: + """Return daemon version string, or 'unknown' on failure.""" + config = configparser.ConfigParser() + configfile = os.path.join( + os.path.dirname(__file__), "..", "config.ini", + ) + try: + with open(configfile, encoding="utf8") as f: + config.read_file(f) + except (FileNotFoundError, configparser.Error): + return "unknown" + build_dir = config.get("environment", "BUILDDIR", fallback="") + dashd = os.path.join(build_dir, "src", "dashd") + if not os.path.isfile(dashd): + return "unknown" + try: + out = subprocess.check_output( + [dashd, "--version"], text=True, timeout=5, + ) + # First line: "Dash Core version v23.1.0-167-gceab392..." + first_line = out.strip().splitlines()[0] + return first_line.replace("Dash Core version ", "") + except (subprocess.SubprocessError, IndexError): + return "unknown" + + +def discover_benchmarks(bench_dir: str) -> List[str]: + """Return sorted list of benchmark script filenames.""" + pattern = os.path.join(bench_dir, "*_bench.py") + all_files = glob(pattern) + return sorted( + os.path.basename(f) for f in all_files + if not os.path.basename(f).startswith("bench_") + ) + + +def _extract_failure_log(output: str) -> str: + """Extract failure information from benchmark output.""" + lines = output.splitlines() + relevant: List[str] = [] + in_traceback = False + + for line in lines: + if "Traceback (most recent call last)" in line: + in_traceback = True + if in_traceback: + relevant.append(line) + if relevant and not line.startswith(" ") and "Traceback" not in line: + in_traceback = False + elif "(ERROR)" in line: + relevant.append(line) + + return "\n".join(relevant) if relevant else output + + +def run_benchmark( + bench_dir: str, + script: str, + extra_args: List[str], + timeout: int = 600, +) -> Tuple[int, float, str]: + """Run a single benchmark script.""" + cmd = [sys.executable, os.path.join(bench_dir, script)] + extra_args + t0 = time.time() + try: + result = subprocess.run( + cmd, + cwd=os.path.dirname(bench_dir) or ".", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + ) + elapsed = time.time() - t0 + return result.returncode, elapsed, result.stdout or "" + except subprocess.TimeoutExpired: + elapsed = time.time() - t0 + return 1, elapsed, f"Benchmark timed out after {timeout}s" + + +def main() -> None: + bench_dir = os.path.dirname(os.path.abspath(__file__)) + + # Split on '--': runner args before, benchmark passthrough after. + argv = sys.argv[1:] + if "--" in argv: + split = argv.index("--") + runner_argv = argv[:split] + passthrough = argv[split + 1:] + else: + runner_argv = argv + passthrough = [] + + parser = argparse.ArgumentParser( + description="Run Dash Core benchmarks", + ) + parser.add_argument( + "benchmarks", + nargs="*", + help="Specific benchmark scripts to run (default: all)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List available benchmarks and exit", + ) + parser.add_argument( + "--timeout", + type=int, + default=600, + help="Per-benchmark timeout in seconds (default: 600)", + ) + args = parser.parse_args(runner_argv) + + available = discover_benchmarks(bench_dir) + + if args.list: + print("Available benchmarks:") + for name in available: + print(f" {name}") + return + + to_run = args.benchmarks if args.benchmarks else available + if not to_run: + print("No benchmarks found.", file=sys.stderr) + sys.exit(1) + + for name in to_run: + if name not in available: + print(f"Unknown benchmark: {name}", file=sys.stderr) + print(f"Available: {', '.join(available)}", file=sys.stderr) + sys.exit(1) + + build_id = _get_build_id() + print(f"Running benchmarks for Dash Core {build_id}\n") + + total = len(to_run) + passed = 0 + skipped = 0 + failed_names: List[str] = [] + + for i, name in enumerate(to_run, 1): + rc, elapsed, output = run_benchmark( + bench_dir, name, passthrough, timeout=args.timeout, + ) + duration = int(elapsed) + label = f"{i}/{total} - {BOLD[1]}{name}{BOLD[0]}" + + if rc == 0: + passed += 1 + print(f"{GREEN[1]}{TICK}{label} passed, Duration: {duration} s{GREEN[0]}") + elif rc == TEST_EXIT_SKIPPED: + skipped += 1 + print(f"{SKIP[1]}{TICK}{label} skipped{SKIP[0]}") + else: + failed_names.append(name) + print(f"{RED[1]}{CROSS}{label} failed, Duration: {duration} s{RED[0]}") + print(f"\n{BOLD[1]}Error log:{BOLD[0]}") + print(_extract_failure_log(output)) + print() + + failed = len(failed_names) + print( + f"\n{BOLD[1]}{passed} passed, {failed} failed, " + f"{skipped} skipped{BOLD[0]}" + ) + + if failed_names: + print(f"{RED[1]}Failed: {', '.join(failed_names)}{RED[0]}") + + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() From a9ddac622d133bc34d226738f89036ed9e9d649a Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:21:43 +0530 Subject: [PATCH 06/21] test: add support for Markdown table export replacing plain printing --- test/bench/bench_framework.py | 14 +++----------- test/bench/bench_results.py | 34 ++++++++++++++++++++++++++++++++++ test/bench/bench_runner.py | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/test/bench/bench_framework.py b/test/bench/bench_framework.py index 82a9b36dd3d8..19744f133803 100755 --- a/test/bench/bench_framework.py +++ b/test/bench/bench_framework.py @@ -13,6 +13,7 @@ from bench_results import ( # noqa: E402 BenchResult, + results_to_markdown, save_results, ) from test_framework.test_framework import BitcoinTestFramework # noqa: E402 @@ -119,17 +120,8 @@ def _build_results(self) -> List[BenchResult]: def _report_results(self) -> None: """Print a summary of all recorded measurements.""" results = self._build_results() - self.log.info("=" * 60) - self.log.info("Benchmark: %s", self.bench_name) - self.log.info("=" * 60) - for r in results: - self.log.info( - " %-30s n=%-6d mean=%8.2fms p50=%8.2fms " - "p99=%8.2fms min=%8.2fms max=%8.2fms", - r.name, r.sample_count, r.mean_ms, r.p50_ms, - r.p99_ms, r.min_ms, r.max_ms, - ) - self.log.info("=" * 60) + md = results_to_markdown(results, title=self.bench_name) + print(md) if self.results_file: save_results(results, self.results_file, label=self.bench_name) diff --git a/test/bench/bench_results.py b/test/bench/bench_results.py index ed14df1a7430..ae2f19df3af4 100755 --- a/test/bench/bench_results.py +++ b/test/bench/bench_results.py @@ -9,6 +9,8 @@ from dataclasses import asdict, dataclass, field from typing import Any, Dict, List, Optional +from tabulate import tabulate + def _percentile(data: List[float], p: float) -> float: """Return the *p*-th percentile (0–100) of a **sorted** list.""" @@ -78,6 +80,38 @@ def to_dict(self) -> Dict[str, Any]: def from_dict(cls, d: Dict[str, Any]) -> "BenchResult": return cls(**d) + def to_row(self) -> List[Any]: + """Return a flat row suitable for ``tabulate``.""" + return [ + self.name, + self.sample_count, + f"{self.ops_per_sec:.1f}", + f"{self.mean_ms:.2f}", + f"{self.p50_ms:.2f}", + f"{self.p90_ms:.2f}", + f"{self.p99_ms:.2f}", + f"{self.max_ms:.2f}", + f"{self.stddev_ms:.2f}", + ] + + @staticmethod + def table_headers() -> List[str]: + return [ + "Name", "N", "ops/s", + "mean(ms)", "p50(ms)", "p90(ms)", "p99(ms)", "max(ms)", + "stddev(ms)", + ] + + +def results_to_markdown( + results: List[BenchResult], + title: str = "Benchmark Results", +) -> str: + """Render a list of ``BenchResult`` objects as a Markdown table.""" + rows = [r.to_row() for r in results] + table = tabulate(rows, headers=BenchResult.table_headers(), tablefmt="pipe") + return f"## {title}\n\n{table}\n" + def save_results( results: List[BenchResult], diff --git a/test/bench/bench_runner.py b/test/bench/bench_runner.py index b36170171445..fafa0ca1d00a 100755 --- a/test/bench/bench_runner.py +++ b/test/bench/bench_runner.py @@ -10,7 +10,7 @@ import sys import time from glob import glob -from typing import List, Tuple +from typing import List, Optional, Tuple TEST_EXIT_SKIPPED = 77 @@ -67,6 +67,31 @@ def discover_benchmarks(bench_dir: str) -> List[str]: ) +def _extract_markdown(output: str) -> Optional[str]: + """Extract Markdown table block from benchmark output.""" + lines = output.splitlines() + md_lines: List[str] = [] + capturing = False + + for raw_line in lines: + line = raw_line.rstrip() + if line.startswith("## "): + capturing = True + md_lines.append(line) + elif capturing: + if line.startswith("|") or line == "": + md_lines.append(line) + else: + capturing = False + + if not md_lines: + return None + # Strip trailing blank lines. + while md_lines and md_lines[-1] == "": + md_lines.pop() + return "\n".join(md_lines) + + def _extract_failure_log(output: str) -> str: """Extract failure information from benchmark output.""" lines = output.splitlines() @@ -171,6 +196,7 @@ def main() -> None: passed = 0 skipped = 0 failed_names: List[str] = [] + markdown_blocks: List[str] = [] for i, name in enumerate(to_run, 1): rc, elapsed, output = run_benchmark( @@ -182,6 +208,9 @@ def main() -> None: if rc == 0: passed += 1 print(f"{GREEN[1]}{TICK}{label} passed, Duration: {duration} s{GREEN[0]}") + md = _extract_markdown(output) + if md: + markdown_blocks.append(md) elif rc == TEST_EXIT_SKIPPED: skipped += 1 print(f"{SKIP[1]}{TICK}{label} skipped{SKIP[0]}") @@ -198,6 +227,10 @@ def main() -> None: f"{skipped} skipped{BOLD[0]}" ) + if markdown_blocks: + print("\n---\n") + print("\n\n".join(markdown_blocks)) + if failed_names: print(f"{RED[1]}Failed: {', '.join(failed_names)}{RED[0]}") From bd8a8e30bcd6169f9b5ce42f3981779cb4babe35 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:21:56 +0530 Subject: [PATCH 07/21] test: introduce mempool submission pressure benchmark --- test/bench/bench_helpers.py | 42 +++++++++++++++++++ test/bench/mempool_bench.py | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100755 test/bench/bench_helpers.py create mode 100755 test/bench/mempool_bench.py diff --git a/test/bench/bench_helpers.py b/test/bench/bench_helpers.py new file mode 100755 index 000000000000..db44ba12c901 --- /dev/null +++ b/test/bench/bench_helpers.py @@ -0,0 +1,42 @@ +#!/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. + +import os +import sys +from typing import List + +# Allow imports from the functional test framework. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'functional')) + +from test_framework.authproxy import JSONRPCException # noqa: E402 +from test_framework.test_node import TestNode # noqa: E402 +from test_framework.wallet import MiniWallet # noqa: E402 + + +def create_self_transfer_batch( + wallet: MiniWallet, + count: int, +) -> List[str]: + """Create *count* signed self-transfer transactions from *wallet* UTXOs.""" + txs: List[str] = [] + for _ in range(count): + tx = wallet.create_self_transfer() + txs.append(tx["hex"]) + return txs + + +def submit_transactions( + node: TestNode, + tx_hexes: List[str], +) -> List[str]: + """Submit a list of raw transactions to *node* via ``sendrawtransaction``.""" + txids: List[str] = [] + for tx_hex in tx_hexes: + try: + txid = node.sendrawtransaction(tx_hex) + txids.append(txid) + except JSONRPCException: + pass + return txids diff --git a/test/bench/mempool_bench.py b/test/bench/mempool_bench.py new file mode 100755 index 000000000000..39afb1f63855 --- /dev/null +++ b/test/bench/mempool_bench.py @@ -0,0 +1,83 @@ +#!/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. + +import time +from typing import List + +from bench_framework import BenchFramework +from bench_helpers import create_self_transfer_batch + +from test_framework.authproxy import JSONRPCException # noqa: E402 +from test_framework.wallet import MiniWallet # noqa: E402 + + +class MempoolBench(BenchFramework): + + def set_test_params(self) -> None: + super().set_test_params() + + def run_test(self) -> None: + super().run_test() + + def add_options(self, parser) -> None: # type: ignore[override] + super().add_options(parser) + parser.add_argument( + "--tx-count", + dest="tx_count", + type=int, + default=50, + help="Transactions per iteration (default: 50)", + ) + + def set_bench_params(self) -> None: + self._tx_count = 50 + self.bench_iterations = 3 + self.bench_name = "mempool_acceptance" + self.num_nodes = 1 + self.setup_clean_chain = True + self.warmup_iterations = 1 + + def run_bench(self) -> None: + self._tx_count = self.options.tx_count + node = self.nodes[0] + wallet = MiniWallet(node) + + num_blocks = self._tx_count + 110 + self.log.info("Mining %d blocks for funding...", num_blocks) + self.generate(wallet, num_blocks, sync_fun=self.no_op) + + # Pre-create transactions + self.log.info("Creating %d transactions...", self._tx_count) + tx_hexes: List[str] = create_self_transfer_batch(wallet, self._tx_count) + + # Submit and time each one + self.log.info("Submitting %d transactions...", len(tx_hexes)) + accepted = 0 + rejected = 0 + t_batch_start = time.perf_counter() + for tx_hex in tx_hexes: + self.start_timer() + try: + node.sendrawtransaction(tx_hex) + self.stop_timer("sendrawtransaction") + accepted += 1 + except JSONRPCException: + self.stop_timer("sendrawtransaction_rejected") + rejected += 1 + + batch_elapsed_ms = (time.perf_counter() - t_batch_start) * 1000.0 + self.record_sample("batch_total", batch_elapsed_ms) + + self.log.info( + "Submitted %d tx: %d accepted, %d rejected in %.1fms", + len(tx_hexes), accepted, rejected, batch_elapsed_ms, + ) + + # Clear mempool for next iteration by mining + self.generate(node, 1, sync_fun=self.no_op) + + +if __name__ == '__main__': + MempoolBench().main() From 82ba05df219ba6222fb50004260aa37788c90f43 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:22:05 +0530 Subject: [PATCH 08/21] test: add RPC throughput benchmark (sequential, keep-alive, concurrent) --- test/bench/bench_helpers.py | 88 +++++++++++++++++++++++++++++- test/bench/rpc_bench.py | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100755 test/bench/rpc_bench.py diff --git a/test/bench/bench_helpers.py b/test/bench/bench_helpers.py index db44ba12c901..7ffb38000fb6 100755 --- a/test/bench/bench_helpers.py +++ b/test/bench/bench_helpers.py @@ -3,9 +3,15 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. +import asyncio +import json import os import sys -from typing import List +import time +import urllib.parse +from typing import Any, Dict, List, Optional, Tuple + +import aiohttp # Allow imports from the functional test framework. sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'functional')) @@ -40,3 +46,83 @@ def submit_transactions( except JSONRPCException: pass return txids + + +def _parse_rpc_url(node: TestNode) -> Tuple[str, Optional[aiohttp.BasicAuth]]: + """Extract base URL and auth from a TestNode's RPC URL.""" + parsed = urllib.parse.urlparse(node.url) + auth: Optional[aiohttp.BasicAuth] = None + if parsed.username: + auth = aiohttp.BasicAuth(parsed.username, parsed.password or "") + base = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}" + return base, auth + + +def _rpc_payload(method: str, params: Optional[List[Any]] = None) -> bytes: + """Build a RPC request body.""" + return json.dumps({ + "version": "1.1", + "id": 1, + "method": method, + "params": params or [], + }).encode() + + +async def async_rpc_flood( + node: TestNode, + method: str, + params: Optional[List[Any]] = None, + concurrency: int = 50, + duration_s: float = 10.0, +) -> Dict[str, Any]: + """Flood a node's RPC endpoint with concurrent requests.""" + base_url, auth = _parse_rpc_url(node) + latencies: List[float] = [] + success = 0 + failed = 0 + status_codes: Dict[str, int] = {} + bytes_rx = 0 + deadline = time.monotonic() + duration_s + + async def worker() -> None: + nonlocal success, failed, bytes_rx + conn = aiohttp.TCPConnector(limit=0, keepalive_timeout=60) + async with aiohttp.ClientSession(connector=conn) as session: + while time.monotonic() < deadline: + payload = _rpc_payload(method, params) + t0 = time.perf_counter() + try: + async with session.post( + base_url, + data=payload, + auth=auth, + headers={"Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + body = await resp.read() + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(elapsed_ms) + bytes_rx += len(body) + key = str(resp.status) + status_codes[key] = status_codes.get(key, 0) + 1 + if resp.status == 200: + success += 1 + else: + failed += 1 + except Exception as e: + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(elapsed_ms) + failed += 1 + key = type(e).__name__ + status_codes[key] = status_codes.get(key, 0) + 1 + + tasks = [asyncio.create_task(worker()) for _ in range(concurrency)] + await asyncio.gather(*tasks) + + return { + "latencies_ms": latencies, + "success": success, + "failed": failed, + "status_codes": status_codes, + "bytes_received": bytes_rx, + } diff --git a/test/bench/rpc_bench.py b/test/bench/rpc_bench.py new file mode 100755 index 000000000000..55073dfb6e7c --- /dev/null +++ b/test/bench/rpc_bench.py @@ -0,0 +1,106 @@ +#!/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. + +import asyncio +from typing import List + +from bench_framework import BenchFramework +from bench_helpers import async_rpc_flood +from bench_results import BenchResult + + +class RpcBench(BenchFramework): + + def set_test_params(self) -> None: + super().set_test_params() + + def run_test(self) -> None: + super().run_test() + + def add_options(self, parser) -> None: # type: ignore[override] + super().add_options(parser) + parser.add_argument( + "--duration", + dest="bench_duration", + type=float, + default=10.0, + help="Duration per test in seconds (default: 10)", + ) + parser.add_argument( + "--concurrency", + dest="concurrency", + type=int, + default=100, + help="Max concurrent connections (default: 100)", + ) + + def set_bench_params(self) -> None: + self._concurrency: int = 100 + self._duration: float = 10.0 + self.bench_iterations = 1 + self.bench_name = "rpc_throughput" + self.num_nodes = 1 + self.setup_clean_chain = False + self.warmup_iterations = 0 + + def run_bench(self) -> None: + self._duration = self.options.bench_duration + self._concurrency = self.options.concurrency + node = self.nodes[0] + + # Sequential baseline + self.log.info("[1/3] Sequential baseline (c=1, %ds)...", self._duration) + result = asyncio.run( + async_rpc_flood(node, "getblockcount", concurrency=1, + duration_s=self._duration) + ) + for lat in result["latencies_ms"]: + self.record_sample("sequential", lat) + self.log.info( + " %d requests, %d ok, %d failed", + result["success"] + result["failed"], + result["success"], result["failed"], + ) + + # Sustained keep-alive + self.log.info( + "[2/3] Sustained keep-alive (c=%d, %ds)...", + self._concurrency, self._duration, + ) + result = asyncio.run( + async_rpc_flood(node, "getblockcount", + concurrency=self._concurrency, + duration_s=self._duration) + ) + for lat in result["latencies_ms"]: + self.record_sample("keepalive", lat) + self.log.info( + " %d requests, %d ok, %d failed", + result["success"] + result["failed"], + result["success"], result["failed"], + ) + + # Connection scaling + levels: List[int] = [1, 10, 50, 100, 200] + levels = [c for c in levels if c <= self._concurrency * 2] + scale_duration = max(self._duration / 3, 3.0) + self.log.info("[3/3] Connection scaling %s...", levels) + for c in levels: + result = asyncio.run( + async_rpc_flood(node, "getblockcount", + concurrency=c, + duration_s=scale_duration) + ) + for lat in result["latencies_ms"]: + self.record_sample(f"scaling_c{c}", lat) + br = BenchResult.from_samples(f"c={c}", result["latencies_ms"]) + self.log.info( + " c=%-5d %8.1f ops/s mean=%.2fms p99=%.2fms", + c, br.ops_per_sec, br.mean_ms, br.p99_ms, + ) + + +if __name__ == '__main__': + RpcBench().main() From 8d138828e2025f645d8d484e9c0b0f50d835fecd Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:49:27 +0530 Subject: [PATCH 09/21] test: add ZMQ txhash notification latency benchmark --- test/bench/bench_helpers.py | 26 ++++++++ test/bench/zmq_bench.py | 117 ++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100755 test/bench/zmq_bench.py diff --git a/test/bench/bench_helpers.py b/test/bench/bench_helpers.py index 7ffb38000fb6..1b66f3c10c6c 100755 --- a/test/bench/bench_helpers.py +++ b/test/bench/bench_helpers.py @@ -126,3 +126,29 @@ async def worker() -> None: "status_codes": status_codes, "bytes_received": bytes_rx, } + + +def zmq_subscribe( + address: str, + topic: bytes, + timeout_ms: int = 30000, +) -> Tuple[Any, Any]: + """Create a ZMQ SUB socket connected to *address* with *topic*. + Caller must remember to ``socket.close()`` and ``context.destroy()`` when done. + """ + import zmq + ctx = zmq.Context() + sock = ctx.socket(zmq.SUB) + sock.set(zmq.RCVTIMEO, timeout_ms) + sock.set(zmq.IPV6, 1) + sock.setsockopt(zmq.SUBSCRIBE, topic) + sock.connect(address) + return ctx, sock + + +def zmq_receive_one( + sock: Any, +) -> Tuple[bytes, bytes, float]: + """Receive one ZMQ multipart message from *sock*.""" + topic, body, _seq = sock.recv_multipart() + return topic, body, time.perf_counter() diff --git a/test/bench/zmq_bench.py b/test/bench/zmq_bench.py new file mode 100755 index 000000000000..82d48f1753f6 --- /dev/null +++ b/test/bench/zmq_bench.py @@ -0,0 +1,117 @@ +#!/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. + +import time +from typing import Any, List + +from bench_framework import BenchFramework +from bench_helpers import create_self_transfer_batch, zmq_subscribe, zmq_receive_one + +from test_framework.authproxy import JSONRPCException # noqa: E402 +from test_framework.util import p2p_port # noqa: E402 +from test_framework.wallet import MiniWallet # noqa: E402 + +# Test may be skipped and not have zmq installed +try: + import zmq +except ImportError: + pass + + +class ZmqBench(BenchFramework): + + def set_test_params(self) -> None: + super().set_test_params() + + def run_test(self) -> None: + super().run_test() + + def add_options(self, parser) -> None: # type: ignore[override] + super().add_options(parser) + parser.add_argument( + "--tx-count", + dest="tx_count", + type=int, + default=50, + help="Transactions to send (default: 50)", + ) + + def skip_test_if_missing_module(self) -> None: + self.skip_if_no_bitcoind_zmq() + self.skip_if_no_py3_zmq() + + def set_bench_params(self) -> None: + self._tx_count = 50 + self._zmq_port = 0 + self.bench_iterations = 1 + self.bench_name = "zmq_notification_latency" + self.num_nodes = 1 + self.setup_clean_chain = True + self.warmup_iterations = 0 + + def setup_network(self) -> None: + self._zmq_port = p2p_port(self.num_nodes + 10) + zmq_addr = f"tcp://127.0.0.1:{self._zmq_port}" + self.extra_args = [[ + f"-zmqpubhashtx={zmq_addr}", + ]] + self.setup_nodes() + + def run_bench(self) -> None: + self._tx_count = self.options.tx_count + node = self.nodes[0] + wallet = MiniWallet(node) + zmq_addr = f"tcp://127.0.0.1:{self._zmq_port}" + + num_blocks = self._tx_count + 110 + self.log.info("Mining %d blocks for funding...", num_blocks) + self.generate(wallet, num_blocks, sync_fun=self.no_op) + + # Pre-create transactions + self.log.info("Creating %d transactions...", self._tx_count) + tx_hexes: List[str] = create_self_transfer_batch(wallet, self._tx_count) + + # Subscribe to hashtx notifications + ctx: Any + sock: Any + ctx, sock = zmq_subscribe(zmq_addr, b"hashtx") + + try: + # Generate a block and consume its notification so the subscriber is fully connected before timing + self.generate(node, 1, sync_fun=self.no_op) + try: + while True: + sock.recv_multipart(flags=zmq.NOBLOCK) + except zmq.Again: + pass + + # Send transactions and measure notification latency + self.log.info("Submitting %d transactions...", len(tx_hexes)) + received = 0 + for tx_hex in tx_hexes: + t_send = time.perf_counter() + try: + node.sendrawtransaction(tx_hex) + except JSONRPCException: + continue + try: + _topic, _body, t_recv = zmq_receive_one(sock) + latency_ms = (t_recv - t_send) * 1000.0 + self.record_sample("zmq_hashtx_latency", latency_ms) + received += 1 + except zmq.Again: + self.log.warning("ZMQ timeout waiting for notification") + + self.log.info( + "Received %d/%d ZMQ notifications", + received, len(tx_hexes), + ) + finally: + sock.close() + ctx.destroy(linger=0) + + +if __name__ == '__main__': + ZmqBench().main() From 7f0cebfe19fbc8a825abbadc0c9b312e45af2f72 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:54:52 +0530 Subject: [PATCH 10/21] test: add REST benchmarks (sequential, keep-alive, concurrent, mixed) --- test/bench/bench_helpers.py | 136 +++++++++++++++++++++++++ test/bench/rest_bench.py | 192 ++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100755 test/bench/rest_bench.py diff --git a/test/bench/bench_helpers.py b/test/bench/bench_helpers.py index 1b66f3c10c6c..f350f8c7b4eb 100755 --- a/test/bench/bench_helpers.py +++ b/test/bench/bench_helpers.py @@ -128,6 +128,142 @@ async def worker() -> None: } +async def async_rest_flood( + host: str, + port: int, + path: str, + concurrency: int = 50, + duration_s: float = 10.0, + force_close: bool = False, + connect_burst: int = 0, +) -> Dict[str, Any]: + """Flood a REST endpoint with concurrent HTTP GET requests.""" + url = f"http://{host}:{port}{path}" + latencies: List[float] = [] + success = 0 + failed = 0 + status_codes: Dict[str, int] = {} + bytes_rx = 0 + deadline = time.monotonic() + duration_s + + # Share a single connector across all workers so that connection + # establishment is serialised through aiohttp's pool rather than + # each worker racing to open its own TCP socket. This avoids + # overwhelming the server's accept backlog (SOMAXCONN=128 on macOS). + kwargs: Dict[str, Any] = {"limit": 0, "force_close": force_close} + if not force_close: + kwargs["keepalive_timeout"] = 60 + shared_conn = aiohttp.TCPConnector(**kwargs) + + # Semaphore that gates only the *first* request from each worker (the one + # that opens the TCP connection). After the connection is in the keep-alive + # pool, subsequent requests reuse it and never block on the semaphore. + connect_sem: Optional[asyncio.Semaphore] = ( + asyncio.Semaphore(connect_burst) + if (connect_burst > 0 and not force_close) + else None + ) + + async def worker(session: aiohttp.ClientSession) -> None: + nonlocal success, failed, bytes_rx + needs_connect = connect_sem is not None + while time.monotonic() < deadline: + t0 = time.perf_counter() + try: + if needs_connect: + assert connect_sem is not None + async with connect_sem: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + body = await resp.read() + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(elapsed_ms) + bytes_rx += len(body) + key = str(resp.status) + status_codes[key] = status_codes.get(key, 0) + 1 + if resp.status == 200: + success += 1 + else: + failed += 1 + needs_connect = False + else: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + body = await resp.read() + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(elapsed_ms) + bytes_rx += len(body) + key = str(resp.status) + status_codes[key] = status_codes.get(key, 0) + 1 + if resp.status == 200: + success += 1 + else: + failed += 1 + except Exception as e: + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(elapsed_ms) + failed += 1 + key = type(e).__name__ + status_codes[key] = status_codes.get(key, 0) + 1 + + async with aiohttp.ClientSession(connector=shared_conn) as session: + tasks = [asyncio.create_task(worker(session)) for _ in range(concurrency)] + await asyncio.gather(*tasks) + + return { + "latencies_ms": latencies, + "success": success, + "failed": failed, + "status_codes": status_codes, + "bytes_received": bytes_rx, + } + + +async def async_rest_discover( + host: str, + port: int, +) -> Tuple[Optional[str], Optional[str]]: + """Probe a REST server for a light and heavy endpoint.""" + light_path: Optional[str] = None + heavy_path: Optional[str] = None + best_hash: Optional[str] = None + + conn = aiohttp.TCPConnector(force_close=True) + async with aiohttp.ClientSession(connector=conn) as session: + for path in ["/rest/chaininfo.json", "/rest/mempool/info.json"]: + try: + async with session.get( + f"http://{host}:{port}{path}", + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + if resp.status == 200: + body = await resp.json(content_type=None) + light_path = path + if "bestblockhash" in body: + best_hash = body["bestblockhash"] + break + except Exception: + continue + + if best_hash: + heavy_candidate = f"/rest/block/{best_hash}.json" + try: + async with session.get( + f"http://{host}:{port}{heavy_candidate}", + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + if resp.status == 200: + heavy_path = heavy_candidate + except Exception: + pass + + return light_path, heavy_path + + def zmq_subscribe( address: str, topic: bytes, diff --git a/test/bench/rest_bench.py b/test/bench/rest_bench.py new file mode 100755 index 000000000000..08b0b537bbb1 --- /dev/null +++ b/test/bench/rest_bench.py @@ -0,0 +1,192 @@ +#!/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. + +import asyncio +import urllib.parse +from typing import Any, Dict, List, Tuple + +from bench_framework import BenchFramework +from bench_helpers import async_rest_discover, async_rest_flood +from bench_results import BenchResult + + +class RestBench(BenchFramework): + + def set_test_params(self) -> None: + super().set_test_params() + + def run_test(self) -> None: + super().run_test() + + def add_options(self, parser) -> None: # type: ignore[override] + super().add_options(parser) + parser.add_argument( + "--duration", + dest="bench_duration", + type=float, + default=10.0, + help="Duration per test in seconds (default: 10)", + ) + parser.add_argument( + "--concurrency", + dest="concurrency", + type=int, + default=100, + help="Max concurrent connections for storm/keepalive tests (default: 100)", + ) + parser.add_argument( + "--scale-max", + dest="scale_max", + type=int, + default=2000, + help="Max concurrency level for the scaling test (default: 2000)", + ) + parser.add_argument( + "--connect-burst", + dest="connect_burst", + type=int, + default=1024, + help="Max simultaneous initial TCP connections during scaling test (default: 1024)", + ) + + def set_bench_params(self) -> None: + self._concurrency: int = 100 + self._duration: float = 10.0 + self.bench_iterations = 1 + self.bench_name = "rest_throughput" + self.extra_args = [["-rest"]] + self.num_nodes = 1 + self.setup_clean_chain = False + self.warmup_iterations = 0 + + def run_bench(self) -> None: + self._duration = self.options.bench_duration + self._concurrency = self.options.concurrency + self._scale_max: int = self.options.scale_max + self._connect_burst: int = self.options.connect_burst + parsed = urllib.parse.urlparse(self.nodes[0].url) + host = parsed.hostname + port = parsed.port + + self.log.info("Discovering REST endpoints on %s:%d...", host, port) + endpoints = asyncio.run(self._discover_endpoints(host, port)) + if not endpoints.get("light"): + self.log.error("No responsive REST endpoint found") + return + + light_path = endpoints["light"] + heavy_path = endpoints.get("heavy") + self.log.info(" light: %s", light_path) + if heavy_path: + self.log.info(" heavy: %s", heavy_path) + + # Connection storm + self.log.info("[1/4] Connection storm (c=%d, %ds)...", self._concurrency, self._duration) + result = asyncio.run( + async_rest_flood(host, port, light_path, + concurrency=self._concurrency, + duration_s=self._duration, + force_close=True) + ) + for lat in result["latencies_ms"]: + self.record_sample("conn_storm", lat) + self._log_result("conn_storm", result) + + # Sustained keep-alive + self.log.info("[2/4] Sustained keep-alive (c=%d, %ds)...", self._concurrency, self._duration) + result = asyncio.run( + async_rest_flood(host, port, light_path, + concurrency=self._concurrency, + duration_s=self._duration, + connect_burst=self._connect_burst) + ) + for lat in result["latencies_ms"]: + self.record_sample("keepalive", lat) + self._log_result("keepalive", result) + + # Connection scaling + levels: List[int] = [1, 10, 50, 100, 200, 500, 1000, 2000, 5000] + levels = [c for c in levels if c <= self._scale_max] + scale_duration = max(self._duration / 3, 3.0) + self.log.info("[3/4] Connection scaling %s...", levels) + for c in levels: + result = asyncio.run( + async_rest_flood(host, port, light_path, + concurrency=c, + duration_s=scale_duration, + connect_burst=self._connect_burst) + ) + for lat in result["latencies_ms"]: + self.record_sample(f"scaling_c{c}", lat) + br = BenchResult.from_samples(f"c={c}", result["latencies_ms"]) + err_rate = "" + if result["failed"] > 0: + total = result["success"] + result["failed"] + pct = result["failed"] / total * 100 if total else 0 + err_rate = f" err={pct:.1f}%" + err_detail = "" + if result["status_codes"]: + err_detail = f" status_codes={result['status_codes']}" + self.log.info( + " c=%-5d %8.1f ops/s mean=%.2fms p99=%.2fms%s%s", + c, br.ops_per_sec, br.mean_ms, br.p99_ms, err_rate, err_detail, + ) + + # Mixed load + if not heavy_path: + self.log.info("[4/4] Mixed load — skipped (no heavy endpoint)") + else: + self.log.info("[4/4] Mixed load (light c=%d, heavy c=10, %ds)...", self._concurrency, self._duration) + light_result, heavy_result = asyncio.run( + self._mixed_load(host, port, light_path, heavy_path) + ) + for lat in light_result["latencies_ms"]: + self.record_sample("mixed_light", lat) + for lat in heavy_result["latencies_ms"]: + self.record_sample("mixed_heavy", lat) + self._log_result("mixed_light", light_result) + self._log_result("mixed_heavy", heavy_result) + + async def _discover_endpoints( + self, host: str, port: int, + ) -> Dict[str, Any]: + """Discover available REST endpoints and return a dict of paths.""" + light_path, heavy_path = await async_rest_discover(host, port) + result: Dict[str, Any] = {} + if light_path: + result["light"] = light_path + if heavy_path: + result["heavy"] = heavy_path + return result + + async def _mixed_load( + self, host: str, port: int, light_path: str, heavy_path: str, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Run light and heavy requests concurrently.""" + light_task = asyncio.create_task( + async_rest_flood(host, port, light_path, + concurrency=self._concurrency, + duration_s=self._duration) + ) + heavy_task = asyncio.create_task( + async_rest_flood(host, port, heavy_path, + concurrency=10, + duration_s=self._duration) + ) + return await light_task, await heavy_task + + def _log_result(self, name: str, result: Dict[str, Any]) -> None: + br = BenchResult.from_samples(name, result["latencies_ms"]) + err_str = "" + if result["failed"] > 0: + err_str = f" errors={result['failed']}" + self.log.info( + " %8.1f ops/s mean=%.2fms p99=%.2fms%s", + br.ops_per_sec, br.mean_ms, br.p99_ms, err_str, + ) + + +if __name__ == '__main__': + RestBench().main() From c883e5435b5a8394886b9102a13b43301cdd3c86 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:03:14 +0530 Subject: [PATCH 11/21] move-only: `rest.{cpp,h}` -> `rest/server.{cpp,h}` Preparation for isolation of REST server from libbitcoin_util --- src/Makefile.am | 4 ++-- src/{rest.cpp => rest/server.cpp} | 2 +- src/{rest.h => rest/server.h} | 6 +++--- src/test/rest_tests.cpp | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename src/{rest.cpp => rest/server.cpp} (99%) rename src/{rest.h => rest/server.h} (88%) diff --git a/src/Makefile.am b/src/Makefile.am index 8b269a3d9bdb..58e4688b8698 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -342,7 +342,7 @@ BITCOIN_CORE_H = \ psbt.h \ random.h \ randomenv.h \ - rest.h \ + rest/server.h \ rpc/blockchain.h \ rpc/client.h \ rpc/evo_util.h \ @@ -598,7 +598,7 @@ libbitcoin_node_a_SOURCES = \ policy/policy.cpp \ policy/settings.cpp \ pow.cpp \ - rest.cpp \ + rest/server.cpp \ rpc/blockchain.cpp \ rpc/coinjoin.cpp \ rpc/evo.cpp \ diff --git a/src/rest.cpp b/src/rest/server.cpp similarity index 99% rename from src/rest.cpp rename to src/rest/server.cpp index 207a2f247400..3844360286a4 100644 --- a/src/rest.cpp +++ b/src/rest/server.cpp @@ -3,7 +3,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -#include +#include #include #include diff --git a/src/rest.h b/src/rest/server.h similarity index 88% rename from src/rest.h rename to src/rest/server.h index 49b1c333d052..a052f1a433f5 100644 --- a/src/rest.h +++ b/src/rest/server.h @@ -2,8 +2,8 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -#ifndef BITCOIN_REST_H -#define BITCOIN_REST_H +#ifndef BITCOIN_REST_SERVER_H +#define BITCOIN_REST_SERVER_H #include @@ -25,4 +25,4 @@ enum class RESTResponseFormat { */ RESTResponseFormat ParseDataFormat(std::string& param, const std::string& strReq); -#endif // BITCOIN_REST_H +#endif // BITCOIN_REST_SERVER_H diff --git a/src/test/rest_tests.cpp b/src/test/rest_tests.cpp index 20dfe4b41a70..086f97ce88ad 100644 --- a/src/test/rest_tests.cpp +++ b/src/test/rest_tests.cpp @@ -2,7 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -#include +#include #include #include From 6eb3818f5e1088e6c9fa281fd32a498f3c0085c6 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:01:13 +0530 Subject: [PATCH 12/21] build: logically revert bitcoin#21376, supply our own zlib, use 1.3.2 --- depends/packages/packages.mk | 2 ++ depends/packages/qt.mk | 7 +++++-- depends/packages/zlib.mk | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 depends/packages/zlib.mk diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 7e0bb2633219..97a1080b5817 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -1,5 +1,7 @@ packages:=gmp backtrace +qt_packages = zlib + boost_packages = boost libevent_packages = libevent diff --git a/depends/packages/qt.mk b/depends/packages/qt.mk index 18aedcf7afcf..51a357bbf1af 100644 --- a/depends/packages/qt.mk +++ b/depends/packages/qt.mk @@ -4,6 +4,7 @@ $(package)_download_path=https://download.qt.io/archive/qt/5.15/$($(package)_ver $(package)_suffix=everywhere-opensource-src-$($(package)_version).tar.xz $(package)_file_name=qtbase-$($(package)_suffix) $(package)_sha256_hash=7b632550ea1048fc10c741e46e2e3b093e5ca94dfa6209e9e0848800e247023b +$(package)_dependencies=zlib $(package)_linux_dependencies := freetype fontconfig libxcb libxkbcommon libxcb_util libxcb_util_render libxcb_util_keysyms libxcb_util_image libxcb_util_wm $(package)_freebsd_dependencies := $($(package)_linux_dependencies) $(package)_qt_libs=corelib network widgets gui plugins testlib @@ -91,7 +92,7 @@ $(package)_config_opts += -prefix $(host_prefix) $(package)_config_opts += -qt-libpng $(package)_config_opts += -qt-pcre $(package)_config_opts += -qt-harfbuzz -$(package)_config_opts += -qt-zlib +$(package)_config_opts += -system-zlib $(package)_config_opts += -static $(package)_config_opts += -v $(package)_config_opts += -no-feature-bearermanagement @@ -279,7 +280,9 @@ endef define $(package)_config_cmds cd qtbase && \ - ./configure -top-level $($(package)_config_opts) + ./configure -top-level $($(package)_config_opts) && \ + echo "host_build: QT_CONFIG ~= s/system-zlib/zlib" >> mkspecs/qconfig.pri && \ + echo "CONFIG += force_bootstrap" >> mkspecs/qconfig.pri endef define $(package)_build_cmds diff --git a/depends/packages/zlib.mk b/depends/packages/zlib.mk new file mode 100644 index 000000000000..1158ce65ed96 --- /dev/null +++ b/depends/packages/zlib.mk @@ -0,0 +1,30 @@ +package=zlib +$(package)_version=1.3.2 +$(package)_download_path=https://www.zlib.net +$(package)_file_name=$(package)-$($(package)_version).tar.gz +$(package)_sha256_hash=bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16 + +define $(package)_set_vars + $(package)_config_opts := CC="$($(package)_cc)" + $(package)_config_opts += CFLAGS="$($(package)_cflags) $($(package)_cppflags) -fPIC" + $(package)_config_opts += RANLIB="$($(package)_ranlib)" + $(package)_config_opts += AR="$($(package)_ar)" + $(package)_config_opts_darwin += AR="$($(package)_libtool)" + $(package)_config_opts_darwin += ARFLAGS="-o" + $(package)_config_opts_android += CHOST=$(host) +endef + +# zlib has its own custom configure script that takes in options like CC, +# CFLAGS, RANLIB, AR, and ARFLAGS from the environment rather than from +# command-line arguments. +define $(package)_config_cmds + env $($(package)_config_opts) ./configure --static --prefix=$(host_prefix) +endef + +define $(package)_build_cmds + $(MAKE) libz.a +endef + +define $(package)_stage_cmds + $(MAKE) DESTDIR=$($(package)_staging_dir) install +endef From 29abf6064e6a44266cf0173ebe7b51c622dcd449 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:18:39 +0530 Subject: [PATCH 13/21] build: add drogon as a dependency, pull in its deps (jsoncpp, trantor) We already restored zlib in the previous commit, this is why --- depends/packages/drogon.mk | 39 ++++++++++++++++++++++++++++++++++++ depends/packages/jsoncpp.mk | 32 +++++++++++++++++++++++++++++ depends/packages/packages.mk | 6 ++++-- depends/packages/trantor.mk | 32 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 depends/packages/drogon.mk create mode 100644 depends/packages/jsoncpp.mk create mode 100644 depends/packages/trantor.mk diff --git a/depends/packages/drogon.mk b/depends/packages/drogon.mk new file mode 100644 index 000000000000..13b00d676454 --- /dev/null +++ b/depends/packages/drogon.mk @@ -0,0 +1,39 @@ +package=drogon +$(package)_version=1.9.12 +$(package)_download_path=https://github.com/drogonframework/drogon/archive/refs/tags +$(package)_download_file=v$($(package)_version).tar.gz +$(package)_file_name=$(package)-$($(package)_version).tar.gz +$(package)_sha256_hash=becc3c4f3b90f069f814baef164a7e3a2b31476dc6fe249b02ff07a13d032f48 +$(package)_dependencies=jsoncpp trantor zlib +$(package)_build_subdir=build + +define $(package)_set_vars + $(package)_config_opts := -DCMAKE_BUILD_TYPE=None + $(package)_config_opts += -DBUILD_BROTLI=OFF + $(package)_config_opts += -DBUILD_CTL=OFF + $(package)_config_opts += -DBUILD_EXAMPLES=OFF + $(package)_config_opts += -DBUILD_ORM=OFF + $(package)_config_opts += -DBUILD_REDIS=OFF + $(package)_config_opts += -DBUILD_SHARED_LIBS=OFF + $(package)_config_opts += -DBUILD_TESTING=OFF + $(package)_config_opts += -DBUILD_YAML_CONFIG=OFF + $(package)_config_opts += -DUSE_SUBMODULE=OFF + $(package)_cxxflags += -fdebug-prefix-map=$($(package)_extract_dir)=/usr -fmacro-prefix-map=$($(package)_extract_dir)=/usr +endef + +define $(package)_config_cmds + $($(package)_cmake) -S .. -B . +endef + +define $(package)_build_cmds + $(MAKE) +endef + +define $(package)_stage_cmds + $(MAKE) DESTDIR=$($(package)_staging_dir) install +endef + +define $(package)_postprocess_cmds + rm -rf lib/cmake && \ + rm -rf lib/pkgconfig +endef diff --git a/depends/packages/jsoncpp.mk b/depends/packages/jsoncpp.mk new file mode 100644 index 000000000000..3dc0c43db087 --- /dev/null +++ b/depends/packages/jsoncpp.mk @@ -0,0 +1,32 @@ +package=jsoncpp +$(package)_version=1.9.6 +$(package)_download_path=https://github.com/open-source-parsers/jsoncpp/archive/refs/tags +$(package)_download_file=$($(package)_version).tar.gz +$(package)_file_name=$(package)-$($(package)_version).tar.gz +$(package)_sha256_hash=f93b6dd7ce796b13d02c108bc9f79812245a82e577581c4c9aabe57075c90ea2 +$(package)_build_subdir=build + +define $(package)_set_vars + $(package)_config_opts := -DCMAKE_BUILD_TYPE=None + $(package)_config_opts += -DBUILD_SHARED_LIBS=OFF + $(package)_config_opts += -DBUILD_STATIC_LIBS=ON + $(package)_config_opts += -DJSONCPP_WITH_CMAKE_PACKAGE=ON + $(package)_config_opts += -DJSONCPP_WITH_POST_BUILD_UNITTEST=OFF + $(package)_config_opts += -DJSONCPP_WITH_TESTS=OFF +endef + +define $(package)_config_cmds + $($(package)_cmake) -S .. -B . +endef + +define $(package)_build_cmds + $(MAKE) +endef + +define $(package)_stage_cmds + $(MAKE) DESTDIR=$($(package)_staging_dir) install +endef + +define $(package)_postprocess_cmds + rm -rf lib/pkgconfig +endef diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 97a1080b5817..aa68e44ca640 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -1,6 +1,8 @@ -packages:=gmp backtrace +backtrace_packages:=backtrace +drogon_packages:=drogon jsoncpp trantor zlib +gmp_packages:=gmp -qt_packages = zlib +packages:=$(backtrace_packages) $(drogon_packages) $(gmp_packages) boost_packages = boost diff --git a/depends/packages/trantor.mk b/depends/packages/trantor.mk new file mode 100644 index 000000000000..aec13c7d1af8 --- /dev/null +++ b/depends/packages/trantor.mk @@ -0,0 +1,32 @@ +package=trantor +$(package)_version=1.5.26 +$(package)_download_path=https://github.com/an-tao/trantor/archive/refs/tags +$(package)_download_file=v$($(package)_version).tar.gz +$(package)_file_name=$(package)-$($(package)_version).tar.gz +$(package)_sha256_hash=e47092938aaf53d51c8bc72d8f54ebdcf537e6e4ac9c8276f3539413d6dfeddf +$(package)_build_subdir=build + +define $(package)_set_vars + $(package)_config_opts := -DCMAKE_BUILD_TYPE=None + $(package)_config_opts += -DBUILD_C-ARES=OFF + $(package)_config_opts += -DBUILD_SHARED_LIBS=OFF + $(package)_config_opts += -DBUILD_TESTING=OFF + $(package)_config_opts += -DTRANTOR_USE_TLS=none + $(package)_cxxflags += -fdebug-prefix-map=$($(package)_extract_dir)=/usr -fmacro-prefix-map=$($(package)_extract_dir)=/usr +endef + +define $(package)_config_cmds + $($(package)_cmake) -S .. -B . +endef + +define $(package)_build_cmds + $(MAKE) +endef + +define $(package)_stage_cmds + $(MAKE) DESTDIR=$($(package)_staging_dir) install +endef + +define $(package)_postprocess_cmds + rm -rf lib/pkgconfig +endef From 87236112aff02be315a86c495fff92dee760d6eb Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:19:06 +0530 Subject: [PATCH 14/21] build: add drogon as an optional dependency to build `libbitcoin_query` This commit won't build because libbitcoin_query_a_SOURCES is not defined --- configure.ac | 39 +++++++++++++++++++++++++++++++++++++ src/Makefile.am | 16 +++++++++++++-- src/Makefile.bench.include | 2 ++ src/Makefile.qt.include | 3 +++ src/Makefile.qttest.include | 4 ++-- src/Makefile.test.include | 4 ++-- 6 files changed, 62 insertions(+), 6 deletions(-) diff --git a/configure.ac b/configure.ac index 1ead2d6806a4..c69ae38b1287 100644 --- a/configure.ac +++ b/configure.ac @@ -262,6 +262,12 @@ AC_ARG_ENABLE([multiprocess], [enable_multiprocess=$enableval], [enable_multiprocess=no]) +AC_ARG_WITH([drogon], + [AS_HELP_STRING([--with-drogon=yes|no|auto], + [enable drogon server support (default: auto, i.e., enabled if drogon is found)])], + [use_drogon=$withval], + [use_drogon=auto]) + AC_ARG_ENABLE(man, [AS_HELP_STRING([--disable-man], [do not install man pages (default is to install)])],, @@ -1455,6 +1461,7 @@ if test "$enable_fuzz" = "yes"; then use_upnp=no use_natpmp=no use_zmq=no + use_drogon=no enable_fuzz_binary=yes AX_CHECK_PREPROC_FLAG([-DABORT_ON_FAILED_ASSUME], [DEBUG_CPPFLAGS="$DEBUG_CPPFLAGS -DABORT_ON_FAILED_ASSUME"], [], [$CXXFLAG_WERROR]) @@ -1547,6 +1554,7 @@ if test "$build_bitcoind$bitcoin_enable_qt$use_bench$use_tests" = "nononono"; th use_upnp=no use_natpmp=no use_zmq=no + use_drogon=no fi dnl Check for libminiupnpc (optional) @@ -1721,6 +1729,35 @@ LDFLAGS="$TEMP_LDFLAGS" AM_CONDITIONAL([ENABLE_ZMQ], [test "$use_zmq" = "yes"]) +dnl Drogon server check +if test "$use_drogon" != "no"; then + AC_CHECK_HEADER([drogon/drogon.h], [have_drogon=yes], [have_drogon=no]) +fi +AC_MSG_CHECKING([whether to build with drogon server support]) +if test "$use_drogon" = "no"; then + use_drogon=no +elif test "$have_drogon" = "no"; then + if test "$use_drogon" = "yes"; then + AC_MSG_ERROR([drogon support requested but headers not found. Use --without-drogon]) + fi + use_drogon=no +else + AC_DEFINE([USE_DROGON], [1], [Define this symbol to enable the Drogon server]) + DROGON_LIBS="-ldrogon -ltrantor -ljsoncpp -lz" + case $host in + *linux*) + DROGON_LIBS="$DROGON_LIBS -ldl -luuid" + ;; + *darwin*) + DROGON_LIBS="$DROGON_LIBS -ldl" + ;; + esac + use_drogon=yes +fi +AC_MSG_RESULT([$use_drogon]) + +AM_CONDITIONAL([USE_DROGON], [test "$use_drogon" = "yes"]) + dnl libmultiprocess library check libmultiprocess_found=no @@ -2043,6 +2080,7 @@ AC_SUBST(BACKTRACE_FLAGS) AC_SUBST(BACKTRACE_LDFLAGS) AC_SUBST(BACKTRACE_LIBS) AC_SUBST(GMP_LIBS) +AC_SUBST(DROGON_LIBS) AC_SUBST(NATPMP_CPPFLAGS) AC_SUBST(NATPMP_LIBS) AC_SUBST(DSYMUTIL_FLAT) @@ -2107,6 +2145,7 @@ echo "Options used to compile and link:" echo " external signer = $use_external_signer" echo " multiprocess = $build_multiprocess" echo " with libs = $build_bitcoin_libs" +echo " with drogon = $use_drogon" echo " with wallet = $enable_wallet" if test "$enable_wallet" != "no"; then echo " with sqlite = $use_sqlite" diff --git a/src/Makefile.am b/src/Makefile.am index 58e4688b8698..099031e8dc3b 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -58,6 +58,9 @@ LIBSECP256K1=secp256k1/libsecp256k1.la if ENABLE_ZMQ LIBBITCOIN_ZMQ=libbitcoin_zmq.a endif +if USE_DROGON +LIBBITCOIN_QUERY=libbitcoin_query.a +endif if BUILD_BITCOIN_LIBS LIBBITCOINCONSENSUS=libdashconsensus.la endif @@ -120,7 +123,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBBITCOIN_QUERY) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -654,6 +658,13 @@ libbitcoin_zmq_a_SOURCES = \ endif # +# query # +if USE_DROGON +libbitcoin_query_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) +libbitcoin_query_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +endif +# + # wallet # libbitcoin_wallet_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(BDB_CPPFLAGS) $(SQLITE_CFLAGS) libbitcoin_wallet_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) @@ -1051,6 +1062,7 @@ bitcoin_bin_ldadd = \ $(LIBBITCOIN_UTIL) \ $(LIBUNIVALUE) \ $(LIBBITCOIN_ZMQ) \ + $(LIBBITCOIN_QUERY) \ $(LIBBITCOIN_CONSENSUS) \ $(LIBBITCOIN_CRYPTO) \ $(LIBDASHBLS) \ @@ -1058,7 +1070,7 @@ bitcoin_bin_ldadd = \ $(LIBMEMENV) \ $(LIBSECP256K1) -bitcoin_bin_ldadd += $(BACKTRACE_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(ZMQ_LIBS) $(GMP_LIBS) +bitcoin_bin_ldadd += $(BACKTRACE_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(ZMQ_LIBS) $(DROGON_LIBS) $(GMP_LIBS) dashd_SOURCES = $(bitcoin_daemon_sources) init/bitcoind.cpp dashd_CPPFLAGS = $(bitcoin_bin_cppflags) diff --git a/src/Makefile.bench.include b/src/Makefile.bench.include index be7bfe2d2dcc..e061545ce439 100644 --- a/src/Makefile.bench.include +++ b/src/Makefile.bench.include @@ -69,6 +69,7 @@ bench_bench_dash_LDADD = \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_COMMON) \ $(LIBBITCOIN_UTIL) \ + $(LIBBITCOIN_QUERY) \ $(LIBBITCOIN_CONSENSUS) \ $(LIBBITCOIN_CRYPTO) \ $(LIBDASHBLS) \ @@ -80,6 +81,7 @@ bench_bench_dash_LDADD = \ $(EVENT_LIBS) \ $(MINIUPNPC_LIBS) \ $(NATPMP_LIBS) \ + $(DROGON_LIBS) \ $(GMP_LIBS) \ $(BACKTRACE_LIBS) diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 04652688dbef..bfe37c4db4ab 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -462,6 +462,9 @@ endif if ENABLE_ZMQ bitcoin_qt_ldadd += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if USE_DROGON +bitcoin_qt_ldadd += $(LIBBITCOIN_QUERY) $(DROGON_LIBS) +endif bitcoin_qt_ldadd += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) $(LIBMEMENV) \ $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(GMP_LIBS) diff --git a/src/Makefile.qttest.include b/src/Makefile.qttest.include index 62071d40e5a5..cee5faa867ac 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -51,7 +51,7 @@ endif # ENABLE_WALLET nodist_qt_test_test_dash_qt_SOURCES = $(TEST_QT_MOC_CPP) -qt_test_test_dash_qt_LDADD = $(LIBBITCOINQT) $(LIBBITCOIN_NODE) $(LIBTEST_UTIL) +qt_test_test_dash_qt_LDADD = $(LIBBITCOINQT) $(LIBBITCOIN_NODE) $(LIBTEST_UTIL) $(LIBBITCOIN_QUERY) if ENABLE_WALLET qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_UTIL) $(LIBBITCOIN_WALLET) endif @@ -61,7 +61,7 @@ endif qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) \ $(LIBMEMENV) $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QT_TEST_LIBS) \ $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ - $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(GMP_LIBS) + $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(DROGON_LIBS) $(GMP_LIBS) qt_test_test_dash_qt_LDFLAGS = $(LDFLAGS_WRAP_EXCEPTIONS) $(RELDFLAGS) $(AM_LDFLAGS) $(QT_LDFLAGS) $(LIBTOOL_APP_LDFLAGS) $(PTHREAD_FLAGS) qt_test_test_dash_qt_CXXFLAGS = $(AM_CXXFLAGS) $(QT_PIE_FLAGS) diff --git a/src/Makefile.test.include b/src/Makefile.test.include index dd6dda7178c3..f7326b8a07a6 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -256,11 +256,11 @@ if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) endif -test_test_dash_LDADD += $(LIBBITCOIN_NODE) $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBUNIVALUE) \ +test_test_dash_LDADD += $(LIBBITCOIN_NODE) $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_QUERY) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBUNIVALUE) \ $(LIBDASHBLS) $(LIBLEVELDB) $(LIBMEMENV) $(BACKTRACE_LIBS) $(LIBSECP256K1) $(EVENT_LIBS) $(EVENT_PTHREADS_LIBS) $(MINISKETCH_LIBS) test_test_dash_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) -test_test_dash_LDADD += $(BDB_LIBS) $(MINIUPNPC_LIBS) $(SQLITE_LIBS) $(NATPMP_LIBS) $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(GMP_LIBS) +test_test_dash_LDADD += $(BDB_LIBS) $(MINIUPNPC_LIBS) $(SQLITE_LIBS) $(NATPMP_LIBS) $(DROGON_LIBS) $(GMP_LIBS) test_test_dash_LDFLAGS = $(LDFLAGS_WRAP_EXCEPTIONS) $(RELDFLAGS) $(AM_LDFLAGS) $(LIBTOOL_APP_LDFLAGS) $(PTHREAD_FLAGS) -static if ENABLE_ZMQ From 1c866ec08baf788b3092d54afc17b83876ca655b Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:31:34 +0530 Subject: [PATCH 15/21] build: make REST server conditional on Drogon inclusion --- src/Makefile.am | 7 ++++--- src/Makefile.test.include | 7 ++++++- src/httprpc.h | 6 ++++++ src/init.cpp | 6 ++++++ test/config.ini.in | 1 + test/functional/interface_rest.py | 3 +++ test/functional/test_framework/test_framework.py | 9 +++++++++ 7 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index 099031e8dc3b..8c6c4a57c1d5 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -346,7 +346,6 @@ BITCOIN_CORE_H = \ psbt.h \ random.h \ randomenv.h \ - rest/server.h \ rpc/blockchain.h \ rpc/client.h \ rpc/evo_util.h \ @@ -602,7 +601,6 @@ libbitcoin_node_a_SOURCES = \ policy/policy.cpp \ policy/settings.cpp \ pow.cpp \ - rest/server.cpp \ rpc/blockchain.cpp \ rpc/coinjoin.cpp \ rpc/evo.cpp \ @@ -660,8 +658,11 @@ endif # query # if USE_DROGON -libbitcoin_query_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) +libbitcoin_query_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(EVENT_CFLAGS) $(EVENT_PTHREADS_CFLAGS) libbitcoin_query_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +libbitcoin_query_a_SOURCES = \ + rest/server.cpp \ + rest/server.h endif # diff --git a/src/Makefile.test.include b/src/Makefile.test.include index f7326b8a07a6..ca77cf73eadb 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -162,7 +162,6 @@ BITCOIN_TESTS =\ test/raii_event_tests.cpp \ test/random_tests.cpp \ test/ratecheck_tests.cpp \ - test/rest_tests.cpp \ test/result_tests.cpp \ test/reverselock_tests.cpp \ test/rpc_tests.cpp \ @@ -268,6 +267,12 @@ test_test_dash_LDADD += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) FUZZ_SUITE_LD_COMMON += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if USE_DROGON +BITCOIN_TESTS += \ + test/rest_tests.cpp +FUZZ_SUITE_LD_COMMON += $(LIBBITCOIN_QUERY) $(DROGON_LIBS) +endif + if ENABLE_FUZZ_BINARY test_fuzz_fuzz_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) test_fuzz_fuzz_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/httprpc.h b/src/httprpc.h index a486cc2d063c..fe45ce9351ee 100644 --- a/src/httprpc.h +++ b/src/httprpc.h @@ -5,6 +5,10 @@ #ifndef BITCOIN_HTTPRPC_H #define BITCOIN_HTTPRPC_H +#if defined(HAVE_CONFIG_H) +#include +#endif + #include /** Start HTTP RPC subsystem. @@ -19,6 +23,7 @@ void InterruptHTTPRPC(); */ void StopHTTPRPC(); +#ifdef USE_DROGON /** Start HTTP REST subsystem. * Precondition; HTTP and RPC has been started. */ @@ -30,5 +35,6 @@ void InterruptREST(); * Precondition; HTTP and RPC has been stopped. */ void StopREST(); +#endif // USE_DROGON #endif // BITCOIN_HTTPRPC_H diff --git a/src/init.cpp b/src/init.cpp index 5c2a0bcf0ec2..59bb716d7778 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -251,7 +251,9 @@ void Interrupt(NodeContext& node) InterruptHTTPServer(); InterruptHTTPRPC(); InterruptRPC(); +#ifdef USE_DROGON InterruptREST(); +#endif // USE_DROGON InterruptTorControl(); if (node.peerman) { node.peerman->InterruptHandlers(); @@ -285,7 +287,9 @@ void PrepareShutdown(NodeContext& node) if (node.mempool) node.mempool->AddTransactionsUpdated(1); StopHTTPRPC(); +#ifdef USE_DROGON StopREST(); +#endif // USE_DROGON StopRPC(); StopHTTPServer(); @@ -931,7 +935,9 @@ static bool AppInitServers(NodeContext& node) node.rpc_interruption_point = RpcInterruptionPoint; if (!StartHTTPRPC(node)) return false; +#ifdef USE_DROGON if (args.GetBoolArg("-rest", DEFAULT_REST_ENABLE)) StartREST(node); +#endif // USE_DROGON StartHTTPServer(); return true; } diff --git a/test/config.ini.in b/test/config.ini.in index af3d994c84d3..e6dece6c445b 100644 --- a/test/config.ini.in +++ b/test/config.ini.in @@ -26,3 +26,4 @@ RPCAUTH=@abs_top_srcdir@/share/rpcauth/rpcauth.py @ENABLE_ZMQ_TRUE@ENABLE_ZMQ=true @ENABLE_EXTERNAL_SIGNER_TRUE@ENABLE_EXTERNAL_SIGNER=true @ENABLE_USDT_TRACEPOINTS_TRUE@ENABLE_USDT_TRACEPOINTS=true +@USE_DROGON_TRUE@USE_DROGON=true diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py index 3d4345f31ae4..4762a17f2354 100755 --- a/test/functional/interface_rest.py +++ b/test/functional/interface_rest.py @@ -58,6 +58,9 @@ def set_test_params(self): args.append("-whitelist=noban@127.0.0.1") self.supports_cli = False + def skip_test_if_missing_module(self): + self.skip_if_no_drogon() + def test_rest_request( self, uri: str, diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 8d6ad3f51550..9c367f4ac3de 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -1160,6 +1160,15 @@ def is_bdb_compiled(self): """Checks whether the wallet module was compiled with BDB support.""" return self.config["components"].getboolean("USE_BDB") + def is_drogon_compiled(self): + """Checks whether dashd was compiled with Drogon support.""" + return self.config["components"].getboolean("USE_DROGON", fallback=False) + + def skip_if_no_drogon(self): + """Skip the running test if dashd has not been compiled with Drogon support.""" + if not self.is_drogon_compiled(): + raise SkipTest("dashd has not been built with Drogon enabled.") + MASTERNODE_COLLATERAL = 1000 EVONODE_COLLATERAL = 4000 From d047c3683a4e4215a4df3d3a31dc0c0900caa6cf Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:25:34 +0530 Subject: [PATCH 16/21] trivial: add BCLog::REST, dedicated port numbers for REST server Currently unused but will be important in the next commit. --- src/chainparamsbase.cpp | 8 ++++---- src/chainparamsbase.h | 6 ++++-- src/logging.cpp | 3 +++ src/logging.h | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/chainparamsbase.cpp b/src/chainparamsbase.cpp index 1a5fb9a8a4b2..242ab5ef1392 100644 --- a/src/chainparamsbase.cpp +++ b/src/chainparamsbase.cpp @@ -39,13 +39,13 @@ const CBaseChainParams& BaseParams() std::unique_ptr CreateBaseChainParams(const std::string& chain) { if (chain == CBaseChainParams::MAIN) - return std::make_unique("", 9998, 9996); + return std::make_unique("", 9998, 9997, 9996); else if (chain == CBaseChainParams::TESTNET) - return std::make_unique("testnet3", 19998, 19996); + return std::make_unique("testnet3", 19998, 19997, 19996); else if (chain == CBaseChainParams::DEVNET) - return std::make_unique(gArgs.GetDevNetName(), 19798, 19796); + return std::make_unique(gArgs.GetDevNetName(), 19798, 19797, 19796); else if (chain == CBaseChainParams::REGTEST) - return std::make_unique("regtest", 19898, 19896); + return std::make_unique("regtest", 19898, 19897, 19896); else throw std::runtime_error(strprintf("%s: Unknown chain %s.", __func__, chain)); } diff --git a/src/chainparamsbase.h b/src/chainparamsbase.h index 718392e04def..7f7f8938188b 100644 --- a/src/chainparamsbase.h +++ b/src/chainparamsbase.h @@ -26,14 +26,16 @@ class CBaseChainParams ///@} const std::string& DataDir() const { return strDataDir; } + uint16_t RESTPort() const { return m_rest_port; } uint16_t RPCPort() const { return m_rpc_port; } uint16_t OnionServiceTargetPort() const { return m_onion_service_target_port; } CBaseChainParams() = delete; - CBaseChainParams(const std::string& data_dir, uint16_t rpc_port, uint16_t onion_service_target_port) - : m_rpc_port(rpc_port), m_onion_service_target_port(onion_service_target_port), strDataDir(data_dir) {} + CBaseChainParams(const std::string& data_dir, uint16_t rpc_port, uint16_t rest_port, uint16_t onion_service_target_port) + : m_rest_port(rest_port), m_rpc_port(rpc_port), m_onion_service_target_port(onion_service_target_port), strDataDir(data_dir) {} private: + const uint16_t m_rest_port; const uint16_t m_rpc_port; const uint16_t m_onion_service_target_port; std::string strDataDir; diff --git a/src/logging.cpp b/src/logging.cpp index 98dcf1e2c69d..7c6359ccf167 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -197,6 +197,7 @@ const CLogCategoryDesc LogCategories[] = {BCLog::NETCONN, "netconn"}, {BCLog::CREDITPOOL, "creditpool"}, {BCLog::EHF, "ehf"}, + {BCLog::REST, "rest"}, {BCLog::DASH, "dash"}, //End Dash }; @@ -328,6 +329,8 @@ std::string LogCategoryToStr(BCLog::LogFlags category) return "dash"; case BCLog::LogFlags::NET_NETCONN: return "net|netconn"; + case BCLog::LogFlags::REST: + return "rest"; /* End Dash */ case BCLog::LogFlags::ALL: return "all"; diff --git a/src/logging.h b/src/logging.h index d0b2069319f0..6ec8cad594db 100644 --- a/src/logging.h +++ b/src/logging.h @@ -84,10 +84,11 @@ namespace BCLog { NETCONN = ((uint64_t)1 << 43), EHF = ((uint64_t)1 << 44), CREDITPOOL = ((uint64_t)1 << 45), + REST = ((uint64_t)1 << 46), DASH = CHAINLOCKS | GOBJECT | INSTANTSEND | LLMQ | LLMQ_DKG | LLMQ_SIGS | MNPAYMENTS | MNSYNC | COINJOIN | SPORK | NETCONN - | EHF | CREDITPOOL, + | EHF | CREDITPOOL | REST, NET_NETCONN = NET | NETCONN, // use this to have something logged in NET and NETCONN as well //End Dash From 48bdf044c88573debea3678bbd6706f8038ee04e Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:25:11 +0530 Subject: [PATCH 17/21] fix: avoid RLIM_INFINITY overflow in RaiseFileDescriptorLimit() --- src/util/system.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/system.cpp b/src/util/system.cpp index d769dbd055be..e2923a21f4dd 100644 --- a/src/util/system.cpp +++ b/src/util/system.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -1280,7 +1281,7 @@ int RaiseFileDescriptorLimit(int nMinFD) { setrlimit(RLIMIT_NOFILE, &limitFD); getrlimit(RLIMIT_NOFILE, &limitFD); } - return limitFD.rlim_cur; + return std::min(limitFD.rlim_cur, std::numeric_limits::max()); } return nMinFD; // getrlimit failed, assume it's fine #endif From 9a8bc2643a7bbcf964784c144906f421214c994e Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:30:42 +0530 Subject: [PATCH 18/21] feat: switch REST server over to Drogon, add test for arg interactions --- doc/REST-interface.md | 32 +- src/Makefile.am | 2 +- src/httprpc.h | 18 - src/init.cpp | 88 ++++- src/rest/server.cpp | 493 +++++++++++++++---------- src/rest/server.h | 54 +++ src/util/sock.cpp | 47 +++ src/util/sock.h | 8 + test/bench/rest_bench.py | 14 +- test/functional/feature_rest.py | 144 ++++++++ test/functional/interface_rest.py | 8 +- test/functional/test_framework/util.py | 4 + test/functional/test_runner.py | 1 + 13 files changed, 666 insertions(+), 247 deletions(-) create mode 100755 test/functional/feature_rest.py diff --git a/doc/REST-interface.md b/doc/REST-interface.md index 4b95ec1e3210..1046b53c120c 100644 --- a/doc/REST-interface.md +++ b/doc/REST-interface.md @@ -3,8 +3,20 @@ Unauthenticated REST Interface The REST API can be enabled with the `-rest` option. -The interface runs on the same port as the JSON-RPC interface, by default port 9998 for mainnet, port 19998 for testnet, -port 19788 for devnet, and port 19898 for regtest. +The interface runs on its own dedicated port, separate from the JSON-RPC interface. Default port 9997 for mainnet, port 19997 for testnet, port 19897 for regtest and port 19797 for devnet. The port can be overridden with `-restport=`. + +Configuration +------------- + +| Option | Default | Description | +|---------------------------|-------------|--------------------------------------------------------------| +| `-rest` | `0` | Enable the REST server. | +| `-restbind=` | `127.0.0.1` | IP address to bind to. | +| `-restport=` | per-network | Port to listen on (1–65535). | +| `-restthreads=` | `4` | Number of I/O threads. | +| `-restmaxconnections=` | `100` | Maximum concurrent connections (1–65535). | +| `-restidletimeout=` | `30` | Seconds before an idle connection is closed (5–3600). | +| `-restreuseport` | `false` | Allow multiple sockets to bind to the same port (Linux only).| REST Interface consistency guarantees ------------------------------------- @@ -12,18 +24,6 @@ REST Interface consistency guarantees The [same guarantees as for the RPC Interface](/doc/JSON-RPC-interface.md#rpc-consistency-guarantees) apply. -Limitations ------------ - -There is a known issue in the REST interface that can cause a node to crash if -too many http connections are being opened at the same time because the system runs -out of available file descriptors. To prevent this from happening you might -want to increase the number of maximum allowed file descriptors in your system -and try to prevent opening too many connections to your rest interface at the -same time if this is under your control. It is hard to give general advice -since this depends on your system but if you make several hundred requests at -once you are definitely at risk of encountering this issue. - Supported API ------------- @@ -97,7 +97,7 @@ input and output serialization (relevant for `bin` and `hex` output formats). Example: ``` -$ curl localhost:19998/rest/getutxos/checkmempool/b2cdfd7b89def827ff8af7cd9bff7627ff72e5e8b0f71210f92ea7a4000c5d75-0.json 2>/dev/null | json_pp +$ curl localhost:19997/rest/getutxos/checkmempool/b2cdfd7b89def827ff8af7cd9bff7627ff72e5e8b0f71210f92ea7a4000c5d75-0.json 2>/dev/null | json_pp { "chainHeight" : 325347, "chaintipHash" : "00000000fb01a7f3745a717f8caebee056c484e6e0bfe4a9591c235bb70506fb", @@ -133,4 +133,4 @@ Refer to the `getrawmempool` RPC help for details. Risks ------------- -Running a web browser on the same node with a REST enabled dashd can be a risk. Accessing prepared XSS websites could read out tx/block data of your node by placing links like `