From a3d4b2ed29ee7144ee6a6db44409260737c64ecb Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 21:03:45 -0500 Subject: [PATCH 1/5] build(depends): add a prebuilt Rust toolchain and per-host standard library native_rust stages the prebuilt Rust 1.98.1 compiler and Cargo (the toolchain dashpay/platform pins) for the four supported build hosts. rust_stdlib stages the standard library for the host, for every default Guix host. The prebuilt toolchain does not run inside a Guix environment as is, so native_rust stops there with an error saying PLATFORM_GUI is not supported in Guix builds yet. Linux hosts use the glibc (-unknown-linux-gnu) standard library, the one Rust supports for linking into a glibc program. Its libc imports are unversioned and bind to the glibc the program is linked against; every symbol it requires unconditionally is in glibc 2.31 on all five Linux architectures. contrib/devtools/update-rust-hashes.py refreshes the pins and requires every download to match the .sha256 file static.rust-lang.org publishes; --check compares the pins with those files. Nothing uses the packages yet. Co-Authored-By: Claude Opus 5.5 --- contrib/devtools/README.md | 10 +++ contrib/devtools/update-rust-hashes.py | 113 +++++++++++++++++++++++++ depends/packages/native_rust.mk | 52 ++++++++++++ depends/packages/rust_stdlib.mk | 65 ++++++++++++++ 4 files changed, 240 insertions(+) create mode 100755 contrib/devtools/update-rust-hashes.py create mode 100644 depends/packages/native_rust.mk create mode 100644 depends/packages/rust_stdlib.mk diff --git a/contrib/devtools/README.md b/contrib/devtools/README.md index 358c238866a3..6bcdf93f6df5 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -203,3 +203,13 @@ Example usage: cd .../src ../contrib/devtools/circular-dependencies.py {*,*/*,*/*/*}.{h,cpp} + +update-rust-hashes.py +===================== + +Refreshes the sha256 pins of the prebuilt Rust toolchain +(`depends/packages/native_rust.mk`) and of the per-host standard libraries +(`depends/packages/rust_stdlib.mk`) after the version in `native_rust.mk` was +changed; each downloaded archive must match the `.sha256` file published next +to it. `--check` compares the pins with the published `.sha256` files instead +of rewriting them. diff --git a/contrib/devtools/update-rust-hashes.py b/contrib/devtools/update-rust-hashes.py new file mode 100755 index 000000000000..5017729bf51d --- /dev/null +++ b/contrib/devtools/update-rust-hashes.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021-2022 The Zcash developers +# 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. +''' +Refresh the sha256 pins of the prebuilt Rust toolchain (native_rust.mk) and +the per-host standard libraries (rust_stdlib.mk) for the version set in +native_rust.mk, or verify them with --check. + +Rewriting downloads every archive from static.rust-lang.org, hashes it locally +and requires the result to match the .sha256 file published next to it. +--check only compares the pins with the published .sha256 files. +''' +import argparse +import hashlib +import http.client +import re +import sys +import urllib.request +from pathlib import Path + +DIST_URL = "https://static.rust-lang.org/dist" +TIMEOUT = 60 # seconds without data before a request fails +PACKAGES_DIR = Path(__file__).resolve().parents[2] / "depends" / "packages" +NATIVE_RUST_MK = PACKAGES_DIR / "native_rust.mk" +RUST_STDLIB_MK = PACKAGES_DIR / "rust_stdlib.mk" + + +def pins(content: str, kind: str) -> dict: + """Return {id: value} for every `$(package)__:=` line.""" + return dict(re.findall(rf"^\$\(package\)_{kind}_(\w+):=(\S+)$", content, re.MULTILINE)) + + +def published_sha256(url: str) -> str: + """Return the hash in the ` ` line of `.sha256`.""" + with urllib.request.urlopen(f"{url}.sha256", timeout=TIMEOUT) as response: + return response.read().decode().split()[0] + + +def sha256_of(url: str, attempts: int = 3) -> str: + for _ in range(attempts): + hasher = hashlib.sha256() + received = 0 + try: + with urllib.request.urlopen(url, timeout=TIMEOUT) as response: + length = response.headers["Content-Length"] + while chunk := response.read(1 << 20): + hasher.update(chunk) + received += len(chunk) + except (http.client.IncompleteRead, OSError) as error: + print(f"warning: {url}: {error}", file=sys.stderr) + continue + if length is None or received == int(length): + return hasher.hexdigest() + print(f"warning: {url}: download ended after {received} of {length} bytes", file=sys.stderr) + sys.exit(f"error: {url}: no complete download in {attempts} attempts") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help="verify the pinned hashes instead of rewriting them") + args = parser.parse_args() + + native = NATIVE_RUST_MK.read_text(encoding="utf-8") + stdlib = RUST_STDLIB_MK.read_text(encoding="utf-8") + version_pin = re.search(r"^\$\(package\)_version:=(\S+)$", native, re.MULTILINE) + if version_pin is None: + sys.exit(f"error: no version pin in {NATIVE_RUST_MK}") + version = version_pin.group(1) + + archives = [] # (makefile, pin id, archive name) + build_targets = pins(native, "build_target") + for build_id, file_name in pins(native, "file_name").items(): + file_name = file_name.replace("$($(package)_version)", version) + if build_id not in build_targets: + sys.exit(f"error: {NATIVE_RUST_MK} has no build_target_{build_id} pin") + file_name = file_name.replace(f"$($(package)_build_target_{build_id})", build_targets[build_id]) + archives.append((NATIVE_RUST_MK, build_id, file_name)) + for host_id, target in pins(stdlib, "target").items(): + archives.append((RUST_STDLIB_MK, host_id, f"rust-std-{version}-{target}.tar.gz")) + + contents = {NATIVE_RUST_MK: native, RUST_STDLIB_MK: stdlib} + stale = [] + for makefile, pin_id, archive in archives: + pinned = pins(contents[makefile], "sha256_hash").get(pin_id) + if pinned is None: + sys.exit(f"error: {makefile} has no sha256_hash_{pin_id} pin") + url = f"{DIST_URL}/{archive}" + actual = published_sha256(url) + if not args.check: + downloaded = sha256_of(url) + if downloaded != actual: + sys.exit(f"error: {archive} hashes to {downloaded}, but its published .sha256 says {actual}") + print(f"{actual} {archive}") + if actual != pinned: + stale.append(archive) + contents[makefile] = re.sub(rf"^(\$\(package\)_sha256_hash_{pin_id}:=)\S*$", + rf"\g<1>{actual}", contents[makefile], flags=re.MULTILINE) + + if args.check: + for archive in stale: + print(f"error: pinned hash for {archive} does not match", file=sys.stderr) + return 1 if stale else 0 + + for makefile, content in contents.items(): + makefile.write_text(content, encoding="utf-8") + print(f"Updated {len(stale)} of {len(archives)} pins for Rust {version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/depends/packages/native_rust.mk b/depends/packages/native_rust.mk new file mode 100644 index 000000000000..97ac7d34deea --- /dev/null +++ b/depends/packages/native_rust.mk @@ -0,0 +1,52 @@ +# Copyright (c) 2016-2025 The Zcash developers +# 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. + +# To update the Rust compiler, change the version below and then run the script +# ./contrib/devtools/update-rust-hashes.py + +package:=native_rust +$(package)_version:=1.98.1 +$(package)_download_path:=https://static.rust-lang.org/dist + +# Linux (ARMv8) +$(package)_build_target_aarch64_linux:=aarch64-unknown-linux-gnu +$(package)_file_name_aarch64_linux:=rust-$($(package)_version)-$($(package)_build_target_aarch64_linux).tar.gz +$(package)_sha256_hash_aarch64_linux:=f00ba576645cef658e1deed96fab8f707958e9d58808b16343448b5d1c4f7407 + +# Linux (x86_64) +$(package)_build_target_x86_64_linux:=x86_64-unknown-linux-gnu +$(package)_file_name_x86_64_linux:=rust-$($(package)_version)-$($(package)_build_target_x86_64_linux).tar.gz +$(package)_sha256_hash_x86_64_linux:=24ba1338a2d35c5a3247936546429e163fa674d726102af18bdf624582c57aea + +# macOS (ARMv8) +$(package)_build_target_aarch64_darwin:=aarch64-apple-darwin +$(package)_file_name_aarch64_darwin:=rust-$($(package)_version)-$($(package)_build_target_aarch64_darwin).tar.gz +$(package)_sha256_hash_aarch64_darwin:=cfc171d8120d401b10a1028c52646dd8e00e3e66852f949061ce087845f55afd + +# macOS (x86_64) +$(package)_build_target_x86_64_darwin:=x86_64-apple-darwin +$(package)_file_name_x86_64_darwin:=rust-$($(package)_version)-$($(package)_build_target_x86_64_darwin).tar.gz +$(package)_sha256_hash_x86_64_darwin:=443a1165abbac41c9143b83ff837c0fb1d8c03d2f8fb1da27427bc9fc646aad3 + +# The Rust target triple of the build machine. +$(package)_build_target:=$($(package)_build_target_$(build_arch)_$(build_os)) +$(package)_file_name=$($(package)_file_name_$(build_arch)_$(build_os)) +$(package)_sha256_hash=$($(package)_sha256_hash_$(build_arch)_$(build_os)) + +ifeq ($($(package)_file_name),) +$(error native_rust has no prebuilt Rust $($(package)_version) for $(build_arch)-$(build_os)) +endif + +# The prebuilt toolchain cannot run inside a Guix environment as is. +ifneq ($(GUIX_ENVIRONMENT),) +$(error native_rust: PLATFORM_GUI is not supported in Guix builds yet) +endif + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/bin $($(package)_staging_prefix_dir)/lib/rustlib && \ + cp cargo/bin/cargo rustc/bin/rustc $($(package)_staging_prefix_dir)/bin/ && \ + cp -R rustc/lib/. $($(package)_staging_prefix_dir)/lib/ && \ + cp -R rust-std-*/lib/rustlib/. $($(package)_staging_prefix_dir)/lib/rustlib/ +endef diff --git a/depends/packages/rust_stdlib.mk b/depends/packages/rust_stdlib.mk new file mode 100644 index 000000000000..e6600acac2be --- /dev/null +++ b/depends/packages/rust_stdlib.mk @@ -0,0 +1,65 @@ +# Copyright (c) 2016-2025 The Zcash developers +# 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. + +# Precompiled Rust standard library for the host, installed next to the +# native_rust compiler. The version follows native_rust.mk; update both with +# ./contrib/devtools/update-rust-hashes.py + +package:=rust_stdlib +$(package)_version:=$(native_rust_version) +$(package)_download_path:=$(native_rust_download_path) + +# Every host in contrib/guix/guix-build's default HOSTS has an entry. Linux +# hosts use the glibc (-unknown-linux-gnu) standard library, the one Rust +# supports for linking into a glibc program. Its libc imports are unversioned +# in the archive and bind at link time to the glibc the program is linked +# against; every symbol it requires unconditionally exists in glibc 2.31 on +# all five Linux architectures (the rest are weak and looked up at run time). + +# Linux (x86_64) +$(package)_target_x86_64_linux:=x86_64-unknown-linux-gnu +$(package)_sha256_hash_x86_64_linux:=eddab0358cbd12aeb897716aab00d1db7b59696e85b9ac4982e72259a9a976b1 + +# Linux (ARMv8) +$(package)_target_aarch64_linux:=aarch64-unknown-linux-gnu +$(package)_sha256_hash_aarch64_linux:=779407b14507542581216d89eb9f3fbb232abbf3abcc15c365cb32fa0614e409 + +# Linux (RISC-V 64) +$(package)_target_riscv64_linux:=riscv64gc-unknown-linux-gnu +$(package)_sha256_hash_riscv64_linux:=bea4eac8f0b752aec63389d626d96280424da68b033c2d515bc4af204f07bf44 + +# Linux (ARMv7, hard float) +$(package)_target_arm_linux:=armv7-unknown-linux-gnueabihf +$(package)_sha256_hash_arm_linux:=6f15060d308793d1687a5092c80f2fbebc808c73096980b67f9de71d4f54f92c + +# Linux (POWER, big endian) +$(package)_target_powerpc64_linux:=powerpc64-unknown-linux-gnu +$(package)_sha256_hash_powerpc64_linux:=2d6268b4dddc385c24ae77b2e1fe16161781b92958108cbb2a6f9fab21689823 + +# Windows (x86_64) +$(package)_target_x86_64_mingw32:=x86_64-pc-windows-gnu +$(package)_sha256_hash_x86_64_mingw32:=0cda26447df0749bc84044be8c8083ac4dc87bf137c11cc31ec9673a6e2e0344 + +# macOS (x86_64) +$(package)_target_x86_64_darwin:=x86_64-apple-darwin +$(package)_sha256_hash_x86_64_darwin:=af7ffb3b408aa2f6a6940fc83ea6dc9c3e919d18f1b04f1a581b7896441e8b78 + +# macOS (ARMv8) +$(package)_target_aarch64_darwin:=aarch64-apple-darwin +$(package)_sha256_hash_aarch64_darwin:=840484e8f9c2a8ed024b706262a1257bb07d9617670a1fc90020536282950690 + +$(package)_target:=$($(package)_target_$(host_arch)_$(host_os)) +$(package)_sha256_hash:=$($(package)_sha256_hash_$(host_arch)_$(host_os)) + +ifeq ($($(package)_target),) +$(error rust_stdlib has no Rust standard library for $(host)) +endif + +$(package)_file_name:=rust-std-$($(package)_version)-$($(package)_target).tar.gz + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_dir)$(build_prefix)/lib/rustlib && \ + cp -R rust-std-$($(package)_target)/lib/rustlib/$($(package)_target) $($(package)_staging_dir)$(build_prefix)/lib/rustlib/ +endef From a8fcab800e3c341995ea4d068dc30ac352410a3d Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 21:03:58 -0500 Subject: [PATCH 2/5] build(depends): build dash-sdk-cxx behind a PLATFORM_GUI knob PLATFORM_GUI=1 adds native_rust, rust_stdlib, prebuilt protoc 32.0 (native_protobuf) and platform_cxx, which builds dash-sdk-cxx, the C++ binding of the Dash Platform SDK (packages/rs-sdk-cxx of dashpay/platform), and installs libdash_sdk_cxx.a, its headers (dash/sdk/ffi.h, dash/sdk/callbacks.h, rust/cxx.h) and dash_sdk_cxx.pc. The knob follows MULTIPROCESS: default package sets are unchanged, and config.site enables --enable-platform-gui. Combining it with NO_QT or NO_WALLET is an error, since the binding is for the GUI wallet only. platform_cxx is built with cargo --frozen --offline from two sha256-pinned archives, the Platform source tarball at the pinned commit and a crate bundle; depends never vendors crates. Both archives must be on the depends sources mirror before this is merged. funcs.mk names a package's download stamp after its version and primary sha256 only. A new optional $(package)_fetched_id adds to that name, and platform_cxx sets it to the bundle hash, so a bundle regenerated for the same commit is fetched again. Otherwise a stale bundle and stamp, as the sources cache can restore them, pass the fetch step and fail only at extraction. Every other package's stamp name is unchanged. contrib/devtools/platform-bundle.sh produces the bundle reproducibly from a commit: workspace trimmed to the crate, Cargo.lock pruned to it, cargo vendor --locked --versioned-dirs, crates outside the build closure reduced to their manifests, the Tenderdash source archive for the tag the lock pins together with TENDERDASH_COMMITISH set to that tag, and tar and gzip with fixed metadata. It prints the pins for platform_cxx.mk. Only the bundle's Cargo configuration is used: Cargo runs from / with --config, its home is private, and variables that would change the build (wrappers, CARGO_BUILD_*, CARGO_PROFILE_*, CARGO_TARGET_*, per-target compiler overrides, TENDERDASH_*) are unset. The release profile is pinned to Platform's (Cargo's default, panic=unwind). The depends host compiler links the crate and compiles its C and C++, the build compiler links build scripts and proc macros, and the build directory is remapped out of the objects. The build refuses a dependency graph that reaches the trusted context provider, an HTTP client or OpenSSL. The system libraries the archive needs are a vetted per-OS list in platform_cxx.mk, which dash_sdk_cxx.pc passes on. The crate is built with cargo rustc --crate-type staticlib and --print=native-static-libs, and check-native-libs.sh fails the build if rustc reports a library that is neither vetted nor in the commented ignore set (the toolchain's C and C++ runtimes; glibc's -lutil and -lrt, which symbol-check.py does not allow and the standard library does not use; macOS system defaults). At the pinned commit (dashpay/platform dc4dc8e80ef8), rustc 1.98.1 reports for aarch64-apple-darwin: -lc++ -framework Security -framework CoreFoundation -liconv -lSystem -lc -lm The Linux and mingw-w64 lists are checked by the same step when those hosts build. Co-Authored-By: Claude Opus 5.5 --- contrib/devtools/README.md | 16 ++ contrib/devtools/platform-bundle.sh | 204 ++++++++++++++++ depends/Makefile | 11 + depends/README.md | 3 + depends/config.site.in | 4 + depends/funcs.mk | 2 +- depends/packages.md | 7 + depends/packages/native_protobuf.mk | 43 ++++ depends/packages/packages.mk | 3 + depends/packages/platform_cxx.mk | 222 ++++++++++++++++++ depends/patches/platform_cxx/build-linker.sh | 8 + .../patches/platform_cxx/check-native-libs.sh | 42 ++++ depends/patches/platform_cxx/rustc-linker.sh | 10 + doc/dependencies.md | 7 + 14 files changed, 581 insertions(+), 1 deletion(-) create mode 100755 contrib/devtools/platform-bundle.sh create mode 100644 depends/packages/native_protobuf.mk create mode 100644 depends/packages/platform_cxx.mk create mode 100755 depends/patches/platform_cxx/build-linker.sh create mode 100755 depends/patches/platform_cxx/check-native-libs.sh create mode 100755 depends/patches/platform_cxx/rustc-linker.sh diff --git a/contrib/devtools/README.md b/contrib/devtools/README.md index 6bcdf93f6df5..bc9fc492d819 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -213,3 +213,19 @@ Refreshes the sha256 pins of the prebuilt Rust toolchain changed; each downloaded archive must match the `.sha256` file published next to it. `--check` compares the pins with the published `.sha256` files instead of rewriting them. + +platform-bundle.sh +================== + +Produces the crate bundle from which `depends/packages/platform_cxx.mk` builds +`dash-sdk-cxx` (the C++ binding of the Dash Platform SDK) for a given +`dashpay/platform` commit, and prints the hashes to pin in `platform_cxx.mk`. +The bundle includes the Tenderdash source archive for the tag the commit's +`Cargo.lock` pins, so the two cannot disagree. Requires the Cargo version pinned in `native_rust.mk`, +GNU tar, GNU gzip (set `TAR` and `GZIP_PROG` if they are installed under other +names) and a `TMPDIR` with no `.cargo/config.toml` in any parent directory. +The output is reproducible: rerunning it for the same commit yields the same +bundle hash on any machine. The Platform tarball and the bundle are then +uploaded to the depends sources mirror. + + contrib/devtools/platform-bundle.sh diff --git a/contrib/devtools/platform-bundle.sh b/contrib/devtools/platform-bundle.sh new file mode 100755 index 000000000000..ec68ea48469d --- /dev/null +++ b/contrib/devtools/platform-bundle.sh @@ -0,0 +1,204 @@ +#!/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. + +export LC_ALL=C +set -euo pipefail + +# Produces the crate bundle that depends/packages/platform_cxx.mk builds +# dash-sdk-cxx from, for a given dashpay/platform commit. +# +# The bundle holds a Cargo workspace trimmed to packages/rs-sdk-cxx, the +# commit's Cargo.lock pruned to that workspace (entries are only removed, never +# changed), every locked crate vendored with `cargo vendor --locked +# --versioned-dirs`, the Tenderdash source archive tenderdash-proto's build +# script generates its protobuf code from (tenderdash/tenderdash-.zip, for +# the rs-tenderdash-abci tag the lock pins), and a .cargo/config.toml that +# replaces all crate sources with the vendored directory and sets +# TENDERDASH_COMMITISH to that same tag. Vendored crates outside the build +# closure of dash-sdk-cxx (dev-dependencies and crates only other +# workspace members use) are reduced to their manifest and empty target files: +# Cargo needs them to resolve the lock, never to build. The archive is written +# with a fixed file order, owner, mode and mtime, so the same commit yields the +# same sha256 on every machine that uses the pinned Rust version, GNU tar and +# GNU gzip (and as long as GitHub serves the same Tenderdash zip). +# +# This is the only step that downloads crates. The maintainer bumping the +# pin runs it, updates the hashes in platform_cxx.mk from its output, and +# uploads the Platform tarball and the bundle to the depends sources mirror; +# reviewers rerun it to reproduce the hash. + +usage() { + echo "Usage: $0 " >&2 + echo >&2 + echo "Writes platform-.tar.gz and dash-sdk-cxx-crates-.tar.gz to" >&2 + echo "SOURCES_PATH (default: depends/sources), and keeps the downloaded" >&2 + echo "tenderdash-.zip there." >&2 + exit 1 +} + +[ $# -eq 1 ] || usage +COMMIT="$1" +[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "error: expected a full 40-character commit hash" >&2; exit 1; } + +TOPDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SOURCES_PATH="${SOURCES_PATH:-$TOPDIR/depends/sources}" +mkdir -p "$SOURCES_PATH" +# The script changes directory below; a relative path must not follow it. +SOURCES_PATH="$(cd "$SOURCES_PATH" && pwd)" +# shellcheck disable=SC2016 +RUST_VERSION="$(sed -n 's/^\$(package)_version:=//p' "$TOPDIR/depends/packages/native_rust.mk")" +CARGO="${CARGO:-cargo}" +TAR="${TAR:-tar}" +GZIP_PROG="${GZIP_PROG:-gzip}" + +# cargo vendor output (normalized manifests) depends on the Cargo version. +export RUSTUP_TOOLCHAIN="${RUSTUP_TOOLCHAIN:-$RUST_VERSION}" +case "$("$CARGO" --version)" in + "cargo $RUST_VERSION "*) ;; + *) echo "error: $CARGO is not Cargo $RUST_VERSION (the version pinned in native_rust.mk)" >&2; exit 1 ;; +esac +case "$("$TAR" --version)" in + *"GNU tar"*) ;; + *) echo "error: $TAR is not GNU tar; set TAR" >&2; exit 1 ;; +esac +case "$("$GZIP_PROG" --version)" in + *"Free Software Foundation"*) ;; + *) echo "error: $GZIP_PROG is not GNU gzip; set GZIP_PROG" >&2; exit 1 ;; +esac + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/platform-bundle.XXXXXX")" +trap 'rm -rf "$WORKDIR"' EXIT +# Cargo looks for configuration above the physical directory it runs in. +WORKDIR="$(cd "$WORKDIR" && pwd -P)" +# A private Cargo home keeps the cached registries and the Cargo home's +# config.toml out of the bundle. Cargo also reads .cargo/config.toml from every +# parent of the directory it runs in, so the work directory must have none +# (TMPDIR under a home directory with ~/.cargo/config.toml fails here). +export CARGO_HOME="$WORKDIR/cargo-home" +dir="$WORKDIR" +while :; do + dir="$(dirname "$dir")" + for config in "$dir/.cargo/config" "$dir/.cargo/config.toml"; do + if [ -e "$config" ]; then + echo "error: $config would configure Cargo; set TMPDIR to a directory outside its tree" >&2 + exit 1 + fi + done + [ "$dir" != / ] || break +done + +# Downloads $1 to SOURCES_PATH/$2, unless a copy there passes the integrity +# test $3 (a partial or corrupt one is downloaded again). +fetch() { + local url="$1" file="$2" test="$3" + if [ -f "$SOURCES_PATH/$file" ] && $test "$SOURCES_PATH/$file" 2> /dev/null; then + return + fi + rm -f "$SOURCES_PATH/$file" + curl --location --fail --silent --show-error --retry 3 -o "$SOURCES_PATH/$file.temp" "$url" + $test "$SOURCES_PATH/$file.temp" + mv "$SOURCES_PATH/$file.temp" "$SOURCES_PATH/$file" +} + +gzip_test() { + "$GZIP_PROG" -t "$1" +} + +zip_test() { + python3 -c 'import sys, zipfile; sys.exit(zipfile.ZipFile(sys.argv[1]).testzip() is not None)' "$1" +} + +sha256() { + if command -v sha256sum >/dev/null; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# Prints "name version source" for every [[package]] of a Cargo.lock. +lock_entries() { + awk '/^\[\[package\]\]$/ { if (name) print name, version, source; name = version = source = "" } + /^name = / { name = $3 } + /^version = / { version = $3 } + /^source = / { source = $3 } + END { if (name) print name, version, source }' "$1" | sort +} + +PLATFORM_ARCHIVE="platform-$COMMIT.tar.gz" +fetch "https://github.com/dashpay/platform/archive/$COMMIT.tar.gz" "$PLATFORM_ARCHIVE" gzip_test + +SRC="$WORKDIR/src" +mkdir -p "$SRC" +"$TAR" --strip-components=1 -xzf "$SOURCES_PATH/$PLATFORM_ARCHIVE" -C "$SRC" +cd "$SRC" + +# Trim the workspace to the one crate depends builds and prune the lock to it. +cp Cargo.lock "$WORKDIR/Cargo.lock.pinned" +awk '/^members = \[/ { print "members = [\"packages/rs-sdk-cxx\"]"; skip = !/\]/; next } + skip && /^\]/ { skip = 0; next } + !skip' Cargo.toml > "$WORKDIR/Cargo.toml" +mv "$WORKDIR/Cargo.toml" Cargo.toml +"$CARGO" metadata --format-version 1 > /dev/null +if lock_entries Cargo.lock | comm -13 <(lock_entries "$WORKDIR/Cargo.lock.pinned") - | grep .; then + echo "error: trimming the workspace changed the locked packages above" >&2 + exit 1 +fi + +TENDERDASH_TAG="$(awk '/^name = "tenderdash-proto"$/ { found = 1 } + found && /^source = / { print; exit }' Cargo.lock | + sed -n 's|.*/rs-tenderdash-abci?tag=\(v[^#]*\)#.*|\1|p')" +[ -n "$TENDERDASH_TAG" ] || { echo "error: no tagged tenderdash-proto entry in Cargo.lock" >&2; exit 1; } +TENDERDASH_ARCHIVE="tenderdash-$TENDERDASH_TAG.zip" +fetch "https://github.com/dashpay/tenderdash/archive/$TENDERDASH_TAG.zip" "$TENDERDASH_ARCHIVE" zip_test +mkdir tenderdash +cp "$SOURCES_PATH/$TENDERDASH_ARCHIVE" tenderdash/ + +rm -rf .cargo +mkdir .cargo +{ + echo "# Generated by contrib/devtools/platform-bundle.sh for dashpay/platform@$COMMIT" + "$CARGO" vendor --locked --versioned-dirs vendor + echo + echo "[env]" + echo "TENDERDASH_COMMITISH = \"$TENDERDASH_TAG\"" +} > "$WORKDIR/config.toml" +grep -qx 'directory = "vendor"' "$WORKDIR/config.toml" || { echo "error: cargo vendor printed no source replacement" >&2; exit 1; } +mv "$WORKDIR/config.toml" .cargo/config.toml +# From here on every crate must come from vendor/, never from the Cargo home. +rm -rf "$CARGO_HOME" + +closure() { + "$CARGO" tree --frozen -p dash-sdk-cxx -e normal,build --target all --prefix none --format '{p}' | + awk '{ sub(/^v/, "", $2); print $1 "-" $2 }' | sort -u +} +closure > "$WORKDIR/closure" +for dir in vendor/*/; do + dir="${dir%/}" + grep -qxF "${dir#vendor/}" "$WORKDIR/closure" && continue + checksum="$(sed -En 's/.*"package":(null|"[0-9a-f]*")}$/\1/p' "$dir/.cargo-checksum.json")" + find "$dir" ! -type d ! -name '*.rs' ! -path "$dir/Cargo.toml" -delete + find "$dir" -type f -name '*.rs' -print0 | while IFS= read -r -d '' file; do : > "$file"; done + find "$dir" -type d -empty -delete + printf '{"files":{},"package":%s}' "$checksum" > "$dir/.cargo-checksum.json" +done +closure | cmp -s - "$WORKDIR/closure" || { echo "error: the build closure changed after pruning" >&2; exit 1; } + +BUNDLE="dash-sdk-cxx-crates-$COMMIT.tar.gz" +"$TAR" --create --format=gnu --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \ + --mode='u+rw,go+r-w,a+X' Cargo.toml Cargo.lock .cargo/config.toml tenderdash vendor | + "$GZIP_PROG" -9n > "$SOURCES_PATH/$BUNDLE.temp" +mv "$SOURCES_PATH/$BUNDLE.temp" "$SOURCES_PATH/$BUNDLE" + +cat < .$($(package)_file_name).hash && \ + $(build_SHA256SUM) -c .$($(package)_file_name).hash && \ + python3 -m zipfile -e $($(package)_source) . +endef + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/bin $($(package)_staging_prefix_dir)/include && \ + cp bin/protoc $($(package)_staging_prefix_dir)/bin/ && \ + chmod 0755 $($(package)_staging_prefix_dir)/bin/protoc && \ + cp -R include/google $($(package)_staging_prefix_dir)/include/ +endef diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 59232930eb38..e3299ea041ff 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -24,4 +24,7 @@ natpmp_packages=libnatpmp multiprocess_packages = libmultiprocess capnp multiprocess_native_packages = native_libmultiprocess native_capnp +platform_packages = rust_stdlib platform_cxx +platform_native_packages = native_rust native_protobuf + usdt_linux_packages=systemtap diff --git a/depends/packages/platform_cxx.mk b/depends/packages/platform_cxx.mk new file mode 100644 index 000000000000..3fe90d174a99 --- /dev/null +++ b/depends/packages/platform_cxx.mk @@ -0,0 +1,222 @@ +# 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. + +# dash-sdk-cxx, the C++ binding of the Dash Platform SDK (packages/rs-sdk-cxx +# of dashpay/platform), built from two sha256-pinned archives: the Platform +# source tarball at the pinned commit, and the crate bundle that +# contrib/devtools/platform-bundle.sh produces for that commit (trimmed +# workspace, pruned Cargo.lock, vendored crates, the Tenderdash sources +# tenderdash-proto generates its protobuf code from, Cargo configuration). +# Cargo runs with --frozen and never touches the network. +# +# Both archives must be on the depends sources mirror (FALLBACK_DOWNLOAD_PATH) +# before this is merged. The bundle has no upstream URL, and GitHub does not +# promise stable bytes for the archive endpoint the tarball comes from. +# Whoever bumps the pin runs platform-bundle.sh, which prints the lines to +# update here, and uploads both. Until then, a bundle the script wrote to +# SOURCES_PATH is used as is; its hash is checked on extraction either way. + +package=platform_cxx +$(package)_version=fb463c282ea09adf93dd5d256fc9471969f7a46f +$(package)_download_path=https://github.com/dashpay/platform/archive +$(package)_download_file=$($(package)_version).tar.gz +$(package)_file_name=platform-$($(package)_version).tar.gz +$(package)_sha256_hash=8ccb913069ce8d96a32443e98e44e85c331694727cc37c321364711ddaa04039 +$(package)_crates_file_name=dash-sdk-cxx-crates-$($(package)_version).tar.gz +$(package)_crates_sha256_hash=da47a0ca89ed023f357a70e19d097f42bd4fe76221ee7bfd1c4eca0102b5f70e +$(package)_fetched_id=$($(package)_crates_sha256_hash) +$(package)_extra_sources=$($(package)_crates_file_name) +$(package)_dependencies=native_rust rust_stdlib native_protobuf +$(package)_patches=rustc-linker.sh build-linker.sh check-native-libs.sh + +# The system libraries the archive needs, which dash_sdk_cxx.pc passes on +# to configure. Each is vetted against contrib/guix/symbol-check.py, and the +# build fails if rustc reports one (--print=native-static-libs) that is +# neither here nor in the ignored list below. +$(package)_native_libs_linux=-lpthread -ldl -lm +$(package)_native_libs_darwin=-framework Security -framework CoreFoundation +$(package)_native_libs_mingw32=-lbcrypt -ladvapi32 -lkernel32 -lntdll -luserenv -lws2_32 -ldbghelp + +# Libraries rustc reports that are deliberately not passed on: +# - the C++ runtime the cxx bridge needs, which the C++ link of dash-qt +# already has: -lstdc++, and -lc++ on macOS; +# - the toolchain's own C runtime, which every link already has: -lc, +# -lgcc_s, -lgcc_eh and -lgcc, and on mingw-w64 -lmsvcrt, -lmingwex, +# -lmingw32 and -l:libpthread.a; +# - glibc's -lutil and -lrt: symbol-check.py does not allow them, and the +# glibc standard library imports nothing from them (every symbol it uses +# from glibc 2.31 is in libc, libpthread, libdl or libm); +# - macOS's -liconv, -lSystem, -lc and -lm, which the macOS linker adds anyway. +$(package)_native_libs_ignored_linux=-lstdc++ -lc -lgcc_s -lgcc_eh -lgcc -lutil -lrt +$(package)_native_libs_ignored_darwin=-lc++ -liconv -lSystem -lc -lm +$(package)_native_libs_ignored_mingw32=-lstdc++ -lmsvcrt -lmingwex -lmingw32 -lgcc_eh -lgcc -l:libpthread.a + +define $(package)_fetch_cmds +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_download_file),$($(package)_file_name),$($(package)_sha256_hash)) && \ +( echo "$($(package)_crates_sha256_hash) $($(package)_source_dir)/$($(package)_crates_file_name)" | $(build_SHA256SUM) -c >/dev/null 2>&1 || \ + ( rm -f $($(package)_source_dir)/$($(package)_crates_file_name) && \ + $(call fetch_file_inner,$(package),$(FALLBACK_DOWNLOAD_PATH),$($(package)_crates_file_name),$($(package)_crates_file_name),$($(package)_crates_sha256_hash)) ) ) +endef + +# Only packages/ is needed from the Platform tarball; the bundle supplies the +# workspace manifest, lock and Cargo configuration. +define $(package)_extract_cmds + echo "$($(package)_sha256_hash) $($(package)_source)" > .$($(package)_file_name).hash && \ + echo "$($(package)_crates_sha256_hash) $($(package)_source_dir)/$($(package)_crates_file_name)" >> .$($(package)_file_name).hash && \ + $(build_SHA256SUM) -c .$($(package)_file_name).hash && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source) platform-$($(package)_version)/packages && \ + $(build_TAR) --no-same-owner -xf $($(package)_source_dir)/$($(package)_crates_file_name) +endef + +$(package)_rust_target=$(rust_stdlib_target) +$(package)_cc_target=$(subst -,_,$($(package)_rust_target)) +$(package)_build_linker_var:=CARGO_TARGET_$(shell echo $(native_rust_build_target) | tr a-z- A-Z_)_LINKER + +# Only the bundle's .cargo/config.toml configures Cargo: +# - Cargo reads .cargo/config.toml in the directory it runs in and in every +# parent, whatever --manifest-path says, so it runs from / and is given the +# bundle's file with --config. Configuration above the depends tree, such as +# ~/.cargo/config.toml when the tree is under a home directory, is therefore +# never read, and the build works wherever the tree is; preprocess fails if +# / itself has one. +# - The Cargo home is private and empty. +# - Environment variables that would change the build are removed: +# RUSTC_WORKSPACE_WRAPPER, RUSTC_BOOTSTRAP, CARGO_ENCODED_RUSTFLAGS, +# __CARGO_DEFAULT_LIB_METADATA, and all CARGO_BUILD_*, CARGO_PROFILE_*, +# CARGO_TARGET_* and CARGO_UNSTABLE_* ones, and the _ forms +# (such as CC_x86_64-unknown-linux-gnu) that cc-rs prefers over the +# _ ones set below; every TENDERDASH_ +# variable, among them TENDERDASH_COMMITISH, which the bundle's +# configuration sets to the tag of the Tenderdash archive it carries, and +# TENDERDASH_DIR and TENDERDASH_PROTO_OUT_DIR, which would let +# tenderdash-proto's build script take protobuf sources from outside the +# bundle. +# - The release profile is pinned to Platform's, which is Cargo's default: +# opt-level 3, no debug info, no LTO, 16 codegen units, no incremental +# compilation, and unwinding panics (the crate refuses panic=abort). Nothing +# Cargo links here is installed (build scripts, the cdylib dash-sdk also +# declares), so nothing is stripped; on macOS that would need rust-objcopy +# and an LLVM library native_rust does not stage. +$(package)_unset_env:=RUSTC_WORKSPACE_WRAPPER RUSTC_BOOTSTRAP CARGO_ENCODED_RUSTFLAGS __CARGO_DEFAULT_LIB_METADATA +$(package)_unset_env+=$(sort TENDERDASH_DIR TENDERDASH_COMMITISH TENDERDASH_PROTO_OUT_DIR $(filter TENDERDASH_%,$(.VARIABLES))) +$(package)_unset_env+=$(filter CARGO_BUILD_% CARGO_PROFILE_% CARGO_TARGET_% CARGO_UNSTABLE_%,$(.VARIABLES)) +$(package)_unset_env+=$(filter %_$(rust_stdlib_target) %_$(native_rust_build_target),$(.VARIABLES)) +$(package)_profile_env:=CARGO_PROFILE_RELEASE_OPT_LEVEL=3 CARGO_PROFILE_RELEASE_DEBUG=false +$(package)_profile_env+=CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +$(package)_profile_env+=CARGO_PROFILE_RELEASE_PANIC=unwind CARGO_PROFILE_RELEASE_INCREMENTAL=false +$(package)_profile_env+=CARGO_PROFILE_RELEASE_STRIP=false CARGO_INCREMENTAL=0 + +# RUSTFLAGS takes the place of Platform's .cargo/config.toml, which the bundle +# does not carry. Of what that file sets, --cfg tokio_unstable only enables +# tokio APIs (runtime metrics, task hooks, io-uring) that nothing in +# dash-sdk-cxx's dependency graph uses, target-feature=-crt-static only +# changes musl targets (glibc targets link the C runtime dynamically anyway), +# and target-cpu=x86-64 and -lstdc++ are the defaults or only matter for +# executables; none of them is set here. +# +# For the host, the depends compiler links (through rustc-linker.sh), the C and +# C++ sources in the crate closure (ring, blst, blake3, secp256k1, the cxx +# bridge) are compiled with the depends compiler and flags, and the build +# directory is remapped out of both the Rust and the C objects. Build scripts +# and proc macros run on the build machine; build-linker.sh links them with the +# depends build compiler, which also compiles any C they need (CC_ and HOST_CC; when the build and host triples are the same, the host's +# CC_ wins and the host and build compilers are the same machine's). +# +# For windows-gnu, rustc creates the import libraries of raw-dylib imports +# (windows-link, which the Rust standard library and windows-sys use) with +# the mingw-w64 dlltool. +define $(package)_set_vars +$(package)_cargo_env = CARGO_HOME=$$($(package)_build_dir)/.cargo-home +$(package)_cargo_env += CARGO_TARGET_DIR=$$($(package)_build_dir)/target +$(package)_cargo_env += $($(package)_profile_env) +$(package)_cargo_env += $($(package)_build_linker_var)=$$($(package)_build_dir)/build-linker.sh +$(package)_cargo = cd / && env $$(addprefix -u ,$$($(package)_unset_env)) $$($(package)_cargo_env) \ + cargo --config $$($(package)_build_dir)/.cargo/config.toml +$(package)_rustflags = -C linker=$$($(package)_build_dir)/rustc-linker.sh --remap-path-prefix=$(BASEDIR)=/build +$(package)_rustflags_mingw32 = -C dlltool=$(host_toolchain)dlltool +$(package)_build_env += RUSTC="$(build_prefix)/bin/rustc" RUSTC_WRAPPER= +$(package)_build_env += RUSTFLAGS="$$($(package)_rustflags) $$($(package)_rustflags_$(host_os))" +$(package)_build_env += DEPENDS_CC="$$($(package)_cc)" DEPENDS_LDFLAGS="$$($(package)_ldflags)" +$(package)_build_env += DEPENDS_BUILD_CC="$(build_CC)" +$(package)_build_env += CC_$($(package)_cc_target)="$$($(package)_cc)" CXX_$($(package)_cc_target)="$$($(package)_cxx)" +$(package)_build_env += AR_$($(package)_cc_target)="$$($(package)_ar)" +$(package)_build_env += CFLAGS_$($(package)_cc_target)="$$($(package)_cppflags) $$($(package)_cflags) -ffile-prefix-map=$(BASEDIR)=/build" +$(package)_build_env += CXXFLAGS_$($(package)_cc_target)="$$($(package)_cppflags) $$($(package)_cxxflags) -ffile-prefix-map=$(BASEDIR)=/build" +$(package)_build_env += HOST_CC="$(build_CC)" HOST_CXX="$(build_CXX)" +ifneq ($(native_rust_build_target),$($(package)_rust_target)) +$(package)_build_env += CC_$(subst -,_,$(native_rust_build_target))="$(build_CC)" CXX_$(subst -,_,$(native_rust_build_target))="$(build_CXX)" +endif +$(package)_build_env += PROTOC="$(build_prefix)/bin/protoc" PROTOC_INCLUDE="$(build_prefix)/include" +ifeq ($(host_os),darwin) +$(package)_build_env += MACOSX_DEPLOYMENT_TARGET=$(OSX_MIN_VERSION) +ifneq ($(build_os),darwin) +$(package)_build_env += SDKROOT="$(OSX_SDK)" +endif +endif +endef + +# tenderdash-proto's build script extracts its sources from +# tenderdash-$$TENDERDASH_COMMITISH.zip in its cache directory +# (CARGO_TARGET_DIR) and would download the archive if it were missing; the +# bundle's configuration sets the tag of the archive the bundle carries. +define $(package)_preprocess_cmds + for config in /.cargo/config /.cargo/config.toml; do \ + if test -e $$$$config; then echo "$$$$config would configure Cargo; remove it" >&2; exit 1; fi; \ + done && \ + cp $($(package)_patch_dir)/rustc-linker.sh $($(package)_patch_dir)/build-linker.sh . && \ + chmod +x rustc-linker.sh build-linker.sh && \ + tag=$$$$(sed -n 's/^TENDERDASH_COMMITISH = "\(.*\)"$$$$/\1/p' .cargo/config.toml) && \ + if ! test -f "tenderdash/tenderdash-$$$$tag.zip"; then \ + echo "the crate bundle has no Tenderdash archive for TENDERDASH_COMMITISH \"$$$$tag\"" >&2; exit 1; \ + fi && \ + mkdir -p target && \ + cp "tenderdash/tenderdash-$$$$tag.zip" target/ +endef + +# The build fails if the dependency graph (normal and build dependencies, both +# of which Cargo compiles) reaches a trusted third-party context provider, an +# HTTP client or OpenSSL: every trust input comes from Core. The graph goes to +# a file first, so that a failing cargo tree fails the build. +# +# The crate is built as a static library only, with rustc printing the system +# libraries the archive needs; check-native-libs.sh compares them with the +# vetted and ignored lists above. The build log goes to a file as well, and +# the exit status to another, since a pipeline has the status of tee. +define $(package)_build_cmds + ( $($(package)_cargo) tree --frozen --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-sdk-cxx -e normal,build --target $($(package)_rust_target) ) > cargo-tree.txt && \ + if grep -E 'rs-sdk-trusted-context-provider|reqwest|openssl-sys' cargo-tree.txt; then \ + echo "dash-sdk-cxx depends on a forbidden crate" >&2; exit 1; \ + fi && \ + ( ( $($(package)_cargo) rustc --frozen --offline --release --color never --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-sdk-cxx --lib --crate-type staticlib --target $($(package)_rust_target) \ + -- --print=native-static-libs ); echo $$$$? > cargo-rustc.status ) 2>&1 | tee cargo-rustc.log && \ + test "$$$$(cat cargo-rustc.status)" = 0 && \ + sh $($(package)_patch_dir)/check-native-libs.sh cargo-rustc.log \ + "$($(package)_native_libs_$(host_os))" "$($(package)_native_libs_ignored_$(host_os))" +endef + +# The crate's build script stages the generated bridge header dash/sdk/ffi.h, +# dash/sdk/callbacks.h and the cxx runtime header rust/cxx.h under +# target//release/include; that tree, the static archive and +# dash_sdk_cxx.pc are the installed interface. The +# .pc file locates the prefix from its own directory. +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/include $($(package)_staging_prefix_dir)/lib/pkgconfig && \ + cp -R target/$($(package)_rust_target)/release/include/. $($(package)_staging_prefix_dir)/include/ && \ + cp target/$($(package)_rust_target)/release/libdash_sdk_cxx.a $($(package)_staging_prefix_dir)/lib/ && \ + printf '%s\n' \ + 'prefix=$$$${pcfiledir}/../..' \ + 'exec_prefix=$$$${prefix}' \ + 'libdir=$$$${prefix}/lib' \ + 'includedir=$$$${prefix}/include' \ + '' \ + 'Name: dash_sdk_cxx' \ + 'Description: C++ binding of the Dash Platform SDK' \ + 'Version: $($(package)_version)' \ + 'Cflags: -I$$$${includedir}' \ + 'Libs: -L$$$${libdir} -ldash_sdk_cxx $($(package)_native_libs_$(host_os))' \ + > $($(package)_staging_prefix_dir)/lib/pkgconfig/dash_sdk_cxx.pc +endef diff --git a/depends/patches/platform_cxx/build-linker.sh b/depends/patches/platform_cxx/build-linker.sh new file mode 100755 index 000000000000..da7d04b6a9cc --- /dev/null +++ b/depends/patches/platform_cxx/build-linker.sh @@ -0,0 +1,8 @@ +#!/bin/sh +export LC_ALL=C +set -f +# Links build scripts and proc macros, which run on the build machine, with the +# depends build compiler. rustc's `-C linker=` takes a single executable, but +# DEPENDS_BUILD_CC is a command line (on macOS with an -isysroot flag). +# shellcheck disable=SC2086 +exec $DEPENDS_BUILD_CC "$@" diff --git a/depends/patches/platform_cxx/check-native-libs.sh b/depends/patches/platform_cxx/check-native-libs.sh new file mode 100755 index 000000000000..51d1bad850f3 --- /dev/null +++ b/depends/patches/platform_cxx/check-native-libs.sh @@ -0,0 +1,42 @@ +#!/bin/sh +export LC_ALL=C +set -f +# Usage: check-native-libs.sh +# +# Fails unless every system library rustc reports for the static archive (the +# "native-static-libs" note in the build log) is vetted or ignored, so that +# whoever bumps the Platform pin or the Rust toolchain notices a new one. +log=$1 +vetted=$2 +ignored=$3 + +libs=$(sed -n 's/^note: native-static-libs: //p' "$log") +if test -z "$libs"; then + echo "rustc reported no native-static-libs in $log" >&2 + exit 1 +fi +echo "rustc native-static-libs: $libs" + +unvetted= +framework= +for word in $libs; do + if test "$word" = -framework; then + framework=yes + continue + fi + lib=$word + if test -n "$framework"; then + lib="-framework $word" + framework= + fi + case " $vetted $ignored " in + *" $lib "*) ;; + *) unvetted="$unvetted $lib" ;; + esac +done + +if test -n "$unvetted"; then + echo "rustc reports system libraries platform_cxx.mk has not vetted:$unvetted" >&2 + echo "Check each against contrib/guix/symbol-check.py, then add it to platform_cxx_native_libs_, or with the reason to platform_cxx_native_libs_ignored_." >&2 + exit 1 +fi diff --git a/depends/patches/platform_cxx/rustc-linker.sh b/depends/patches/platform_cxx/rustc-linker.sh new file mode 100755 index 000000000000..db2063792520 --- /dev/null +++ b/depends/patches/platform_cxx/rustc-linker.sh @@ -0,0 +1,10 @@ +#!/bin/sh +export LC_ALL=C +set -f +# rustc's `-C linker=` takes a single executable, but the depends compiler is +# a command line (target and sysroot flags, under Guix an `env -u ...` prefix). +# platform_cxx.mk passes that command in DEPENDS_CC and the link flags in +# DEPENDS_LDFLAGS; word splitting them here keeps every part, and set -f keeps +# a flag with a glob character from being expanded. +# shellcheck disable=SC2086 +exec $DEPENDS_CC $DEPENDS_LDFLAGS "$@" diff --git a/doc/dependencies.md b/doc/dependencies.md index eaf67114fd32..1cc3a64b5ed2 100644 --- a/doc/dependencies.md +++ b/doc/dependencies.md @@ -36,6 +36,13 @@ You can find installation instructions in the `build-*.md` file for your platfor | [qrencode](../depends/packages/qrencode.mk) | [link](https://fukuchi.org/works/qrencode/) | [4.1.1](https://github.com/bitcoin/bitcoin/pull/27312) | | No | | [Qt](../depends/packages/qt.mk) | [link](https://download.qt.io/official_releases/qt/) | [5.15.18](https://github.com/dashpay/dash/pull/6949) | [5.11.3](https://github.com/bitcoin/bitcoin/pull/24132) | No | +### Dash Platform GUI (`--enable-platform-gui`, depends `PLATFORM_GUI=1` only) +| Dependency | Releases | Version used | Minimum required | Runtime | +| --- | --- | --- | --- | --- | +| [Rust](../depends/packages/native_rust.mk) (compiler and standard library) | [link](https://forge.rust-lang.org/infra/other-installation-methods.html#standalone-installers) | 1.98.1 | 1.98.1 | No | +| [protoc](../depends/packages/native_protobuf.mk) (build tool) | [link](https://github.com/protocolbuffers/protobuf/releases) | 32.0 | 25.0 | No | +| [dash-sdk-cxx](../depends/packages/platform_cxx.mk) (Dash Platform SDK C++ binding) | [link](https://github.com/dashpay/platform) | commit [fb463c2...](https://github.com/dashpay/platform/tree/fb463c282ea09adf93dd5d256fc9471969f7a46f) | | No | + ### Networking | Dependency | Releases | Version used | Minimum required | Runtime | | --- | --- | --- | --- | --- | From 707216ed06966429559cd1499fe7bf2a9575289e Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 10:39:48 -0500 Subject: [PATCH 3/5] build: add --enable-platform-gui The option (default no) requires the GUI and the wallet, and checks that a program using dash-sdk-cxx, the C++ binding of the Dash Platform SDK, links: it includes dash/sdk/ffi.h and creates and shuts down a dash_sdk::PlatformClient. PLATFORM_CXX_CFLAGS and PLATFORM_CXX_LIBS come from dash_sdk_cxx.pc through PKG_CHECK_MODULES, and can be overridden as usual: the include directory, the library, and the vetted system libraries from the depends .pc. depends checks those libraries against rustc's --print=native-static-libs. The option defines ENABLE_PLATFORM_GUI and the automake conditional of the same name, under which PLATFORM_CXX_LIBS is added to the link of dash-qt, test_dash and test_dash-qt only; dashd and the other binaries never link it, and nothing references the binding yet. Co-Authored-By: Claude Opus 5.5 --- configure.ac | 44 +++++++++++++++++++++++++++++++++++++ src/Makefile.qt.include | 3 +++ src/Makefile.qttest.include | 3 +++ src/Makefile.test.include | 3 +++ 4 files changed, 53 insertions(+) diff --git a/configure.ac b/configure.ac index a9e35495bca7..d7b80b369d35 100644 --- a/configure.ac +++ b/configure.ac @@ -307,6 +307,14 @@ if test "$enable_miner" = "yes"; then AC_DEFINE(ENABLE_MINER, 1, [Define this symbol if in-wallet miner should be enabled]) fi +AC_ARG_ENABLE([platform-gui], + [AS_HELP_STRING([--enable-platform-gui], + [enable Dash Platform (usernames, DashPay contacts) in the GUI; requires the GUI, the wallet and the Dash Platform SDK's C++ binding (dash-sdk-cxx) built by depends with PLATFORM_GUI=1 (default is no)])], + [enable_platform_gui=$enableval], + [enable_platform_gui=no]) +AC_ARG_VAR([PLATFORM_CXX_CFLAGS], [C++ compiler flags for dash-sdk-cxx, overriding pkg-config]) +AC_ARG_VAR([PLATFORM_CXX_LIBS], [Linker flags for dash-sdk-cxx, overriding pkg-config (default: the library and the vetted system libraries from the depends dash_sdk_cxx.pc)]) + dnl Enable different -fsanitize options AC_ARG_WITH([sanitizers], [AS_HELP_STRING([--with-sanitizers], @@ -1927,6 +1935,41 @@ if test "$build_bitcoin_wallet$build_bitcoin_cli$build_bitcoin_tx$build_bitcoin_ AC_MSG_ERROR([No targets! Please specify at least one of: --with-utils --with-libs --with-daemon --with-gui --enable-fuzz(-binary) --enable-bench or --enable-tests]) fi +dnl dash-sdk-cxx, the C++ binding of the Dash Platform SDK, is a Rust static +dnl library linked into dash-qt (and its tests) only. depends installs +dnl dash_sdk_cxx.pc, whose Libs are the library and the vetted system libraries +dnl it needs; depends checks those against rustc's --print=native-static-libs. +if test "$enable_platform_gui" = "yes"; then + if test "$bitcoin_enable_qt" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires the GUI]) + fi + if test "$enable_wallet" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires the wallet]) + fi + PKG_CHECK_MODULES([PLATFORM_CXX], [dash_sdk_cxx], [], + [AC_MSG_ERROR([--enable-platform-gui requires dash-sdk-cxx (build depends with PLATFORM_GUI=1)])]) + TEMP_CPPFLAGS="$CPPFLAGS" + TEMP_LIBS="$LIBS" + CPPFLAGS="$CPPFLAGS $PLATFORM_CXX_CFLAGS" + LIBS="$PLATFORM_CXX_LIBS $LIBS" + AC_LANG_PUSH([C++]) + AC_MSG_CHECKING([whether a program using dash-sdk-cxx links]) + AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + ]], [[ + rust::Box client{dash_sdk::new_platform_client(dash_sdk::Config{}, nullptr)}; + client->shutdown(); + ]])], + [AC_MSG_RESULT([yes])], + [AC_MSG_RESULT([no]) + AC_MSG_ERROR([a program using dash-sdk-cxx does not link; see config.log])]) + AC_LANG_POP([C++]) + CPPFLAGS="$TEMP_CPPFLAGS" + LIBS="$TEMP_LIBS" + AC_DEFINE([ENABLE_PLATFORM_GUI], [1], [Define this symbol to enable Dash Platform support in the GUI]) +fi +AM_CONDITIONAL([ENABLE_PLATFORM_GUI], [test "$enable_platform_gui" = "yes"]) + AM_CONDITIONAL([TARGET_DARWIN], [test "$TARGET_OS" = "darwin"]) AM_CONDITIONAL([BUILD_DARWIN], [test "$BUILD_OS" = "darwin"]) AM_CONDITIONAL([TARGET_LINUX], [test "$TARGET_OS" = "linux"]) @@ -2119,6 +2162,7 @@ echo " debug enabled = $enable_debug" echo " stacktraces = $enable_stacktraces" echo " crash hooks = $enable_crashhooks" echo " miner enabled = $enable_miner" +echo " platform gui = $enable_platform_gui" echo " werror = $enable_werror" echo echo " target os = $host_os" diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 9dc7d11ba75f..58a43732676f 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -487,6 +487,9 @@ endif if ENABLE_ZMQ bitcoin_qt_ldadd += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(PLATFORM_CXX_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 4690c07a5ea0..f7d1615836d6 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -87,6 +87,9 @@ endif if ENABLE_ZMQ qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_LDADD += $(PLATFORM_CXX_LIBS) +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) \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index a145cbdec341..8908d43cc923 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -282,6 +282,9 @@ if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) endif +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(PLATFORM_CXX_LIBS) +endif test_test_dash_LDADD += $(LIBBITCOIN_NODE) $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(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) From b4a4436ba734080b21375fbbc7b81e62d27813dc Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 10:39:48 -0500 Subject: [PATCH 4/5] ci: build linux64_sqlite against depends with PLATFORM_GUI=1 A new linux64_platform_gui depends target builds depends with PLATFORM_GUI=1, and linux64_sqlite builds against it instead of the linux64 depends (as linux64_tsan builds against linux64_multiprocess's), so dash-qt and the unit tests of that job are built with --enable-platform-gui (enabled through config.site) without adding a separate build and test job. contrib/devtools/check-no-rust.py then fails the linux64_sqlite build if dashd, dash-cli, dash-tx, dash-util, dash-wallet, bench_dash or the fuzz binary contain cxx bridge, Rust runtime or Rust standard library symbols, or no symbols at all: dash-sdk-cxx is for dash-qt and its tests only. The source cache also gets the x86_64 Linux sources of the new depends job: the Rust toolchain, protoc, standard library and Platform archives. Its runner may be ARM, and the toolchain and protoc archives are chosen by the build machine, so download-one is given BUILD and HOST directly; download-linux would not pass BUILD to its sub-make. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 25 +++++--- .github/workflows/cache-depends-sources.yml | 7 +++ ci/dash/build_src.sh | 4 ++ ci/dash/matrix.sh | 2 + ci/test/00_setup_env_native_platform_gui.sh | 12 ++++ ci/test/00_setup_env_native_sqlite.sh | 1 + contrib/devtools/README.md | 8 +++ contrib/devtools/check-no-rust.py | 64 +++++++++++++++++++++ 8 files changed, 116 insertions(+), 7 deletions(-) create mode 100755 ci/test/00_setup_env_native_platform_gui.sh create mode 100755 contrib/devtools/check-no-rust.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e20ae2e22b6..fd7727c15464 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -235,8 +235,7 @@ jobs: if: | vars.SKIP_LINUX64 == '' || vars.SKIP_LINUX64_ASAN == '' || - vars.SKIP_LINUX64_FUZZ == '' || - vars.SKIP_LINUX64_SQLITE == '' + vars.SKIP_LINUX64_FUZZ == '' with: build-target: linux64 container-path: ${{ needs.container.outputs.path }} @@ -272,6 +271,18 @@ jobs: runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} runs-on-check: ${{ needs.check-skip.outputs['runner-check-skip'] }} + depends-linux64_platform_gui: + name: x86_64-pc-linux-gnu_platform_gui + uses: ./.github/workflows/build-depends.yml + needs: [check-skip, container, cache-sources] + if: ${{ vars.SKIP_LINUX64_SQLITE == '' }} + with: + build-target: linux64_platform_gui + container-path: ${{ needs.container.outputs.path }} + base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + runs-on-check: ${{ needs.check-skip.outputs['runner-check-skip'] }} + depends-mac: name: x86_64-apple-darwin uses: ./.github/workflows/build-depends.yml @@ -405,15 +416,15 @@ jobs: src-linux64_sqlite: name: linux64_sqlite-build uses: ./.github/workflows/build-src.yml - needs: [check-skip, container, depends-linux64] + needs: [check-skip, container, depends-linux64_platform_gui] if: ${{ vars.SKIP_LINUX64_SQLITE == '' }} with: build-target: linux64_sqlite container-path: ${{ needs.container.outputs.path }} - depends-key: ${{ needs.depends-linux64.outputs.key }} - depends-host: ${{ needs.depends-linux64.outputs.host }} - depends-dep-opts: ${{ needs.depends-linux64.outputs.dep-opts }} - depends-artifact: ${{ needs.depends-linux64.outputs.built-artifact }} + depends-key: ${{ needs.depends-linux64_platform_gui.outputs.key }} + depends-host: ${{ needs.depends-linux64_platform_gui.outputs.host }} + depends-dep-opts: ${{ needs.depends-linux64_platform_gui.outputs.dep-opts }} + depends-artifact: ${{ needs.depends-linux64_platform_gui.outputs.built-artifact }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} secrets: *remote-ccache-auth diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index d8fb54b67ee9..f4ba76f2cda8 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -43,3 +43,10 @@ jobs: - name: Download sources if: steps.cache-check.outputs.cache-hit != 'true' run: make -C depends download + + # The linux64_platform_gui depends job runs on x86_64, but this runner + # may not: name the build machine, since the Rust toolchain and protoc + # archives are chosen by it. download-linux would not pass BUILD on. + - name: Download Platform GUI sources for x86_64 Linux + if: steps.cache-check.outputs.cache-hit != 'true' + run: make -C depends download-one PLATFORM_GUI=1 BUILD=x86_64-pc-linux-gnu HOST=x86_64-unknown-linux-gnu diff --git a/ci/dash/build_src.sh b/ci/dash/build_src.sh index 2184966c28eb..6934b71399e8 100755 --- a/ci/dash/build_src.sh +++ b/ci/dash/build_src.sh @@ -54,6 +54,10 @@ if [ "${RUN_STDLIB_PATH_CHECK}" = "true" ]; then make -C src --jobs=1 check-stdlib-paths fi +if [ "${RUN_CHECK_NO_RUST}" = "true" ]; then + "${BASE_ROOT_DIR}/contrib/devtools/check-no-rust.py" src/dashd src/dash-cli src/dash-tx src/dash-util src/dash-wallet src/bench/bench_dash src/test/fuzz/fuzz +fi + if [ -n "$USE_VALGRIND" ]; then echo "valgrind in USE!" "${BASE_ROOT_DIR}/ci/test/wrap-valgrind.sh" diff --git a/ci/dash/matrix.sh b/ci/dash/matrix.sh index 8949ae17fcaa..727b6b7fe48a 100755 --- a/ci/dash/matrix.sh +++ b/ci/dash/matrix.sh @@ -30,6 +30,8 @@ elif [ "$BUILD_TARGET" = "linux64_multiprocess" ]; then source ./ci/test/00_setup_env_native_multiprocess.sh elif [ "$BUILD_TARGET" = "linux64_nowallet" ]; then source ./ci/test/00_setup_env_native_nowallet_libbitcoinkernel.sh +elif [ "$BUILD_TARGET" = "linux64_platform_gui" ]; then + source ./ci/test/00_setup_env_native_platform_gui.sh elif [ "$BUILD_TARGET" = "linux64_sqlite" ]; then source ./ci/test/00_setup_env_native_sqlite.sh elif [ "$BUILD_TARGET" = "linux64_tsan" ]; then diff --git a/ci/test/00_setup_env_native_platform_gui.sh b/ci/test/00_setup_env_native_platform_gui.sh new file mode 100755 index 000000000000..6fc4c8cd2d5a --- /dev/null +++ b/ci/test/00_setup_env_native_platform_gui.sh @@ -0,0 +1,12 @@ +#!/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. + +export LC_ALL=C.UTF-8 + +# Depends with dash-sdk-cxx (PLATFORM_GUI=1); linux64_sqlite builds against it. +export CONTAINER_NAME=ci_native_platform_gui +export HOST=x86_64-pc-linux-gnu +export DEP_OPTS="PLATFORM_GUI=1" diff --git a/ci/test/00_setup_env_native_sqlite.sh b/ci/test/00_setup_env_native_sqlite.sh index 8af6d3fd9ae9..32f42e300a7e 100755 --- a/ci/test/00_setup_env_native_sqlite.sh +++ b/ci/test/00_setup_env_native_sqlite.sh @@ -11,3 +11,4 @@ export PACKAGES="python3-zmq qtbase5-dev qttools5-dev-tools libdbus-1-dev libhar export DEP_OPTS="NO_BDB=1 NO_UPNP=1 DEBUG=1" export GOAL="install" export BITCOIN_CONFIG="--enable-zmq --enable-reduce-exports --with-sqlite --without-bdb CC=gcc-11 CXX=g++-11" +export RUN_CHECK_NO_RUST="true" diff --git a/contrib/devtools/README.md b/contrib/devtools/README.md index bc9fc492d819..c4fe81daa323 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -229,3 +229,11 @@ bundle hash on any machine. The Platform tarball and the bundle are then uploaded to the depends sources mirror. contrib/devtools/platform-bundle.sh + +check-no-rust.py +================ + +Fails if any of the given executables contain Rust code. Run by the +`linux64_sqlite` CI job, which builds against depends with `PLATFORM_GUI=1`, +on `dashd`, the command-line tools, `bench_dash` and the fuzz binary, which +must never link `dash-sdk-cxx`. diff --git a/contrib/devtools/check-no-rust.py b/contrib/devtools/check-no-rust.py new file mode 100755 index 000000000000..610fa45e5e79 --- /dev/null +++ b/contrib/devtools/check-no-rust.py @@ -0,0 +1,64 @@ +#!/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. +''' +Check that executables do not link Rust code. + +dash-sdk-cxx, the Dash Platform SDK binding (--enable-platform-gui), is for +dash-qt and its tests only; dashd, the command-line tools, bench_dash and +the fuzz binary must not contain any of it. +A binary fails if its symbol table has cxx bridge, Rust runtime or Rust +standard library symbols, or has no symbols at all (a stripped binary cannot +be checked). + +Example usage: + + contrib/devtools/check-no-rust.py src/dashd src/dash-cli src/dash-tx src/dash-util src/dash-wallet src/bench/bench_dash src/test/fuzz/fuzz +''' +import re +import subprocess +import sys + +from utils import determine_wellknown_cmd + +# cxx bridge symbols carry "cxxbridge1$". Rust code is either legacy-mangled +# under a crate namespace (rust, std, core, alloc) or v0-mangled ("_R" +# followed by a path tag and a crate root "Cs_"), which C and +# C++ symbols never match. The allocator shim, the panic handler and the +# unwinding personality are plain C names every Rust program links. +RUST_SYMBOL = re.compile( + r'cxxbridge1\$' + r'|_ZN(4rust|3std|4core|5alloc)[0-9]' + r'|\b_?_R[A-Za-z0-9_]*?Cs[A-Za-z0-9]*_[0-9]' + r'|\b_?(__rust_alloc|rust_begin_unwind|rust_eh_personality)' +) + + +def rust_symbols(nm, path): + result = subprocess.run(nm + [path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) + if result.returncode != 0: + sys.exit(f'{path}: {result.stderr.strip()}') + lines = result.stdout.splitlines() + if not lines: + sys.exit(f'{path}: no symbols, so it cannot be checked (stripped?)') + return [line for line in lines if RUST_SYMBOL.search(line)] + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + nm = determine_wellknown_cmd('NM', 'nm') + failed = False + for path in sys.argv[1:]: + found = rust_symbols(nm, path) + if found: + failed = True + print(f'{path}: links Rust code, for example:') + for line in found[:5]: + print(f' {line}') + sys.exit(1 if failed else 0) + + +if __name__ == '__main__': + main() From cf94bb8ab66dec3f21b0b0c024ccedb3134ce44b Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 02:29:04 -0500 Subject: [PATCH 5/5] feat: Dash Platform client library over dash-sdk-cxx src/platform is the Qt-free client dash-qt's DashPay layers will drive. It is built as libdash_platform.a only under --enable-platform-gui; default builds, dashd and the tools are unchanged. PlatformClient is asynchronous over the blocking dash-sdk-cxx binding: identity by id and by key hash, name resolution, the names of an identity within a page bound, pending name contests and contact requests, plus identity registration, top-up, DPNS name registration and contact requests. Each call runs on one of four worker threads, and its callback runs through an executor the consumer supplies, never on a worker. A finished call waits in a client-owned outbox; the closure posted to the executor owns nothing. So a call's callback and unlock scope (dash-qt's WalletModel::UnlockContext, which relocks through a QObject) are destroyed on the executor or in shutdown(), never on a worker. A write releases its unlock just before its callback, so a write the callback starts unlocks afresh. shutdown() drops queued calls, cancels and waits for the ones in flight, joins the workers and destroys every undelivered call; no callback runs afterwards. Writes sign through the wallet given with them: identity key id k is IdentityAuthKey{identity_index, k}, with the index a parameter, and the asset lock signs with RegistrationFundingKey{identity_index} or the top-up's TopupFundingKey. The contact-request callbacks give the ECDH secret of our ENCRYPTION key and the compact xpub of our DIP-15 receiving chain, imported as it is published. Proofs are verified against quorum keys from interfaces::LLMQ::getPlatformQuorums, matched on both the Platform LLMQ type and the hash, with no fallback. Network and LLMQ type come from Params(). Private keys never leave the wallet. types.h and marshal mirror the binding's structs in Core types, so only src/platform includes ; it and test_dash are compiled with $(PLATFORM_CXX_CFLAGS). FakePlatformClient in src/test/util is a scripted, equally asynchronous client for the layers above. Tests: the threading and shutdown behaviour through the real client with no endpoints, a signer whose call is held in cs_wallet included; the signer's compact signatures for the published identity keys 0-3, a second identity index and both funding keys, and the ECDH secret, against values computed independently in Python; the published xpub's direction; the quorum match on type and hash; and an unusable proxy giving no client. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.am | 21 +- src/Makefile.qt.include | 3 + src/Makefile.qttest.include | 3 + src/Makefile.test.include | 8 + src/Makefile.test_util.include | 6 + src/platform/client.cpp | 328 +++++++++++++++++++++++++ src/platform/client.h | 128 ++++++++++ src/platform/marshal.cpp | 240 ++++++++++++++++++ src/platform/marshal.h | 38 +++ src/platform/signer.cpp | 93 +++++++ src/platform/signer.h | 46 ++++ src/platform/types.h | 173 +++++++++++++ src/test/platform_client_tests.cpp | 378 +++++++++++++++++++++++++++++ src/test/util/platform_client.cpp | 110 +++++++++ src/test/util/platform_client.h | 79 ++++++ test/util/data/non-backported.txt | 4 + 16 files changed, 1657 insertions(+), 1 deletion(-) create mode 100644 src/platform/client.cpp create mode 100644 src/platform/client.h create mode 100644 src/platform/marshal.cpp create mode 100644 src/platform/marshal.h create mode 100644 src/platform/signer.cpp create mode 100644 src/platform/signer.h create mode 100644 src/platform/types.h create mode 100644 src/test/platform_client_tests.cpp create mode 100644 src/test/util/platform_client.cpp create mode 100644 src/test/util/platform_client.h diff --git a/src/Makefile.am b/src/Makefile.am index 0724e19a22da..226fb65e5fc2 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -70,6 +70,9 @@ LIBSECP256K1=secp256k1/libsecp256k1.la if ENABLE_ZMQ LIBBITCOIN_ZMQ=libbitcoin_zmq.a endif +if ENABLE_PLATFORM_GUI +LIBDASH_PLATFORM=libdash_platform.a +endif if BUILD_BITCOIN_LIBS LIBBITCOINCONSENSUS=libdashconsensus.la endif @@ -135,7 +138,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBDASH_PLATFORM) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -729,6 +733,21 @@ libbitcoin_zmq_a_SOURCES = \ endif # +# platform: the Dash Platform client of dash-qt and its tests # +if ENABLE_PLATFORM_GUI +libdash_platform_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(PLATFORM_CXX_CFLAGS) +libdash_platform_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +libdash_platform_a_SOURCES = \ + platform/client.cpp \ + platform/client.h \ + platform/marshal.cpp \ + platform/marshal.h \ + platform/signer.cpp \ + platform/signer.h \ + platform/types.h +endif +# + # wallet # libbitcoin_wallet_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(BDB_CPPFLAGS) $(SQLITE_CFLAGS) libbitcoin_wallet_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 58a43732676f..7958168dd851 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -481,6 +481,9 @@ if TARGET_WINDOWS bitcoin_qt_sources += $(BITCOIN_QT_RC) endif bitcoin_qt_ldadd = qt/libbitcoinqt.a $(LIBBITCOIN_NODE) +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(LIBDASH_PLATFORM) +endif if ENABLE_WALLET bitcoin_qt_ldadd += $(LIBBITCOIN_UTIL) $(LIBBITCOIN_WALLET) endif diff --git a/src/Makefile.qttest.include b/src/Makefile.qttest.include index f7d1615836d6..a3d0bd2e040b 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -81,6 +81,9 @@ 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) +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_LDADD += $(LIBDASH_PLATFORM) +endif if ENABLE_WALLET qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_UTIL) $(LIBBITCOIN_WALLET) endif diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 8908d43cc923..43c7ce94aa3f 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -229,6 +229,10 @@ BITCOIN_TESTS =\ test/versionbits_tests.cpp \ test/xoroshiro128plusplus_tests.cpp +if ENABLE_PLATFORM_GUI +BITCOIN_TESTS += test/platform_client_tests.cpp +endif + if ENABLE_WALLET BITCOIN_TESTS += \ wallet/test/bip39_tests.cpp \ @@ -278,6 +282,10 @@ endif # ENABLE_WALLET test_test_dash_SOURCES = $(BITCOIN_TEST_SUITE) $(BITCOIN_TESTS) $(JSON_TEST_FILES) $(RAW_TEST_FILES) test_test_dash_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(TESTDEFS) $(BOOST_CPPFLAGS) $(EVENT_CFLAGS) test_test_dash_LDADD = $(LIBTEST_UTIL) +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(LIBDASH_PLATFORM) +test_test_dash_CPPFLAGS += $(PLATFORM_CXX_CFLAGS) +endif if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) diff --git a/src/Makefile.test_util.include b/src/Makefile.test_util.include index 91a9ca2788dc..d61eff6b0df2 100644 --- a/src/Makefile.test_util.include +++ b/src/Makefile.test_util.include @@ -31,6 +31,9 @@ TEST_UTIL_H = \ if ENABLE_WALLET TEST_UTIL_H += wallet/test/util.h endif # ENABLE_WALLET +if ENABLE_PLATFORM_GUI +TEST_UTIL_H += test/util/platform_client.h +endif libtest_util_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) libtest_util_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) @@ -54,5 +57,8 @@ libtest_util_a_SOURCES = \ if ENABLE_WALLET libtest_util_a_SOURCES += wallet/test/util.cpp endif # ENABLE_WALLET +if ENABLE_PLATFORM_GUI +libtest_util_a_SOURCES += test/util/platform_client.cpp +endif libtest_util_a_SOURCES += $(TEST_UTIL_H) diff --git a/src/platform/client.cpp b/src/platform/client.cpp new file mode 100644 index 000000000000..1e090edd9345 --- /dev/null +++ b/src/platform/client.cpp @@ -0,0 +1,328 @@ +// 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 +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace platform { + +namespace { + +//! Enough for a write in flight not to hold up the reads a wallet load makes. +constexpr int WORKER_THREADS{4}; + +//! A call. Run() makes the blocking SDK call on a worker; Deliver() releases +//! the unlock scope and runs the callback on the executor. A task is +//! destroyed on the executor or by shutdown(), never on a worker. +class Task +{ +public: + virtual ~Task() = default; + virtual void Run() = 0; + virtual void Deliver() = 0; +}; + +template +class Call final : public Task +{ +public: + Call(std::function()> call, PlatformClient::Callback cb, std::unique_ptr unlock) : + m_call(std::move(call)), + m_cb(std::move(cb)), + m_unlock(std::move(unlock)) + { + } + + void Run() override + { + try { + m_result = m_call(); + } catch (const std::exception& e) { + // A rust::Str or rust::String refused a string that is not UTF-8. + m_result.status.message = e.what(); + } + } + + void Deliver() override + { + // Relock first, so that a write the callback starts unlocks afresh. + m_unlock.reset(); + m_cb(std::move(m_result)); + } + +private: + std::function()> m_call; + PlatformClient::Callback m_cb; + std::unique_ptr m_unlock; + Result m_result; +}; + +class TaskQueue +{ +public: + //! A task pushed after Close() is destroyed here. + void Push(std::unique_ptr task) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + { + LOCK(m_mutex); + if (m_closed) return; + m_tasks.push_back(std::move(task)); + } + m_cv.notify_one(); + } + + //! The next task, waiting for one if wait is set; nullptr once closed. + std::unique_ptr Pop(bool wait) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + WAIT_LOCK(m_mutex, lock); + if (wait) m_cv.wait(lock, [this]() EXCLUSIVE_LOCKS_REQUIRED(m_mutex) { return m_closed || !m_tasks.empty(); }); + if (m_closed || m_tasks.empty()) return nullptr; + auto task{std::move(m_tasks.front())}; + m_tasks.pop_front(); + return task; + } + + //! Destroys the queued tasks here, outside the lock. False if it was + //! already closed. + bool Close() EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + // cppcheck-suppress variableScope + std::deque> dropped; + { + LOCK(m_mutex); + if (m_closed) return false; + m_closed = true; + dropped.swap(m_tasks); + } + m_cv.notify_all(); + return true; + } + +private: + Mutex m_mutex; + std::condition_variable m_cv; + std::deque> m_tasks GUARDED_BY(m_mutex); + bool m_closed GUARDED_BY(m_mutex){false}; +}; + +class SdkClient final : public PlatformClient +{ +public: + SdkClient(rust::Box sdk, Executor executor) : + m_sdk(std::move(sdk)), + m_executor(std::move(executor)) + { + for (int i = 0; i < WORKER_THREADS; ++i) { + m_workers.emplace_back(&util::TraceThread, "platform", [this] { WorkerLoop(); }); + } + } + + ~SdkClient() override { shutdown(); } + + void getIdentity(const Identifier& id, Callback cb) override + { + Submit([this, id] { return marshal::FromFfi(m_sdk->get_identity(id)); }, std::move(cb)); + } + + void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) override + { + Submit([this, + pubkey_hash] { return marshal::FromFfi(m_sdk->get_identity_by_pubkey_hash(pubkey_hash)); }, + std::move(cb)); + } + + void resolveName(const std::string& label, Callback cb) override + { + Submit([this, label] { return marshal::FromFfi(m_sdk->resolve_name(label)); }, std::move(cb)); + } + + void namesOfIdentity(const Identifier& identity, uint32_t page_limit, uint32_t max_pages, Callback cb) override + { + Submit([this, identity, page_limit, + max_pages] { return marshal::FromFfi(m_sdk->names_of_identity(identity, page_limit, max_pages)); }, + std::move(cb)); + } + + void pendingContests(const Identifier& identity, Callback> cb) override + { + Submit>( + [this, identity] { return marshal::FromFfi(m_sdk->pending_contests_for_identity(identity)); }, std::move(cb)); + } + + void contactRequests(const Identifier& identity, ContactDirection direction, uint64_t since_ms, + Callback> cb) override + { + const auto ffi_direction{direction == ContactDirection::SENT ? dash_sdk::ContactDirection::Sent + : dash_sdk::ContactDirection::Received}; + Submit>( + [this, identity, ffi_direction, since_ms] { + return marshal::ReadContactRequests( + [&](uint64_t since) { return m_sdk->contact_requests(identity, ffi_direction, since); }, since_ms); + }, + std::move(cb)); + } + + void registerIdentity(std::vector keys, AssetLockProof proof, Signing signing, + Callback cb) override + { + Submit( + [this, keys = std::move(keys), proof = std::move(proof), &wallet = signing.wallet, + index = signing.identity_index] { + const auto ffi_keys{marshal::ToFfi(keys)}; + return marshal::FromFfi( + m_sdk->register_identity({ffi_keys.data(), ffi_keys.size()}, marshal::ToFfi(proof), + /*asset_lock_key_ref=*/index, + MakeWalletSigner(wallet, index, wallet::RegistrationFundingKey{index}))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void topUpIdentity(const Identifier& identity, AssetLockProof proof, wallet::TopupFundingKey funding, + Signing signing, Callback cb) override + { + Submit( + [this, identity, proof = std::move(proof), funding, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->top_up_identity(identity, marshal::ToFfi(proof), + /*asset_lock_key_ref=*/funding.funding_index, + MakeWalletSigner(wallet, index, funding))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void registerName(const std::string& label, const Identifier& identity, uint32_t key_id, Signing signing, + Callback cb) override + { + Submit( + [this, label, identity, key_id, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->register_dpns_name(label, identity, key_id, /*contest_fund=*/0, + MakeWalletSigner(wallet, index, std::nullopt))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void sendContactRequest(const ContactRequestInput& input, Signing signing, Callback cb) override + { + Submit( + [this, input, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->send_contact_request(marshal::ToFfi(input), + MakeWalletSigner(wallet, index, std::nullopt), + MakeContactKeys(wallet, index, input.sender))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void shutdown() override + { + if (!m_calls.Close()) return; + // Cancels the calls in flight and waits for them to return. + m_sdk->shutdown(); + for (auto& worker : m_workers) + worker.join(); + // Finished calls the executor has not run yet die here, unlock + // scopes included, without their callbacks. + m_results->Close(); + } + +private: + template + void Submit(std::function()> call, Callback cb, std::unique_ptr unlock = nullptr) + { + m_calls.Push(std::make_unique>(std::move(call), std::move(cb), std::move(unlock))); + } + + void WorkerLoop() + { + while (auto task{m_calls.Pop(/*wait=*/true)}) { + task->Run(); + m_results->Push(std::move(task)); + // Each posted closure delivers one finished call; it owns none. + m_executor([results = m_results] { + if (auto result{results->Pop(/*wait=*/false)}) result->Deliver(); + }); + } + } + + const rust::Box m_sdk; + const Executor m_executor; + TaskQueue m_calls; + const std::shared_ptr m_results{std::make_shared()}; + std::vector m_workers; +}; + +dash_sdk::Network SdkNetwork() +{ + const std::string network{Params().NetworkIDString()}; + if (network == CBaseChainParams::MAIN) return dash_sdk::Network::Mainnet; + if (network == CBaseChainParams::TESTNET) return dash_sdk::Network::Testnet; + if (network == CBaseChainParams::DEVNET) return dash_sdk::Network::Devnet; + return dash_sdk::Network::Regtest; +} + +} // namespace + +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config, interfaces::LLMQ& llmq, Executor executor) +{ + try { + dash_sdk::Config sdk_config; + sdk_config.network = SdkNetwork(); + sdk_config.platform_llmq_type = static_cast(Params().GetConsensus().llmqTypePlatform); + sdk_config.proxy = marshal::ToFfi(config.proxy); + for (const CService& endpoint : config.endpoints) { + sdk_config.endpoints.push_back("https://" + endpoint.ToStringAddrPort()); + } + return std::make_unique( + dash_sdk::new_platform_client(sdk_config, MakeCoreContext(llmq, sdk_config.platform_llmq_type)), + std::move(executor)); + } catch (const std::exception& e) { + LogPrintf("Platform client: the SDK refused its configuration: %s\n", e.what()); + return nullptr; + } +} + +bool IsValidUsername(const std::string& label) +{ + try { + return dash_sdk::is_valid_username(label); + } catch (const std::exception&) { + return false; + } +} + +bool IsContestedUsername(const std::string& label) +{ + try { + return dash_sdk::is_contested_username(label); + } catch (const std::exception&) { + return true; + } +} + +std::optional DecryptContactXpub(const std::array& shared_secret, + Span encrypted_public_key) +{ + try { + const auto xpub{ + dash_sdk::decrypt_contact_xpub(shared_secret, {encrypted_public_key.data(), encrypted_public_key.size()})}; + return wallet::FriendshipXpub{CPubKey{xpub.public_key}, ChainCode{xpub.chain_code}, xpub.parent_fingerprint}; + } catch (const std::exception&) { + return std::nullopt; + } +} + +} // namespace platform diff --git a/src/platform/client.h b/src/platform/client.h new file mode 100644 index 000000000000..4b49299c38cc --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,128 @@ +// 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. + +#ifndef BITCOIN_PLATFORM_CLIENT_H +#define BITCOIN_PLATFORM_CLIENT_H + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace interfaces { +class LLMQ; +class Wallet; +} // namespace interfaces + +namespace platform { + +//! Keeps the wallet unlocked while a write signs. The consumer implements it +//! (dash-qt wraps WalletModel::UnlockContext). The client destroys it on the +//! executor just before the write's callback runs, or in shutdown(). +class UnlockScope +{ +public: + virtual ~UnlockScope() = default; +}; + +//! The wallet a write signs with. Identity keys are +//! IdentityAuthKey{identity_index, key id}. The wallet must stay loaded +//! until the client has shut down. +struct Signing { + // cppcheck-suppress uninitMemberVarNoCtor + interfaces::Wallet& wallet; + uint32_t identity_index{0}; + std::unique_ptr unlock; +}; + +//! Runs a task later on the consumer's thread, never inline (dash-qt queues +//! it with QMetaObject::invokeMethod). The client calls it from several +//! worker threads at once until shutdown() returns. A task owns nothing: one +//! that runs after shutdown() does nothing. +using Executor = std::function)>; + +struct ClientConfig { + std::optional proxy; //!< nullopt: connect directly + //! Evonode Platform HTTPS endpoints, fixed for the client's lifetime. + std::vector endpoints; +}; + +//! Asynchronous Dash Platform client. Each call runs on a worker thread and +//! its callback runs through the executor, never on a worker. Writes sign +//! through the wallet in Signing; private keys stay in the wallet. +class PlatformClient +{ +public: + template + using Callback = std::function)>; + + virtual ~PlatformClient() = default; + + virtual void getIdentity(const Identifier& id, Callback cb) = 0; + //! The identity whose key hashes to pubkey_hash (HASH160). + virtual void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) = 0; + //! The identity a DPNS name resolves to; the SDK normalizes the label. + virtual void resolveName(const std::string& label, Callback cb) = 0; + //! The names of an identity, reading at most max_pages pages of page_limit. + virtual void namesOfIdentity(const Identifier& identity, uint32_t page_limit, uint32_t max_pages, + Callback cb) = 0; + //! Fails if the vote state of any of the contests cannot be read. + virtual void pendingContests(const Identifier& identity, Callback> cb) = 0; + //! Contact requests sent by or to identity, created strictly after + //! since_ms (0: all). Requests whose key indices or account reference + //! exceed 32 bits are left out. + virtual void contactRequests(const Identifier& identity, ContactDirection direction, uint64_t since_ms, + Callback> cb) = 0; + + //! Create an identity with keys, funded by an asset lock paying + //! RegistrationFundingKey{signing.identity_index}. + virtual void registerIdentity(std::vector keys, AssetLockProof proof, Signing signing, + Callback cb) = 0; + //! Add the asset lock paying funding to the identity's balance; the + //! result is the new balance. + virtual void topUpIdentity(const Identifier& identity, AssetLockProof proof, wallet::TopupFundingKey funding, + Signing signing, Callback cb) = 0; + //! Register a DPNS name, signed with identity key key_id. The result is + //! the domain document id. + virtual void registerName(const std::string& label, const Identifier& identity, uint32_t key_id, Signing signing, + Callback cb) = 0; + //! The result is the contact request document id. + virtual void sendContactRequest(const ContactRequestInput& input, Signing signing, Callback cb) = 0; + + //! Drop queued calls, cancel and wait for running ones, and destroy + //! every call not yet delivered, unlock scopes included. No callback runs + //! afterwards, and later calls are dropped: a dropped call never calls + //! back. Call it, or destroy the client, on the executor's thread (a + //! result callback may), never while holding cs_main or cs_wallet, which + //! the SDK's wallet and quorum callbacks take. + virtual void shutdown() = 0; +}; + +//! The client over dash-sdk-cxx, verifying proofs against the quorums llmq +//! retains; llmq must outlive the client. Network and Platform LLMQ type come +//! from Params(). Returns nullptr when the SDK refuses the configuration, such +//! as a proxy it cannot use: it never falls back to connecting directly. +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config, interfaces::LLMQ& llmq, + Executor executor); + +//! DPNS rules, as the SDK applies them. +bool IsValidUsername(const std::string& label); +//! Fails closed: true when the SDK cannot answer. +bool IsContestedUsername(const std::string& label); + +//! Decrypt a contact request's encryptedPublicKey with the ECDH secret. +//! The public key is not validated: check IsFullyValid() before use. +std::optional DecryptContactXpub(const std::array& shared_secret, + Span encrypted_public_key); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_CLIENT_H diff --git a/src/platform/marshal.cpp b/src/platform/marshal.cpp new file mode 100644 index 000000000000..55175e5dfb66 --- /dev/null +++ b/src/platform/marshal.cpp @@ -0,0 +1,240 @@ +// 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 +#include +#include + +namespace platform::marshal { + +namespace { + +std::vector Bytes(const rust::Vec& bytes) { return {bytes.begin(), bytes.end()}; } + +template +rust::Vec ToVec(const T& bytes) +{ + rust::Vec out; + out.reserve(bytes.size()); + for (const auto byte : bytes) + out.push_back(static_cast(byte)); + return out; +} + +template +rust::Vec Serialized(const T& object) +{ + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << object; + return ToVec(stream); +} + +Status FromFfi(const dash_sdk::Status& status) +{ + Status out; + switch (status.kind) { + case dash_sdk::StatusKind::Ok: + out.kind = StatusKind::OK; + break; + case dash_sdk::StatusKind::NotFound: + out.kind = StatusKind::NOT_FOUND; + break; + case dash_sdk::StatusKind::Unavailable: + out.kind = StatusKind::UNAVAILABLE; + break; + case dash_sdk::StatusKind::Rejected: + out.kind = StatusKind::REJECTED; + break; + case dash_sdk::StatusKind::Consensus: + out.kind = StatusKind::CONSENSUS; + break; + case dash_sdk::StatusKind::Internal: + out.kind = StatusKind::INTERNAL; + break; + } + out.consensus_code = status.consensus_code; + out.message = std::string{status.message}; + return out; +} + +IdentityPublicKey FromFfi(const dash_sdk::IdentityKey& key) +{ + IdentityPublicKey out; + out.id = key.id; + out.purpose = static_cast(key.purpose); + out.security_level = static_cast(key.security_level); + out.type = static_cast(key.key_type); + out.read_only = key.read_only; + out.data = Bytes(key.data); + out.disabled_at = key.disabled_at; + return out; +} + +DpnsName FromFfi(const dash_sdk::DpnsName& name) +{ + return {std::string{name.label}, std::string{name.normalized_label}, name.owner, name.identity}; +} + +ContactRequest FromFfi(const dash_sdk::ContactRequest& request) +{ + ContactRequest out; + out.document_id = request.document_id; + out.owner = request.owner; + out.to_user_id = request.to_user_id; + out.encrypted_public_key = Bytes(request.encrypted_public_key); + out.sender_key_index = static_cast(request.sender_key_index); + out.recipient_key_index = static_cast(request.recipient_key_index); + out.account_reference = static_cast(request.account_reference); + out.encrypted_account_label = Bytes(request.encrypted_account_label); + out.auto_accept_proof = Bytes(request.auto_accept_proof); + out.created_at_ms = request.created_at_ms; + out.created_at_core_height = request.created_at_core_height; + return out; +} + +template +std::vector Each(const rust::Vec& items) +{ + std::vector out; + out.reserve(items.size()); + for (const In& item : items) + out.push_back(FromFfi(item)); + return out; +} + +Result> FromFfi(const dash_sdk::ContactRequestsResult& result) +{ + // The contactRequest schema bounds the key indices and the account + // reference from below only. One above 32 bits is no DIP-15 request. + constexpr uint64_t max{std::numeric_limits::max()}; + std::vector requests; + for (const auto& request : result.requests) { + if (request.sender_key_index > max || request.recipient_key_index > max || request.account_reference > max) { + continue; + } + requests.push_back(FromFfi(request)); + } + return {FromFfi(result.status), std::move(requests)}; +} + +} // namespace + +Result FromFfi(const dash_sdk::IdentityResult& result) +{ + const auto& identity{result.identity}; + return {FromFfi(result.status), + {identity.id, identity.balance, identity.revision, Each(identity.keys)}}; +} + +Result FromFfi(const dash_sdk::IdResult& result) { return {FromFfi(result.status), result.id}; } + +Result FromFfi(const dash_sdk::BalanceResult& result) { return {FromFfi(result.status), result.balance}; } + +Result FromFfi(const dash_sdk::NamesResult& result) +{ + return {FromFfi(result.status), {Each(result.names), result.complete}}; +} + +Result> FromFfi(const dash_sdk::ContestsResult& result) +{ + std::vector contests; + contests.reserve(result.contests.size()); + for (const auto& contest : result.contests) { + contests.push_back({std::string{contest.name}, contest.ends_at_ms}); + } + return {FromFfi(result.status), std::move(contests)}; +} + +Result> +ReadContactRequests(const std::function& read, uint64_t since_ms) +{ + Result> all{{StatusKind::OK, 0, {}}, {}}; + while (true) { + const auto page{read(since_ms)}; + auto result{FromFfi(page)}; + if (!result.ok()) return result; + all.value.insert(all.value.end(), std::make_move_iterator(result.value.begin()), + std::make_move_iterator(result.value.end())); + if (page.complete) return all; + // The next page starts after the last request read, refused or not. + if (page.requests.empty() || page.requests.back().created_at_ms <= since_ms) { + return {{StatusKind::UNAVAILABLE, 0, "contact requests: a page did not advance"}, {}}; + } + since_ms = page.requests.back().created_at_ms; + } +} + +std::vector ToFfi(const std::vector& keys) +{ + std::vector out; + out.reserve(keys.size()); + for (const IdentityPublicKey& key : keys) { + dash_sdk::IdentityKey ffi; + ffi.id = key.id; + ffi.purpose = static_cast(key.purpose); + ffi.security_level = static_cast(key.security_level); + ffi.key_type = static_cast(key.type); + ffi.read_only = key.read_only; + ffi.data = ToVec(key.data); + ffi.disabled_at = key.disabled_at; + out.push_back(std::move(ffi)); + } + return out; +} + +dash_sdk::AssetLockProof ToFfi(const AssetLockProof& proof) +{ + dash_sdk::AssetLockProof out{}; + if (const auto* instant{std::get_if(&proof)}) { + out.kind = dash_sdk::AssetLockProofKind::Instant; + out.transaction = Serialized(*instant->transaction); + out.instant_lock = ToVec(instant->instant_lock); + out.output_index = instant->output_index; + } else { + const auto& chain{std::get(proof)}; + out.kind = dash_sdk::AssetLockProofKind::Chain; + out.core_chain_locked_height = chain.core_chain_locked_height; + const auto out_point{Serialized(chain.out_point)}; + std::copy(out_point.begin(), out_point.end(), out.out_point.begin()); + } + return out; +} + +dash_sdk::ContactRequestInput ToFfi(const ContactRequestInput& input) +{ + dash_sdk::ContactRequestInput out; + out.sender = input.sender; + out.recipient = input.recipient; + out.sender_key_index = input.sender_key_index; + out.recipient_key_index = input.recipient_key_index; + out.signing_key_id = input.signing_key_id; + out.account_reference = input.account_reference; + out.account_label = input.account_label; + return out; +} + +dash_sdk::Proxy ToFfi(const std::optional& proxy) +{ + dash_sdk::Proxy out; + out.kind = dash_sdk::ProxyKind::None; + if (!proxy) return out; + out.isolate = proxy->randomize_credentials; + if (const auto* service{std::get_if(&proxy->address)}) { + out.kind = dash_sdk::ProxyKind::Tcp; + out.address = service->ToStringAddrPort(); + } else { + out.kind = dash_sdk::ProxyKind::Unix; + out.address = std::get(proxy->address); + } + return out; +} + +} // namespace platform::marshal diff --git a/src/platform/marshal.h b/src/platform/marshal.h new file mode 100644 index 000000000000..2a842e031218 --- /dev/null +++ b/src/platform/marshal.h @@ -0,0 +1,38 @@ +// 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. + +#ifndef BITCOIN_PLATFORM_MARSHAL_H +#define BITCOIN_PLATFORM_MARSHAL_H + +#include + +#include + +#include +#include +#include +#include + +//! Conversions between dash-sdk-cxx's bridge structs and platform:: types. +namespace platform::marshal { + +Result FromFfi(const dash_sdk::IdentityResult& result); +Result FromFfi(const dash_sdk::IdResult& result); +Result FromFfi(const dash_sdk::BalanceResult& result); +Result FromFfi(const dash_sdk::NamesResult& result); +Result> FromFfi(const dash_sdk::ContestsResult& result); + +//! Every contact request created after since_ms, read a page per `read` +//! call until a page is complete. +Result> +ReadContactRequests(const std::function& read, uint64_t since_ms); + +std::vector ToFfi(const std::vector& keys); +dash_sdk::AssetLockProof ToFfi(const AssetLockProof& proof); +dash_sdk::ContactRequestInput ToFfi(const ContactRequestInput& input); +dash_sdk::Proxy ToFfi(const std::optional& proxy); + +} // namespace platform::marshal + +#endif // BITCOIN_PLATFORM_MARSHAL_H diff --git a/src/platform/signer.cpp b/src/platform/signer.cpp new file mode 100644 index 000000000000..5f5d97132bc9 --- /dev/null +++ b/src/platform/signer.cpp @@ -0,0 +1,93 @@ +// 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 +#include + +#include +#include +#include + +namespace platform { + +namespace { + +constexpr uint8_t KEY_TYPE_ECDSA_SECP256K1{static_cast(IdentityPublicKey::Type::ECDSA_SECP256K1)}; + +bool Sign(interfaces::Wallet& wallet, const wallet::PlatformKeyRequest& key, const uint256& digest, + std::array& sig_out) +{ + const auto signature{wallet.signPlatformDigest(key, digest)}; + if (!signature || signature.value.size() != sig_out.size()) return false; + std::copy(signature.value.begin(), signature.value.end(), sig_out.begin()); + return true; +} + +} // namespace + +std::unique_ptr MakeWalletSigner(interfaces::Wallet& wallet, uint32_t identity_index, + std::optional funding_key) +{ + return std::make_unique( + [&wallet, identity_index](uint32_t key_id, uint8_t key_type, std::span /*key_data*/, + std::span signable, std::array& sig_out) { + return key_type == KEY_TYPE_ECDSA_SECP256K1 && + Sign(wallet, wallet::IdentityAuthKey{identity_index, key_id}, Hash(signable), sig_out); + }, + [&wallet, funding_key](uint32_t /*key_ref*/, const std::array& sighash, + std::array& sig_out) { + if (!funding_key) return false; + const auto key{std::visit([](auto k) -> wallet::PlatformKeyRequest { return k; }, *funding_key)}; + return Sign(wallet, key, uint256{sighash}, sig_out); + }); +} + +std::unique_ptr MakeContactKeys(interfaces::Wallet& wallet, uint32_t identity_index, + const Identifier& sender) +{ + return std::make_unique( + [&wallet, identity_index](uint32_t sender_key_id, const std::array& recipient_key, + std::array& secret_out) { + const auto secret{wallet.platformECDHSecret(wallet::IdentityAuthKey{identity_index, sender_key_id}, + CPubKey{recipient_key})}; + if (!secret || secret.value.size() != secret_out.size()) return false; + std::copy(secret.value.begin(), secret.value.end(), secret_out.begin()); + return true; + }, + [&wallet, sender](const std::array& recipient, uint32_t /*account_reference*/, + std::array& xpub_out) { + // Account 0, as DashPay iOS sends it. No payment to this chain + // can predate its publication, so it is born now. + const auto keychain{wallet.ensureFriendshipReceivingKeychain( + {/*account=*/0, uint256{sender}, uint256{recipient}, + /*birth_time=*/TicksSinceEpoch(NodeClock::now())})}; + return keychain && wallet::CompactXpubBytes(keychain.value, xpub_out); + }); +} + +std::unique_ptr MakeCoreContext(interfaces::LLMQ& llmq, uint8_t platform_llmq_type) +{ + return std::make_unique( + [&llmq, platform_llmq_type](uint8_t llmq_type, const std::array& quorum_hash, + uint32_t /*core_chain_locked_height*/, std::vector& key_out) { + if (llmq_type != platform_llmq_type) return false; + const uint256 hash{quorum_hash}; + for (auto& quorum : llmq.getPlatformQuorums(llmq_type)) { + if (quorum.m_quorum_hash == hash) { + key_out = std::move(quorum.m_pubkey); + return true; + } + } + return false; + }, + dash_sdk::CoreContext::PlatformActivationHeightFn{}); +} + +} // namespace platform diff --git a/src/platform/signer.h b/src/platform/signer.h new file mode 100644 index 000000000000..7287158830fb --- /dev/null +++ b/src/platform/signer.h @@ -0,0 +1,46 @@ +// 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. + +#ifndef BITCOIN_PLATFORM_SIGNER_H +#define BITCOIN_PLATFORM_SIGNER_H + +#include +#include + +#include + +#include +#include +#include + +namespace interfaces { +class LLMQ; +class Wallet; +} // namespace interfaces + +//! The C++ objects dash-sdk-cxx calls back into. They run on SDK worker +//! threads and touch only thread-safe node and wallet interfaces. +namespace platform { + +//! Signs with the wallet's Platform keys: identity key `key_id` as +//! IdentityAuthKey{identity_index, key_id}, and the asset lock with +//! funding_key (none: refused). +std::unique_ptr MakeWalletSigner(interfaces::Wallet& wallet, uint32_t identity_index, + std::optional funding_key); + +//! The wallet's half of a contact request from sender: the ECDH secret of +//! IdentityAuthKey{identity_index, sender_key_id}, and the compact xpub of +//! our DIP-15 receiving chain towards the recipient, imported as it is +//! published. +std::unique_ptr MakeContactKeys(interfaces::Wallet& wallet, uint32_t identity_index, + const Identifier& sender); + +//! Answers quorum keys from the quorums the node retains, matching both +//! type and hash. There is no Platform activation height source: the SDK +//! needs it only for a query the binding does not expose. +std::unique_ptr MakeCoreContext(interfaces::LLMQ& llmq, uint8_t platform_llmq_type); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_SIGNER_H diff --git a/src/platform/types.h b/src/platform/types.h new file mode 100644 index 000000000000..59daa9f6f8be --- /dev/null +++ b/src/platform/types.h @@ -0,0 +1,173 @@ +// 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. + +#ifndef BITCOIN_PLATFORM_TYPES_H +#define BITCOIN_PLATFORM_TYPES_H + +#include +#include + +#include +#include +#include +#include +#include + +//! The values PlatformClient reads and writes, as plain Core types. They +//! mirror dash-sdk-cxx's bridge structs, so nothing above src/platform +//! includes the binding. +namespace platform { + +using Identifier = std::array; + +//! How a call ended, as the binding classifies it. The message is for logs. +enum class StatusKind : uint8_t { + OK, + //! Proved absence: the identity or name does not exist. For resolveName + //! this means "does not resolve", not "available". + NOT_FOUND, + //! Transport, proxy, timeout, no endpoint, or the client is shut down. + UNAVAILABLE, + //! A proof or quorum check refused the response. + REJECTED, + //! Platform refused the state transition; consensus_code says why. + CONSENSUS, + //! Bad input, a refused callback, or an unclassified SDK error. + INTERNAL, +}; + +struct Status { + StatusKind kind{StatusKind::INTERNAL}; + uint32_t consensus_code{0}; + std::string message; +}; + +//! The value is meaningful only under OK. +template +struct Result { + Status status; + T value{}; + + bool ok() const { return status.kind == StatusKind::OK; } +}; + +struct IdentityPublicKey { + enum class Type : uint8_t { + ECDSA_SECP256K1 = 0, + BLS12_381 = 1, + ECDSA_HASH160 = 2, + BIP13_SCRIPT_HASH = 3, + EDDSA_25519_HASH160 = 4, + }; + enum class Purpose : uint8_t { + AUTHENTICATION = 0, + ENCRYPTION = 1, + DECRYPTION = 2, + TRANSFER = 3, + SYSTEM = 4, + VOTING = 5, + OWNER = 6, + }; + enum class SecurityLevel : uint8_t { + MASTER = 0, + CRITICAL = 1, + HIGH = 2, + MEDIUM = 3, + }; + + uint32_t id{0}; + Purpose purpose{Purpose::AUTHENTICATION}; + SecurityLevel security_level{SecurityLevel::MASTER}; + Type type{Type::ECDSA_SECP256K1}; + bool read_only{false}; + std::vector data; //!< the 33-byte public key for ECDSA_SECP256K1 + uint64_t disabled_at{0}; //!< 0: enabled +}; + +struct Identity { + Identifier id{}; + uint64_t balance{0}; //!< credits + uint64_t revision{0}; + std::vector public_keys; +}; + +struct DpnsName { + std::string label; + std::string normalized_label; + Identifier owner{}; + Identifier identity{}; //!< records.identity, all zero if none +}; + +struct Names { + std::vector names; + //! false: the page bound was reached, so the list is a lower bound. + bool complete{false}; +}; + +//! A contested name the identity is a contender for, still being voted on. +struct Contest { + std::string normalized_label; + uint64_t ends_at_ms{0}; +}; + +enum class ContactDirection : uint8_t { + SENT, + RECEIVED, +}; + +//! A DashPay contactRequest document. +struct ContactRequest { + Identifier document_id{}; + Identifier owner{}; + Identifier to_user_id{}; + std::vector encrypted_public_key; + uint32_t sender_key_index{0}; + uint32_t recipient_key_index{0}; + uint32_t account_reference{0}; + std::vector encrypted_account_label; //!< empty: absent + std::vector auto_accept_proof; //!< empty: absent + uint64_t created_at_ms{0}; + uint32_t created_at_core_height{0}; +}; + +//! An InstantSend-locked asset lock: the transaction, its serialized islock +//! and the index of the credit output. +struct InstantAssetLockProof { + CTransactionRef transaction; + std::vector instant_lock; + uint32_t output_index{0}; +}; + +//! A ChainLocked asset lock: its credit output and a ChainLocked height at +//! or above the block that mined it. +struct ChainAssetLockProof { + uint32_t core_chain_locked_height{0}; + COutPoint out_point; +}; + +using AssetLockProof = std::variant; + +//! A DIP-15 contact request to send. account_reference is the finished +//! value from interfaces::Wallet::platformAccountReference. +struct ContactRequestInput { + Identifier sender{}; + Identifier recipient{}; + uint32_t sender_key_index{0}; //!< our ENCRYPTION key + uint32_t recipient_key_index{0}; + uint32_t signing_key_id{0}; + uint32_t account_reference{0}; + std::string account_label; //!< empty: none +}; + +//! The SOCKS5 proxy every connection of a client goes through. +struct ProxyConfig { + //! A numeric address, or the path of a Unix socket (without "unix:"). + std::variant address; + //! -proxyrandomize: fresh credentials per connection. + bool randomize_credentials{true}; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_TYPES_H diff --git a/src/test/platform_client_tests.cpp b/src/test/platform_client_tests.cpp new file mode 100644 index 000000000000..83c7776d3ca5 --- /dev/null +++ b/src/test/platform_client_tests.cpp @@ -0,0 +1,378 @@ +// 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 +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +using namespace platform; + +namespace { + +//! The mnemonic of platformkeys_tests' DIP-14 seed. +const SecureString MNEMONIC{"birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; + +struct PlatformClientSetup : public TestingSetup { + PlatformClientSetup() + { + m_context.args = &m_args; + m_context.chain = m_node.chain.get(); + m_wallet = std::make_shared(m_node.chain.get(), "", wallet::CreateMockableWalletDatabase()); + m_wallet->LoadWallet(); + { + LOCK(m_wallet->cs_wallet); + m_wallet->SetWalletFlag(wallet::WALLET_FLAG_DESCRIPTORS); + m_wallet->SetupDescriptorScriptPubKeyMans(MNEMONIC, ""); + } + m_iface = interfaces::MakeWallet(m_context, m_wallet); + } + + wallet::WalletContext m_context; + std::shared_ptr m_wallet; + std::unique_ptr m_iface; +}; + +//! The consumer's thread: tasks the client posts wait here until the test +//! runs them. +class TestExecutor +{ +public: + Executor executor() + { + return [this](std::function task) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) { + LOCK(m_mutex); + m_tasks.push_back(std::move(task)); + }; + } + + size_t pending() EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + LOCK(m_mutex); + return m_tasks.size(); + } + + //! Wait until n tasks are posted. + bool WaitFor(size_t n) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + for (int i = 0; i < 1000 && pending() < n; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds{10}); + return pending() >= n; + } + + //! Run and destroy every posted task on this thread. + void Drain() EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + std::vector> tasks; + WITH_LOCK(m_mutex, tasks.swap(m_tasks)); + for (auto& task : tasks) + task(); + } + +private: + Mutex m_mutex; + std::vector> m_tasks GUARDED_BY(m_mutex); +}; + +//! Records the thread it is released on. +class TestUnlock final : public UnlockScope +{ +public: + explicit TestUnlock(std::optional& released_on) : + m_released_on(released_on) + { + } + ~TestUnlock() override { m_released_on = std::this_thread::get_id(); } + +private: + std::optional& m_released_on; +}; + +class TestLLMQ final : public interfaces::LLMQ +{ +public: + std::vector quorums; + + ChainLockInfo getBestChainLock() override { return {}; } + CreditPoolCounts getCreditPoolCounts() override { return {}; } + InstantSendCounts getInstantSendCounts() override { return {}; } + size_t getPendingAssetUnlocks() override { return 0; } + std::vector getQuorumStats() override { return {}; } + std::vector getPlatformQuorums(uint8_t) override { return quorums; } + std::vector getInstantSendLock(const uint256&) override { return {}; } +}; + +} // namespace + +BOOST_FIXTURE_TEST_SUITE(platform_client_tests, PlatformClientSetup) + +//! Through the real client with no endpoints: callbacks run on the +//! consumer's executor, never on a worker, a write's unlock scope being +//! released there just before its callback; shutdown() waits for a call +//! that is inside a wallet callback and releases its unlock scope; and no +//! callback runs after shutdown(). +BOOST_AUTO_TEST_CASE(client_threads_and_shutdown) +{ + TestLLMQ llmq; + TestExecutor executor; + auto client{MakeSdkPlatformClient(ClientConfig{}, llmq, executor.executor())}; + BOOST_REQUIRE(client); + const auto consumer{std::this_thread::get_id()}; + + // Results wait for the executor; a write keeps the wallet unlocked until + // its result is delivered. + std::optional read_status; + std::optional read_thread; + client->getIdentity(Identifier{}, [&](Result result) { + read_status = result.status.kind; + read_thread = std::this_thread::get_id(); + }); + std::optional unlock_released_on; + std::optional unlock_released_before_callback; + client->registerName("alice", Identifier{}, /*key_id=*/1, + Signing{*m_iface, 0, std::make_unique(unlock_released_on)}, + [&](Result) { unlock_released_before_callback = unlock_released_on; }); + BOOST_REQUIRE(executor.WaitFor(2)); + BOOST_CHECK(!read_status); + BOOST_CHECK(!unlock_released_on); + executor.Drain(); + BOOST_CHECK(read_status == StatusKind::UNAVAILABLE); + BOOST_CHECK(read_thread == consumer); + BOOST_CHECK(unlock_released_before_callback == consumer); + + // A registration blocked in the wallet signer, which needs cs_wallet: + // shutdown() must not return before the holder releases it. + const auto pubkey{m_iface->getPlatformPubKey(wallet::IdentityAuthKey{0, 0})}; + BOOST_REQUIRE(pubkey); + IdentityPublicKey key; + key.data.assign(pubkey.value.begin(), pubkey.value.end()); + std::promise locked; + std::atomic_bool releasing{false}; + std::thread holder{[&] { + LOCK(m_wallet->cs_wallet); + locked.set_value(); + // Longer than the SDK runtime's own 2 s stop timeout. + std::this_thread::sleep_for(std::chrono::milliseconds{3500}); + releasing = true; + }}; + locked.get_future().wait(); + unlock_released_on.reset(); + bool write_called{false}; + client->registerIdentity({key}, ChainAssetLockProof{}, + Signing{*m_iface, 0, std::make_unique(unlock_released_on)}, + [&](Result) { write_called = true; }); + // Time for a worker to reach the signer; there is no network to wait on. + std::this_thread::sleep_for(std::chrono::seconds{1}); + client->shutdown(); + BOOST_CHECK(releasing); + holder.join(); + + // shutdown() released the write's unlock here; its result, handed to the + // executor, no longer calls back. + BOOST_CHECK(unlock_released_on == consumer); + executor.Drain(); + BOOST_CHECK(!write_called); + + // Calls after shutdown() are dropped on the calling thread. + unlock_released_on.reset(); + client->registerName("alice", Identifier{}, 1, Signing{*m_iface, 0, std::make_unique(unlock_released_on)}, + [&](Result) { write_called = true; }); + BOOST_CHECK(unlock_released_on == consumer); + BOOST_CHECK_EQUAL(executor.pending(), 0U); + BOOST_CHECK(!write_called); +} + +//! The signer answers with the wallet's RFC6979 compact signature over +//! SHA256d(signable) for each published identity key, by identity index, +//! and over the sighash for the asset lock's funding key. Expected values +//! are computed independently in Python (stdlib HMAC-SHA512 BIP32 and the +//! functional test framework's secp256k1) from the DIP-14 seed, on the +//! mainnet coin type that TestingSetup's chain uses. +BOOST_AUTO_TEST_CASE(wallet_signer_vectors) +{ + std::vector signable(64); + for (size_t i = 0; i < signable.size(); ++i) + signable[i] = i; + std::array sig; + + const struct { + uint32_t identity_index; + uint32_t key_id; + const char* signature; + } identity_keys[]{ + {0, 0, + "2001450b14681ad74139462b838ff42b46f4f742a1e41134949084a10b62589c64471aed532f76c7d5878439baf57166f98ad85c0358a" + "5fa59ea65c8c196fc2e22"}, + {0, 1, + "1ff1882b86979e490de505496ef06d0c79d91f2deccd3e2bd575ddb0f0ab0481bc508d0e4f79611fc15d4e779cd9c19d8f5291a0255bf" + "475f37d9ef662971b5412"}, + {0, 2, + "1ffed3a6bcc4cec33c32a9149c7c94c43643a2c0a58d7e55a57cba85b817331a1910715c1670978a4ce8e1f6d1fa062cdf6cf40e54a8f" + "941711dde613a0e9d002e"}, + {0, 3, + "1fc40d13035242431cfb237c0e68bfbd0b3246600ee216b2c5e279158f54a1af276173fc25abfa25ddbd0486f7857d3e61b74a9c228b1" + "3a51b070937b37de57d7d"}, + {1, 0, + "20b4be2547b6d1a826da05a0b230b4b95ec333341662b2a09fbf2d429b5ad689392dffd24624290e53efe75a58af9f025ad97817365cc" + "ec1a495a73341301d2e2d"}, + }; + for (const auto& row : identity_keys) { + const auto signer{MakeWalletSigner(*m_iface, row.identity_index, std::nullopt)}; + BOOST_REQUIRE(signer->SignIdentityKey(row.key_id, /*key_type=*/0, {}, {signable.data(), signable.size()}, sig)); + BOOST_CHECK_EQUAL(HexStr(sig), row.signature); + } + + std::array sighash; + sighash.fill(0x42); + const auto registration{MakeWalletSigner(*m_iface, 0, wallet::RegistrationFundingKey{0})}; + BOOST_REQUIRE(registration->SignAssetLock(/*key_ref=*/0, sighash, sig)); + BOOST_CHECK_EQUAL(HexStr(sig), + "207982873b927f462833f7ac2a5ddfa56d4f5a224a2f594be99f737530455d5b682da131a012cd943ce5" + "e5a85534814189777b5f6be1de65579eb568d247599c1c"); + const auto top_up{MakeWalletSigner(*m_iface, 0, wallet::TopupFundingKey{3})}; + BOOST_REQUIRE(top_up->SignAssetLock(/*key_ref=*/3, sighash, sig)); + BOOST_CHECK_EQUAL(HexStr(sig), + "1fe4adc78e2d294bd18f1020be29e6589eb7a5f8e98548097b11e9ff01587e284e3a097070bd7a6ccdbc" + "dc1a11c608c8248e1cef5fc34d65d6861fc4a25807566a"); + + // A write without an asset lock has no funding key to sign with, and + // no ECDSA_HASH160 key is signed with yet. + const auto document{MakeWalletSigner(*m_iface, 0, std::nullopt)}; + BOOST_CHECK(!document->SignAssetLock(0, sighash, sig)); + BOOST_CHECK(!document->SignIdentityKey(0, /*key_type=*/2, {}, {signable.data(), signable.size()}, sig)); + + // ECDH with our ENCRYPTION key (identity 0, key 2) and the recipient key + // 7*G: SHA256 of the compressed shared point. + const auto contact{MakeContactKeys(*m_iface, 0, Identifier{})}; + std::array recipient_key; + const auto recipient{ParseHex("025cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc")}; + std::copy(recipient.begin(), recipient.end(), recipient_key.begin()); + std::array secret; + BOOST_REQUIRE(contact->SharedSecret(/*sender_key_id=*/2, recipient_key, secret)); + BOOST_CHECK_EQUAL(HexStr(secret), "893d3a804b2252c11400aeddcf83599fc9f3ac8b6067729c1c95455745cd410f"); +} + +//! The compact xpub published to a recipient is our receiving chain from the +//! sender towards that recipient, not the reverse. +BOOST_AUTO_TEST_CASE(contact_xpub_is_our_receiving_chain) +{ + Identifier sender, recipient; + sender.fill(0x11); + recipient.fill(0x22); + const auto contact{MakeContactKeys(*m_iface, 0, sender)}; + std::array xpub; + BOOST_REQUIRE(contact->CompactXpub(recipient, /*account_reference=*/0, xpub)); + + const auto chain{m_iface->ensureFriendshipReceivingKeychain( + {/*account=*/0, uint256{sender}, uint256{recipient}, /*birth_time=*/0})}; + BOOST_REQUIRE(chain); + wallet::CompactXpub expected; + BOOST_REQUIRE(wallet::CompactXpubBytes(chain.value, expected)); + BOOST_CHECK(xpub == expected); +} + +//! A quorum key is answered only for the Platform LLMQ type and a hash the +//! node retains. +BOOST_AUTO_TEST_CASE(core_context_matches_type_and_hash) +{ + TestLLMQ llmq; + const uint256 hash{uint256S("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")}; + llmq.quorums.push_back({hash, std::vector(48, 0xab), 100}); + const auto context{MakeCoreContext(llmq, /*platform_llmq_type=*/6)}; + + std::array quorum_hash; + std::copy(hash.begin(), hash.end(), quorum_hash.begin()); + std::array key; + BOOST_REQUIRE(context->QuorumPublicKey(6, quorum_hash, 0, key)); + BOOST_CHECK(std::all_of(key.begin(), key.end(), [](uint8_t b) { return b == 0xab; })); + + BOOST_CHECK(!context->QuorumPublicKey(5, quorum_hash, 0, key)); + quorum_hash[0] ^= 1; + BOOST_CHECK(!context->QuorumPublicKey(6, quorum_hash, 0, key)); + uint32_t height; + BOOST_CHECK(!context->PlatformActivationHeight(height)); +} + +//! Contact requests are read page by page from the last request read, and a +//! request with an index above 32 bits is left out but still moves the +//! cursor. +BOOST_AUTO_TEST_CASE(contact_requests_read_every_page) +{ + constexpr uint64_t above{uint64_t{1} << 32}; + const auto request{[](uint64_t created_at_ms, uint64_t sender = 1, uint64_t recipient = 1, uint64_t account = 0) { + dash_sdk::ContactRequest r; + r.created_at_ms = created_at_ms; + r.sender_key_index = sender; + r.recipient_key_index = recipient; + r.account_reference = account; + return r; + }}; + const auto page{[](std::vector requests, bool complete, + dash_sdk::StatusKind kind = dash_sdk::StatusKind::Ok) { + dash_sdk::ContactRequestsResult result; + result.status.kind = kind; + for (auto& r : requests) + result.requests.push_back(std::move(r)); + result.complete = complete; + return result; + }}; + + std::vector cursors; + const auto all{marshal::ReadContactRequests( + [&](uint64_t since) { + cursors.push_back(since); + if (since == 5) return page({request(10), request(20, above)}, false); + return page({request(30), request(40, 1, above), request(50, 1, 1, above)}, true); + }, + 5)}; + BOOST_REQUIRE(all.ok()); + BOOST_CHECK(cursors == (std::vector{5, 20})); + BOOST_REQUIRE_EQUAL(all.value.size(), 2U); + BOOST_CHECK_EQUAL(all.value[0].created_at_ms, 10U); + BOOST_CHECK_EQUAL(all.value[1].created_at_ms, 30U); + + // A failed page fails the read, and a page that does not move the + // cursor ends it instead of repeating forever. + const auto failed{marshal::ReadContactRequests( + [&](uint64_t since) { + if (since == 0) return page({request(10)}, false); + return page({}, false, dash_sdk::StatusKind::Unavailable); + }, + 0)}; + BOOST_CHECK(failed.status.kind == StatusKind::UNAVAILABLE); + BOOST_CHECK(failed.value.empty()); + const auto stuck{marshal::ReadContactRequests([&](uint64_t) { return page({request(10)}, false); }, 10)}; + BOOST_CHECK(stuck.status.kind == StatusKind::UNAVAILABLE); + const auto empty{marshal::ReadContactRequests([&](uint64_t) { return page({}, false); }, 0)}; + BOOST_CHECK(empty.status.kind == StatusKind::UNAVAILABLE); +} + +//! An unusable proxy gives no client: it never falls back to a direct +//! connection. +BOOST_AUTO_TEST_CASE(unusable_proxy_gives_no_client) +{ + TestLLMQ llmq; + TestExecutor executor; + ClientConfig config; + config.proxy = ProxyConfig{std::string{}, true}; + BOOST_CHECK(!MakeSdkPlatformClient(config, llmq, executor.executor())); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/util/platform_client.cpp b/src/test/util/platform_client.cpp new file mode 100644 index 000000000000..b2bbb4362ec4 --- /dev/null +++ b/src/test/util/platform_client.cpp @@ -0,0 +1,110 @@ +// 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 + +using platform::Identifier; + +template +void FakePlatformClient::Answer(Call call, std::deque>& scripted, Callback cb, + std::unique_ptr unlock) +{ + if (shut_down) return; + calls.push_back(std::move(call)); + platform::Result result; + if (scripted.empty()) { + result.status.kind = platform::StatusKind::UNAVAILABLE; + result.status.message = "nothing scripted"; + } else { + result = std::move(scripted.front()); + scripted.pop_front(); + } + m_pending.push_back([cb = std::move(cb), result = std::move(result), + unlock = std::shared_ptr{std::move(unlock)}]() mutable { + unlock.reset(); + cb(std::move(result)); + }); +} + +void FakePlatformClient::getIdentity(const Identifier& id, Callback cb) +{ + Answer({"getIdentity", HexStr(id), std::nullopt}, identities, std::move(cb)); +} + +void FakePlatformClient::getIdentityByPublicKeyHash(const std::array& pubkey_hash, + Callback cb) +{ + Answer({"getIdentityByPublicKeyHash", HexStr(pubkey_hash), std::nullopt}, identities_by_pubkey_hash, std::move(cb)); +} + +void FakePlatformClient::resolveName(const std::string& label, Callback cb) +{ + Answer({"resolveName", label, std::nullopt}, resolved_names, std::move(cb)); +} + +void FakePlatformClient::namesOfIdentity(const Identifier& identity, uint32_t, uint32_t, Callback cb) +{ + Answer({"namesOfIdentity", HexStr(identity), std::nullopt}, names, std::move(cb)); +} + +void FakePlatformClient::pendingContests(const Identifier& identity, Callback> cb) +{ + Answer({"pendingContests", HexStr(identity), std::nullopt}, contests, std::move(cb)); +} + +void FakePlatformClient::contactRequests(const Identifier& identity, platform::ContactDirection, uint64_t, + Callback> cb) +{ + Answer({"contactRequests", HexStr(identity), std::nullopt}, contact_requests, std::move(cb)); +} + +void FakePlatformClient::registerIdentity(std::vector, platform::AssetLockProof, + platform::Signing signing, Callback cb) +{ + Answer({"registerIdentity", "", signing.identity_index}, registered_identities, std::move(cb), + std::move(signing.unlock)); +} + +void FakePlatformClient::topUpIdentity(const Identifier& identity, platform::AssetLockProof, wallet::TopupFundingKey, + platform::Signing signing, Callback cb) +{ + Answer({"topUpIdentity", HexStr(identity), signing.identity_index}, top_ups, std::move(cb), std::move(signing.unlock)); +} + +void FakePlatformClient::registerName(const std::string& label, const Identifier&, uint32_t, platform::Signing signing, + Callback cb) +{ + Answer({"registerName", label, signing.identity_index}, registered_names, std::move(cb), std::move(signing.unlock)); +} + +void FakePlatformClient::sendContactRequest(const platform::ContactRequestInput& input, platform::Signing signing, + Callback cb) +{ + Answer({"sendContactRequest", HexStr(input.recipient), signing.identity_index}, sent_contact_requests, + std::move(cb), std::move(signing.unlock)); +} + +void FakePlatformClient::shutdown() +{ + shut_down = true; + m_pending.clear(); +} + +void FakePlatformClient::deliverAll() +{ + while (!m_pending.empty()) { + auto deliver{std::move(m_pending.front())}; + m_pending.pop_front(); + deliver(); + } +} + +size_t FakePlatformClient::countCalls(const std::string& method) const +{ + return std::count_if(calls.begin(), calls.end(), [&](const Call& call) { return call.method == method; }); +} diff --git a/src/test/util/platform_client.h b/src/test/util/platform_client.h new file mode 100644 index 000000000000..64dde39fef97 --- /dev/null +++ b/src/test/util/platform_client.h @@ -0,0 +1,79 @@ +// 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. + +#ifndef BITCOIN_TEST_UTIL_PLATFORM_CLIENT_H +#define BITCOIN_TEST_UTIL_PLATFORM_CLIENT_H + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +//! A scripted platform::PlatformClient for tests of the layers above it. +//! Each call records itself and takes the next result scripted for it +//! (UNAVAILABLE when none is). As with the real client, its callback runs +//! later: deliverAll() runs the pending callbacks, a write releasing its +//! unlock scope just before its callback. shutdown() drops pending callbacks +//! and later calls. +class FakePlatformClient final : public platform::PlatformClient +{ +public: + struct Call { + std::string method; + std::string argument; //!< hex identifier or hash, or the label + std::optional identity_index; //!< writes only + }; + + std::deque> identities; + std::deque> identities_by_pubkey_hash; + std::deque> resolved_names; + std::deque> names; + std::deque>> contests; + std::deque>> contact_requests; + std::deque> registered_identities; + std::deque> top_ups; + std::deque> registered_names; + std::deque> sent_contact_requests; + + std::vector calls; + bool shut_down{false}; + + void getIdentity(const platform::Identifier& id, Callback cb) override; + void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) override; + void resolveName(const std::string& label, Callback cb) override; + void namesOfIdentity(const platform::Identifier& identity, uint32_t page_limit, uint32_t max_pages, + Callback cb) override; + void pendingContests(const platform::Identifier& identity, Callback> cb) override; + void contactRequests(const platform::Identifier& identity, platform::ContactDirection direction, uint64_t since_ms, + Callback> cb) override; + void registerIdentity(std::vector keys, platform::AssetLockProof proof, + platform::Signing signing, Callback cb) override; + void topUpIdentity(const platform::Identifier& identity, platform::AssetLockProof proof, + wallet::TopupFundingKey funding, platform::Signing signing, Callback cb) override; + void registerName(const std::string& label, const platform::Identifier& identity, uint32_t key_id, + platform::Signing signing, Callback cb) override; + void sendContactRequest(const platform::ContactRequestInput& input, platform::Signing signing, + Callback cb) override; + void shutdown() override; + + //! Run the pending callbacks, including those they add. + void deliverAll(); + //! Number of recorded calls of a method. + size_t countCalls(const std::string& method) const; + +private: + template + void Answer(Call call, std::deque>& scripted, Callback cb, + std::unique_ptr unlock = nullptr); + + std::deque> m_pending; +}; + +#endif // BITCOIN_TEST_UTIL_PLATFORM_CLIENT_H diff --git a/test/util/data/non-backported.txt b/test/util/data/non-backported.txt index 19db26c1ce8f..539235ebad56 100644 --- a/test/util/data/non-backported.txt +++ b/test/util/data/non-backported.txt @@ -37,6 +37,8 @@ src/masternode/*.cpp src/masternode/*.h src/messagesigner.* src/netfulfilledman.* +src/platform/*.cpp +src/platform/*.h src/qt/clientfeeds.* src/qt/descriptiondialog.* src/qt/donutchart.* @@ -94,10 +96,12 @@ src/test/llmq*.cpp src/test/quorum_proofs_tests.cpp src/test/masternode_params_tests.cpp src/test/masternode_payments_tests.cpp +src/test/platform_client_tests.cpp src/test/spork_tests.cpp src/test/unordered_lru_cache_tests.cpp src/test/util/llmq_tests.h src/test/util/masternode.* +src/test/util/platform_client.* src/test/governance*.cpp src/unordered_lru_cache.h src/util/edge.*