From 0388e823f761fdec76cc1525a0f48710cca5e2ef Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 21:03:45 -0500 Subject: [PATCH 1/8] 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 that points to the issue tracking Guix support: . 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 c7dbad85e8be..ded48a615855 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -193,3 +193,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..5d853f094ac8 --- /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; see ) +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 57deb922e040b45d3635ce67c8a84b41fc215846 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 21:03:58 -0500 Subject: [PATCH 2/8] 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 | 219 ++++++++++++++++++ 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, 578 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 ded48a615855..f38cf609818a 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -203,3 +203,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..362d88383574 --- /dev/null +++ b/depends/packages/platform_cxx.mk @@ -0,0 +1,219 @@ +# 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=dc4dc8e80ef8c495be693d4cc19c378a94f5d116 +$(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=fca5a310c8322924734ae7f8c522bffe4622bb4eb8394a6e054f56129ffbe592 +$(package)_crates_file_name=dash-sdk-cxx-crates-$($(package)_version).tar.gz +$(package)_crates_sha256_hash=6f9d50e3c82406956b4b769874824bac7c28e8f1bcf1b3d514f7d381ea672f55 +$(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; TENDERDASH_DIR and +# TENDERDASH_COMMITISH, which the bundle's configuration sets to the tag of +# the Tenderdash archive it carries. +# - 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+=TENDERDASH_DIR TENDERDASH_COMMITISH +$(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 --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 a54e60efbe03..ed49a3fec2bd 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 [dc4dc8e...](https://github.com/dashpay/platform/tree/dc4dc8e80ef8c495be693d4cc19c378a94f5d116) | | No | + ### Networking | Dependency | Releases | Version used | Minimum required | Runtime | | --- | --- | --- | --- | --- | From 9eaa74f206ac93cbb7e2cf429f57cab1fa7f90cc Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 10:39:48 -0500 Subject: [PATCH 3/8] 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 d36c49ebfdf6..cb2a2126aee9 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], @@ -1926,6 +1934,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"]) @@ -2121,6 +2164,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 ca191acfef7b..53eec4a79bde 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 90a0ed75ed64..28474503589a 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -276,6 +276,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 b3f04154ab72a070235439c2cf60fff7b6e09174 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 24 Sep 2026 10:39:48 -0500 Subject: [PATCH 4/8] 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-wallet 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 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. The cache key is versioned so that a cache saved without those sources is not taken as complete. This step fails until the Platform archives are on the depends sources mirror. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-depends.yml | 4 +- .github/workflows/build.yml | 25 +++++--- .github/workflows/cache-depends-sources.yml | 12 +++- 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 +++++++++++++++++++++ 9 files changed, 121 insertions(+), 11 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-depends.yml b/.github/workflows/build-depends.yml index fdd09c425545..fb5b5f4d4a99 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -170,8 +170,8 @@ jobs: uses: actions/cache/restore@v5 with: path: depends/sources - key: depends-sources-${{ hashFiles('depends/packages/*') }} - restore-keys: depends-sources- + key: depends-sources-v2-${{ hashFiles('depends/packages/*') }} + restore-keys: depends-sources-v2- - name: Restore SDKs cache id: sdk-cache diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52811ebc8276..7dc94ae9e8b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -227,8 +227,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 }} @@ -263,6 +262,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 @@ -387,15 +398,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..7681d2a5b9ba 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -31,15 +31,23 @@ jobs: allow-unsafe-pr-checkout: true persist-credentials: false + # v2: caches from before the Platform GUI step below lack its sources. - name: Check for cached sources id: cache-check uses: actions/cache@v5 with: path: depends/sources - key: depends-sources-${{ hashFiles('depends/packages/*') }} - restore-keys: depends-sources- + key: depends-sources-v2-${{ hashFiles('depends/packages/*') }} + restore-keys: depends-sources-v2- lookup-only: true - 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..e7db41d27345 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/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 26ea6d1fc10f..7fdf2b20b849 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 f38cf609818a..49fea496b958 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -219,3 +219,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 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..7572df20f04b --- /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 only; dashd, the command-line tools 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-wallet 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 ff693e725a314db2c52dc0d1d6090e867b1cc94c Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 08:25:36 -0500 Subject: [PATCH 5/8] feat(wallet): DIP-15 compact xpub and accountReference seams FriendshipXpub carries the BIP32 parent fingerprint of the friendship leaf, so CompactXpubBytes() yields the 69-byte DIP-15 compact form (parentFingerprint || chainCode || pubKey) that a contactRequest's encryptedPublicKey and accountReference are computed over. The fingerprint is that of the key one step above the final 256-bit derivation, as rust-dashcore's key-wallet reports it. interfaces::Wallet::platformAccountReference returns the finished accountReference for a contact request: HMAC-SHA256 keyed by the derived identity ENCRYPTION key over the compact xpub, masked as rs-platform-encryption's calculate_account_reference does with iOS's ASK28 reading (be32(mac[28..32]) >> 4), for account 0 and version 0 as DashPay iOS sends it. The key never leaves the wallet. Tests: the parent fingerprint, compact xpub, accountReference and DIP-15 payment-address vectors, generated with key-wallet e4208c90786a and rs-platform-encryption from the DIP-14 test seed; and the iOS ASK28 known answer from rs-platform-encryption (digest 00..1f gives 0x01c1d1e1). Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 6 +++ src/wallet/interfaces.cpp | 5 ++ src/wallet/platformkeys.cpp | 36 +++++++++++++- src/wallet/platformkeys.h | 16 +++++- src/wallet/platformtypes.h | 14 ++++++ src/wallet/test/platformkeys_tests.cpp | 67 ++++++++++++++++++++++++++ src/wallet/wallet.cpp | 18 ++++++- src/wallet/wallet.h | 1 + 8 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 641579241315..150f2198ccf2 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -156,6 +156,12 @@ class Wallet virtual wallet::PlatformKeyResult platformECDHSecret(const wallet::IdentityAuthKey& key, const CPubKey& counterparty) = 0; + //! DIP-15 accountReference of a contact request sending compact_xpub, + //! keyed by the identity ENCRYPTION key, for account 0 and version 0 as + //! DashPay iOS sends it. The key never leaves the wallet. + virtual wallet::PlatformKeyResult platformAccountReference(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) = 0; + //! Ensure our private DIP-15 receiving chain is imported as a ranged //! descriptor and return the corresponding public chain. Derivation, //! descriptor update and result publication are one wallet-locked action. diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index c4c6d4502fbf..45cdfb5bfbc9 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -262,6 +262,11 @@ class WalletImpl : public Wallet { return m_wallet->PlatformECDHSecret(key, counterparty); } + wallet::PlatformKeyResult platformAccountReference(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) override + { + return m_wallet->PlatformAccountReference(key, compact_xpub); + } wallet::PlatformKeyResult ensureFriendshipReceivingKeychain( const wallet::FriendshipKeychainRequest& request) override { diff --git a/src/wallet/platformkeys.cpp b/src/wallet/platformkeys.cpp index aa188969c7ed..47288b690280 100644 --- a/src/wallet/platformkeys.cpp +++ b/src/wallet/platformkeys.cpp @@ -4,6 +4,8 @@ #include +#include +#include #include #include @@ -88,8 +90,15 @@ bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) CKey key{parent.key}; ChainCode chaincode{parent.chaincode}; + std::array parent_fingerprint{}; + + for (size_t i{0}; i < path.size(); ++i) { + const auto& element{path[i]}; + if (i + 1 == path.size()) { + const CKeyID parent_id{key.GetPubKey().GetID()}; + std::copy_n(parent_id.begin(), parent_fingerprint.size(), parent_fingerprint.begin()); + } - for (const auto& element : path) { CKey child_key; ChainCode child_cc; bool ok{false}; @@ -107,6 +116,7 @@ bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) out.key = key; out.chaincode = chaincode; + out.parent_fingerprint = parent_fingerprint; return true; } @@ -144,10 +154,34 @@ bool ComputeECDHSecret(const CKey& key, const CPubKey& counterparty, SecureVecto return true; } +bool ComputeAccountReferenceMac(const CKey& key, const CompactXpub& compact_xpub, uint256& mac_out) +{ + if (!key.IsValid()) return false; + CHMAC_SHA256{key.begin(), key.size()}.Write(compact_xpub.data(), compact_xpub.size()).Finalize(mac_out.begin()); + return true; +} + +uint32_t AccountReferenceFromMac(const uint256& mac) +{ + // DIP-15: (version << 28) | (ASK28 ^ account). With version 0 and + // account 0, the reference is ASK28, the top 28 bits of the MAC's last + // four bytes. + return ReadBE32(mac.begin() + 28) >> 4; +} + } // namespace wallet::platformkeys namespace wallet { +bool CompactXpubBytes(const FriendshipXpub& xpub, CompactXpub& compact_out) +{ + if (!xpub.pubkey.IsCompressed()) return false; + auto it{std::copy(xpub.parent_fingerprint.begin(), xpub.parent_fingerprint.end(), compact_out.begin())}; + it = std::copy(xpub.chaincode.begin(), xpub.chaincode.end(), it); + std::copy(xpub.pubkey.begin(), xpub.pubkey.end(), it); + return true; +} + bool DeriveFriendshipPaymentDestination(const FriendshipXpub& xpub, uint32_t index, CTxDestination& destination_out) { platformkeys::ExtPubKey256 child; diff --git a/src/wallet/platformkeys.h b/src/wallet/platformkeys.h index aeeeebc59fff..4136195a80f4 100644 --- a/src/wallet/platformkeys.h +++ b/src/wallet/platformkeys.h @@ -60,11 +60,15 @@ inline constexpr uint32_t AUTH_KEY_TYPE_ECDSA{0}; inline constexpr uint32_t AUTH_KEY_TYPE_BLS{1}; //! An extended key produced by walking a (possibly 256-bit) derivation path. -//! Unlike CExtKey this does not carry BIP32 serialization metadata; DIP-14 -//! extended-key serialization tracks the path separately. +//! Unlike CExtKey this does not carry BIP32 serialization metadata beyond the +//! parent fingerprint; DIP-14 extended-key serialization tracks the path +//! separately. struct ExtKey256 { CKey key; ChainCode chaincode; + //! BIP32 fingerprint of the key the last path step was derived from + //! (Hash160 prefix of its public key); the DIP-15 compact xpub carries it. + std::array parent_fingerprint{}; ExtKey256() = default; }; @@ -100,6 +104,14 @@ Path FriendshipPath(uint32_t coin_type, uint32_t account, Span us //! encryption. Returns a 32-byte secret. [[nodiscard]] bool ComputeECDHSecret(const CKey& key, const CPubKey& counterparty, SecureVector& secret_out); +//! DIP-15 accountReference MAC (ASK): HMAC-SHA256 keyed by the sender's +//! ENCRYPTION private key over the compact xpub sent in the contact request. +[[nodiscard]] bool ComputeAccountReferenceMac(const CKey& key, const CompactXpub& compact_xpub, uint256& mac_out); + +//! The accountReference for account 0 and version 0, as DashPay iOS sends it +//! (rs-platform-encryption calculate_account_reference with iOS's ASK28 extraction). +uint32_t AccountReferenceFromMac(const uint256& mac); + } // namespace wallet::platformkeys #endif // BITCOIN_WALLET_PLATFORMKEYS_H diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 2c9e9913be00..146dff682995 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include @@ -63,8 +65,20 @@ struct FriendshipKeychainRequest { struct FriendshipXpub { CPubKey pubkey; ChainCode chaincode; + //! BIP32 fingerprint (Hash160 prefix) of the parent key, m/9'/coin'/15'/account'/. + std::array parent_fingerprint{}; }; +//! DIP-15 compact xpub: parentFingerprint(4) || chainCode(32) || pubKey(33). +//! It is the contactRequest encryptedPublicKey plaintext and the +//! accountReference MAC input, with no version, depth or child number. +inline constexpr size_t COMPACT_XPUB_SIZE{4 + 32 + 33}; +using CompactXpub = std::array; + +//! Serialize a friendship xpub in the compact form. Fails for a xpub whose +//! key is not compressed, as externally supplied contact xpubs may be. +[[nodiscard]] bool CompactXpubBytes(const FriendshipXpub& xpub, CompactXpub& compact_out); + //! Derive one contact payment destination from a DIP-15 friendship xpub. //! This is public-only key math and does not require a wallet instance. bool DeriveFriendshipPaymentDestination(const FriendshipXpub& xpub, uint32_t index, CTxDestination& destination_out); diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 791b46c785f6..033b1ae4e5a1 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -192,6 +193,8 @@ BOOST_AUTO_TEST_CASE(friendship_derivation_matches_key_wallet) "1ed00e776865c0308fddae8d22fb3ae47ffd51ce409b298d1987aafdec6bef6b"); BOOST_CHECK_EQUAL(HexStr(out.key.GetPubKey()), "025b4ff1b10b9e1990df46dfa2f986300a7e6eb4a85289f4419581950edd3760bf"); + // key-wallet's parent_fingerprint: Hash160 prefix of m/9'/1'/15'/0'/(id_a). + BOOST_CHECK_EQUAL(HexStr(out.parent_fingerprint), "a582fa1d"); } //! ECDH secrets must be symmetric and match the libsecp256k1 KDF @@ -209,6 +212,18 @@ BOOST_AUTO_TEST_CASE(ecdh_symmetry) BOOST_CHECK(s_ab == s_ba); } +//! iOS ASK28 known answer from rs-platform-encryption's +//! ask28_extraction_matches_ios_and_diverges_from_others: digest 0x00..0x1f +//! gives 0x01c1d1e1, unlike the Android and DIP-literal readings. +BOOST_AUTO_TEST_CASE(account_reference_uses_ios_ask28) +{ + uint256 mac; + for (size_t i{0}; i < mac.size(); ++i) { + *(mac.begin() + i) = static_cast(i); + } + BOOST_CHECK_EQUAL(AccountReferenceFromMac(mac), 0x01c1d1e1U); +} + //! Mnemonic behind Dip14Seed() above. static constexpr const char* DIP14_MNEMONIC{ "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; @@ -290,6 +305,8 @@ struct FriendshipWalletSetup : public TestChain100Setup { } static constexpr uint32_t ACCOUNT{0}; + //! Identity key 2 is the DashPay ENCRYPTION key. + static constexpr IdentityAuthKey ENCRYPTION_KEY{0, 2}; const uint256 m_my_id{uint256S("1111111111111111111111111111111111111111111111111111111111111111")}; const uint256 m_their_id{uint256S("2222222222222222222222222222222222222222222222222222222222222222")}; @@ -643,6 +660,56 @@ BOOST_FIXTURE_TEST_CASE(recovery_ecdh_symmetry_across_restore, SeededWalletPair) BOOST_CHECK(secret_contact_side == secret_original.value); } +//! Cross-implementation vectors generated with rs-platform-encryption and +//! rust-dashcore's key-wallet (rev e4208c90786a) from Dip14Seed(), coin type +//! 1, account 0, identity ids id_a = 000102..1f and id_b = fffefd..e0 (the +//! same friendship as friendship_derivation_matches_key_wallet): +//! - the 69-byte DIP-15 compact xpub parentFingerprint || chainCode || pubKey +//! is the contactRequest encryptedPublicKey plaintext; +//! - calculate_account_reference(ENCRYPTION key at m/9'/1'/5'/0'/0'/0'/2', +//! compact xpub, account 0, version 0) is the request's accountReference; +//! - contact payment addresses are P2PKH of xpub/i, as +//! derive_contact_payment_address computes them. +BOOST_FIXTURE_TEST_CASE(dip15_compact_xpub_account_reference_and_payment_vectors, Dip14WalletSetup) +{ + std::array id_a, id_b; + for (size_t i{0}; i < 32; ++i) { + id_a[i] = static_cast(i); + id_b[i] = static_cast(0xff - i); + } + const auto keychain{m_iface->ensureFriendshipReceivingKeychain( + FriendshipKeychainRequest{ACCOUNT, uint256{id_a}, uint256{id_b}, /*birth_time=*/0})}; + BOOST_REQUIRE(keychain); + BOOST_CHECK_EQUAL(HexStr(keychain.value.parent_fingerprint), "a582fa1d"); + + CompactXpub compact; + BOOST_CHECK(!CompactXpubBytes(FriendshipXpub{}, compact)); + BOOST_REQUIRE(CompactXpubBytes(keychain.value, compact)); + BOOST_CHECK_EQUAL(HexStr(compact), "a582fa1d" + "1ed00e776865c0308fddae8d22fb3ae47ffd51ce409b298d1987aafdec6bef6b" + "025b4ff1b10b9e1990df46dfa2f986300a7e6eb4a85289f4419581950edd3760bf"); + + const auto enc_key{m_iface->getPlatformPubKey(ENCRYPTION_KEY)}; + BOOST_REQUIRE(enc_key); + BOOST_CHECK_EQUAL(HexStr(enc_key.value), "0320e04238fc44be0b4c33768afe0b218e95986f0b77bef2caf6db39fd0c389fe4"); + + // HMAC-SHA256 digest cd9f746d8c7b362670e75fabe05ddde6b35b14fd653d5ce7bf2188da929295ac + const auto account_reference{m_iface->platformAccountReference(ENCRYPTION_KEY, compact)}; + BOOST_REQUIRE(account_reference); + BOOST_CHECK_EQUAL(account_reference.value, 0x0929295aU); + + const char* expected_addresses[]{ + "yYToW2c6ZprW2ZjfbnV8LCEjvFZWjueBEV", + "ycuANvWuH6NozPfYzERcdhuAsq7wR88TfG", + "yifetSUKVcVfgvmFWxqw3vqjA1bAGokvVJ", + }; + for (uint32_t index{0}; index < std::size(expected_addresses); ++index) { + CTxDestination destination; + BOOST_REQUIRE(DeriveFriendshipPaymentDestination(keychain.value, index, destination)); + BOOST_CHECK_EQUAL(EncodeDestination(destination), expected_addresses[index]); + } +} + //! A payment received before the loss must become spendable again once the //! restored wallet re-imports the friendship keychain: the recovery contract //! for DIP-15 funds. diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 467bb4ba432c..3db0617bea28 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4056,6 +4056,22 @@ PlatformKeyResult CWallet::PlatformECDHSecret(const IdentityAuthKe return result; } +PlatformKeyResult CWallet::PlatformAccountReference(const IdentityAuthKey& request, const CompactXpub& compact_xpub) const +{ + LOCK(cs_wallet); + PlatformKeyResult result; + platformkeys::ExtKey256 key; + uint256 mac; + result.status = DerivePlatformKey(PlatformKeyRequest{request}, key); + if (result.status != PlatformKeyStatus::SUCCESS) return result; + if (!platformkeys::ComputeAccountReferenceMac(key.key, compact_xpub, mac)) { + result.status = PlatformKeyStatus::DERIVATION_ERROR; + return result; + } + result.value = platformkeys::AccountReferenceFromMac(mac); + return result; +} + PlatformKeyResult CWallet::EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request) { LOCK(cs_wallet); @@ -4096,7 +4112,7 @@ PlatformKeyResult CWallet::EnsureFriendshipReceivingKeychain(con return result; } - result.value = {key.key.GetPubKey(), key.chaincode}; + result.value = {key.key.GetPubKey(), key.chaincode, key.parent_fingerprint}; return result; } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 4216297a7fcf..b4a3fc4f5ca4 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1111,6 +1111,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati PlatformKeyResult> SignPlatformDigest(const PlatformKeyRequest& request, const uint256& digest) const; PlatformKeyResult PlatformECDHSecret(const IdentityAuthKey& key, const CPubKey& counterparty) const; + PlatformKeyResult PlatformAccountReference(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); /** From 38c90ac1c2ecf7bb66ae9a6de31195a1e46ac833 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:46:45 -0500 Subject: [PATCH 6/8] feat(wallet): asset-lock creation and broadcast-error seams for DashPay interfaces::Wallet::createAssetLockTransaction builds, funds and signs a version 1 asset lock whose single P2PKH credit output pays the key the wallet derives for a typed funding key (registration, top-up or invitation), so the credits stay recoverable from the wallet's seed, and reports the fee it pays. Version 1 is the only payload version CheckAssetLockTx accepts before v24 and IsStandardSpecialTx relays after it. The seam goes through the asset-lock builder CreateTransactionInternal already uses for Platform recipients: CCoinControl gains m_asset_lock_version, which selects the payload version and defaults to CAssetLockPayload::CURRENT_VERSION, so sendtoaddress and every other existing caller build the same transaction and get the same error text as before. The seam requires a fully unlocked wallet: a wallet unlocked for mixing only is refused. CWallet::CommitTransaction returns an error when the transaction could not be broadcast, carrying the mempool's reason, and interfaces::Wallet::commitTransaction passes it on. The transaction stays committed to the wallet, so the caller can abandon it to release its inputs. With -walletbroadcast=0 nothing is offered to the mempool and the commit succeeds, as before. Existing callers ignore the result and behave as before. Committing a transaction the wallet holds abandoned, such as an identical retry of a refused send, first takes it back as not abandoned and spends its inputs again with ReconcileWalletUTXOs(), the transition the wallet already makes when an abandoned transaction re-enters the mempool; before, AddToWallet()'s state assertion aborted. Committing a transaction the wallet already holds as confirmed or in the mempool returns success without touching its state (it still records the commit's metadata, which it lacks if the wallet first saw it relayed or mined through another path), instead of downgrading it to inactive, which after the broadcast's already-known refusal would let the caller abandon it and release its spent inputs. A transaction the broadcast refuses because it is already in the chain (mined through another path before the wallet processed the block) is reported as committed, not rejected. The create_asset_lock_transaction case builds asset locks for a registration and a top-up funding key on a DIP0003-active regtest chain and checks that each pays its own derived key, that the reported fee is the fee the mempool charges, and that the lock passes CheckAssetLockTx on both sides of the v24 boundary; that a Platform recipient without a version selection still gets a version 2 payload; that commitTransaction accepts the lock and reports a conflicting second lock as rejected with its reason; and the zero, over-MAX_MONEY, locked, mixing-only and unlocked cases. Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 14 +++- src/wallet/coincontrol.h | 4 + src/wallet/interfaces.cpp | 35 ++++++++- src/wallet/platformtypes.h | 4 + src/wallet/spend.cpp | 18 +++-- src/wallet/test/wallet_tests.cpp | 121 +++++++++++++++++++++++++++++++ src/wallet/wallet.cpp | 58 +++++++++++++-- src/wallet/wallet.h | 4 +- 8 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 150f2198ccf2..2d38ac90e803 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -244,6 +244,15 @@ class Wallet int& change_pos, CAmount& fee) = 0; + //! Create a signed, uncommitted asset lock converting credit_amount duffs + //! into Platform credits. Its single P2PKH credit output pays the key the + //! wallet derives for funding_key, so the credits stay recoverable from + //! the wallet's seed. + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const wallet::AssetLockFundingKey& funding_key, + const wallet::CCoinControl& coin_control, + CAmount& fee) = 0; + //! Fund a transaction template per the provider-transaction funding policy: //! inputs are selected only from coins received at the single specified //! fee-source destination, change is returned to that same destination, and @@ -256,8 +265,9 @@ class Wallet //! Sign every wallet-owned input of a transaction and report whether signing is complete. virtual util::Result signTransaction(const CMutableTransaction& tx) = 0; - //! Commit transaction. - virtual void commitTransaction(CTransactionRef tx, + //! Commit transaction to the wallet and broadcast it. Returns an error + //! when it could not be broadcast; it stays committed to the wallet. + virtual util::Result commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) = 0; diff --git a/src/wallet/coincontrol.h b/src/wallet/coincontrol.h index 83257d7841a4..6bea8283783f 100644 --- a/src/wallet/coincontrol.h +++ b/src/wallet/coincontrol.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -61,6 +62,9 @@ class CCoinControl FlatSigningProvider m_external_provider; //! Controls which types of coins are allowed to be used (default: ALL_COINS) CoinType nCoinType = CoinType::ALL_COINS; + //! Payload version of the asset lock built for Platform recipients, if not + //! set CAssetLockPayload::CURRENT_VERSION + std::optional m_asset_lock_version; CCoinControl(CoinType coin_type = CoinType::ALL_COINS); diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 45cdfb5bfbc9..e6b817870e4e 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -455,6 +456,36 @@ class WalletImpl : public Wallet return txr.tx; } + util::Result createAssetLockTransaction(CAmount credit_amount, + const AssetLockFundingKey& funding_key, + const CCoinControl& coin_control, + CAmount& fee) override + { + // CreateTransaction accepts a zero Platform credit, which CheckAssetLockTx refuses, + // and bounds no amount from above before coin selection doubles it. + if (credit_amount <= 0 || !MoneyRange(credit_amount)) return util::Error{_("Invalid amount")}; + + LOCK(m_wallet->cs_wallet); + // A wallet unlocked only for mixing keeps its keys but must not spend. + if (m_wallet->IsLocked()) { + return util::Error{_("Please enter the wallet passphrase with walletpassphrase first.")}; + } + const auto funding_pubkey{m_wallet->GetPlatformPubKey( + std::visit([](const auto& key) { return PlatformKeyRequest{key}; }, funding_key))}; + if (!funding_pubkey) return util::Error{_("Could not derive the asset lock funding key")}; + + // Version 1 stays consensus-valid after v24 and is the only version the + // mempool relays today (IsStandardSpecialTx). + CCoinControl version_1_control{coin_control}; + version_1_control.m_asset_lock_version = CAssetLockPayload::INITIAL_VERSION; + auto res{CreateTransaction(*m_wallet, + {CRecipient{GetScriptForDestination(PKHash{funding_pubkey.value}), credit_amount, + /*fSubtractFeeFromAmount=*/false, /*fPlatformTransfer=*/true}}, + RANDOM_CHANGE_POSITION, version_1_control, /*sign=*/true)}; + if (!res) return util::Error{util::ErrorString(res)}; + fee = res->fee; + return res->tx; + } util::Result fundTransaction(const CMutableTransaction& tx_template, const CTxDestination& fund_destination) override { @@ -538,12 +569,12 @@ class WalletImpl : public Wallet errors.push_back(std::move(error)); return WalletTxSignResult{MakeTransactionRef(std::move(signed_tx)), complete, std::move(errors)}; } - void commitTransaction(CTransactionRef tx, + util::Result commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) override { LOCK(m_wallet->cs_wallet); - m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); + return m_wallet->CommitTransaction(std::move(tx), std::move(value_map), std::move(order_form)); } bool transactionCanBeAbandoned(const uint256& txid) override { return m_wallet->TransactionCanBeAbandoned(txid); } bool transactionCanBeResent(const uint256& txid) override { return m_wallet->TransactionCanBeResent(txid); } diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 146dff682995..64aeb0b3f21e 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -55,6 +55,10 @@ struct InvitationFundingKey { using PlatformKeyRequest = std::variant; +//! The keys an asset lock's credit output may pay: every one is derived from +//! the wallet's seed, so the credits it funds can be recovered. +using AssetLockFundingKey = std::variant; + struct FriendshipKeychainRequest { uint32_t account{0}; uint256 local_id; diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index e3721a2c8238..1f9d9fbc4da3 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -810,7 +810,7 @@ static util::Result CreateTransactionInternal( } } - opt_assetLockPayload.emplace(creditOutputs, CAssetLockPayload::CURRENT_VERSION); + opt_assetLockPayload.emplace(creditOutputs, coin_control.m_asset_lock_version.value_or(CAssetLockPayload::CURRENT_VERSION)); CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); ss << *opt_assetLockPayload; nExtraPayloadSize = ss.size(); @@ -1130,13 +1130,17 @@ static util::Result CreateTransactionInternal( return util::Error{_("Transaction too large")}; } - // An asset lock for Platform recipients carries payload version 2, which is - // deliberately non-standard until Platform can process it. Returning such a - // transaction hands the caller a txid for something the local mempool drops on - // submission and that therefore never relays, so fail before it is committed. + // An asset lock for Platform recipients carries payload version 2 by default, + // which is deliberately non-standard until Platform can process it. Returning + // a non-standard transaction hands the caller a txid for something the local + // mempool drops on submission and that therefore never relays, so fail before + // it is committed. if (std::string reason; opt_assetLockPayload && wallet.chain().isNonStandardSpecialTx(tx, reason)) { - return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay; sending to Platform " - "addresses currently requires a node started with -acceptnonstdtxn=1"), reason)}; + if (opt_assetLockPayload->getVersion() == CAssetLockPayload::CURRENT_VERSION) { + return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay; sending to Platform " + "addresses currently requires a node started with -acceptnonstdtxn=1"), reason)}; + } + return util::Error{strprintf(_("Transaction is non-standard (%s) and would not relay"), reason)}; } if (current_fee > wallet.m_default_max_tx_fee) { diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index 9e7e340b3e83..cd80bb62dc17 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -15,9 +15,13 @@ #include #include #include +#include +#include #include #include +#include #include +#include #include #include #include @@ -811,6 +815,123 @@ BOOST_FIXTURE_TEST_CASE(BasicOutputTypesTest, ListCoinsTestingSetup) } } +//! Special transactions are only accepted once DIP0003 is active, which the +//! default regtest parameters place far beyond the pinned fixture chains. The +//! same chain as the evo tests: activation at 109, tip at 108. +struct AssetLockTestingSetup : public TestChainSetup { + AssetLockTestingSetup() : TestChainSetup{107, CBaseChainParams::REGTEST, {"-dip3params=109:500"}} + { + CreateAndProcessBlock({}, GetScriptForRawPubKey(coinbaseKey.GetPubKey())); + wallet = CreateSyncedWallet(*m_node.chain, *Assert(m_node.chainman), coinbaseKey); + } + + ~AssetLockTestingSetup() { wallet.reset(); } + + std::unique_ptr wallet; +}; + +//! An asset lock built through the wallet interface must be a consensus-valid +//! special transaction the local mempool accepts, paying the key the wallet +//! derives for the requested funding key at the fee it reports, and +//! commitTransaction must report a mempool rejection with its reason. +BOOST_FIXTURE_TEST_CASE(create_asset_lock_transaction, AssetLockTestingSetup) +{ + WalletContext context; + context.args = &m_args; + context.chain = m_node.chain.get(); + const auto wallet_ref{std::shared_ptr(wallet.get(), [](CWallet*) {})}; + auto iface{interfaces::MakeWallet(context, wallet_ref)}; + wallet->SetBroadcastTransactions(true); + + const RegistrationFundingKey registration_key{0}; + const TopupFundingKey topup_key{0}; + const auto registration_pubkey{iface->getPlatformPubKey(registration_key)}; + const auto topup_pubkey{iface->getPlatformPubKey(topup_key)}; + BOOST_REQUIRE(registration_pubkey && topup_pubkey); + BOOST_REQUIRE(registration_pubkey.value != topup_pubkey.value); + const CAmount credit_amount{COIN}; + CCoinControl coin_control; + CAmount fee{0}; + + const auto zero_amount{iface->createAssetLockTransaction(0, registration_key, coin_control, fee)}; + BOOST_REQUIRE(!zero_amount); + // Refused like a zero amount, before coin selection, not as insufficient funds. + const auto over_max_money{iface->createAssetLockTransaction(MAX_MONEY + 1, registration_key, coin_control, fee)}; + BOOST_REQUIRE(!over_max_money); + BOOST_CHECK_EQUAL(util::ErrorString(over_max_money).original, util::ErrorString(zero_amount).original); + + const auto res{iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)}; + BOOST_REQUIRE_MESSAGE(res, util::ErrorString(res).original); + const CTransactionRef& tx{*res}; + BOOST_CHECK_EQUAL(tx->nVersion, 3); + BOOST_CHECK_EQUAL(tx->nType, TRANSACTION_ASSET_LOCK); + + const auto payload{GetTxPayload(*tx)}; + BOOST_REQUIRE(payload); + BOOST_CHECK_EQUAL(payload->getVersion(), CAssetLockPayload::INITIAL_VERSION); + BOOST_REQUIRE_EQUAL(payload->getCreditOutputs().size(), 1U); + BOOST_CHECK_EQUAL(payload->getCreditOutputs()[0].nValue, credit_amount); + BOOST_CHECK(payload->getCreditOutputs()[0].scriptPubKey == GetScriptForDestination(PKHash{registration_pubkey.value})); + + // Each funding key request pays its own wallet-derived key. + CAmount topup_fee{0}; + const auto topup{iface->createAssetLockTransaction(credit_amount, topup_key, coin_control, topup_fee)}; + BOOST_REQUIRE_MESSAGE(topup, util::ErrorString(topup).original); + const auto topup_payload{GetTxPayload(**topup)}; + BOOST_REQUIRE(topup_payload); + BOOST_REQUIRE_EQUAL(topup_payload->getCreditOutputs().size(), 1U); + BOOST_CHECK(topup_payload->getCreditOutputs()[0].scriptPubKey == GetScriptForDestination(PKHash{topup_pubkey.value})); + + TxValidationState tx_state; + BOOST_CHECK_MESSAGE(CheckAssetLockTx(*tx, tx_state, /*is_v24_active=*/false), tx_state.ToString()); + BOOST_CHECK_MESSAGE(CheckAssetLockTx(*tx, tx_state, /*is_v24_active=*/true), tx_state.ToString()); + + // Platform recipients without a version selection still get the current + // payload version, which the standard mempool refuses to relay. + const auto default_version{CreateTransaction(*wallet, + {CRecipient{GetScriptForDestination(PKHash{registration_pubkey.value}), credit_amount, + /*fSubtractFeeFromAmount=*/false, /*fPlatformTransfer=*/true}}, + RANDOM_CHANGE_POSITION, CCoinControl{}, /*sign=*/true)}; + BOOST_REQUIRE(!default_version); + BOOST_CHECK(util::ErrorString(default_version).original.find("assetlocktx-version-2") != std::string::npos); + BOOST_CHECK(util::ErrorString(default_version).original.find("-acceptnonstdtxn=1") != std::string::npos); + + // Both spend the same coin: the first reaches the mempool, the second is + // a conflict the wallet must report and let the caller abandon. + CCoinControl same_input; + same_input.Select(tx->vin[0].prevout); + same_input.m_allow_other_inputs = false; + CAmount conflicting_fee{0}; + const auto conflicting{iface->createAssetLockTransaction(credit_amount, registration_key, same_input, conflicting_fee)}; + BOOST_REQUIRE_MESSAGE(conflicting, util::ErrorString(conflicting).original); + const auto accepted{iface->commitTransaction(tx, {}, {})}; + BOOST_CHECK_MESSAGE(accepted, util::ErrorString(accepted).original); + BOOST_CHECK(m_node.mempool->exists(tx->GetHash())); + // The reported fee is the one the mempool charges. + BOOST_CHECK_GT(fee, 0); + BOOST_CHECK_EQUAL(m_node.mempool->info(tx->GetHash()).fee, fee); + + const auto rejected{iface->commitTransaction(*conflicting, {}, {})}; + BOOST_REQUIRE(!rejected); + BOOST_CHECK(util::ErrorString(rejected).original.find("txn-mempool-conflict") != std::string::npos); + BOOST_CHECK(!m_node.mempool->exists((*conflicting)->GetHash())); + BOOST_CHECK(iface->abandonTransaction((*conflicting)->GetHash())); + + // An encrypted wallet builds asset locks only when fully unlocked: not + // while locked, and not while unlocked for mixing only. + const SecureString passphrase{"asset lock"}; + BOOST_REQUIRE(wallet->EncryptWallet(passphrase)); + BOOST_REQUIRE(wallet->IsLocked()); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + BOOST_REQUIRE(wallet->Unlock(passphrase, /*fForMixingOnly=*/true)); + BOOST_REQUIRE(wallet->IsLocked() && !wallet->IsLocked(/*fForMixing=*/true)); + BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + BOOST_REQUIRE(wallet->Lock()); + BOOST_REQUIRE(wallet->Unlock(passphrase)); + const auto unlocked{iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)}; + BOOST_CHECK_MESSAGE(unlocked, util::ErrorString(unlocked).original); +} + BOOST_FIXTURE_TEST_CASE(wallet_disableprivkeys, TestChain100Setup) { { diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 3db0617bea28..69d570be7530 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2487,14 +2487,13 @@ bool CWallet::SignGovernanceVote(const CKeyID& keyID, CGovernanceVote& vote) con return true; } -void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm) +util::Result CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm) { LOCK(cs_wallet); WalletLogPrintf("CommitTransaction:\n%s", tx->ToString()); /* Continued */ - // Add tx to wallet, because if it has change it's also ours, - // otherwise just for transaction history. - CWalletTx* wtx = AddToWallet(tx, TxStateInactive{}, [&](CWalletTx& wtx, bool new_tx) { + // What this commit records about the transaction. + const auto record_commit{[&](CWalletTx& wtx, bool new_tx) { CHECK_NONFATAL(wtx.mapValue.empty()); CHECK_NONFATAL(wtx.vOrderForm.empty()); wtx.mapValue = std::move(mapValue); @@ -2502,7 +2501,42 @@ void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve wtx.fTimeReceivedIsTxTime = true; wtx.fFromMe = true; return true; - }); + }}; + + // A transaction the wallet already holds as confirmed or in the mempool was + // sent. Committing it again must not hand AddToWallet() an inactive state, + // which would undo that and, once the broadcast is refused as already + // known, let the caller abandon it and release its spent inputs. It still + // gets this commit's record, which it lacks if the wallet first saw it + // relayed or mined through another path. + if (const auto it{mapWallet.find(tx->GetHash())}; it != mapWallet.end() && (it->second.isConfirmed() || it->second.InMempool())) { + CWalletTx& sent{it->second}; + sent.mapValue.clear(); + sent.vOrderForm.clear(); + record_commit(sent, /*new_tx=*/false); + if (!WalletBatch{GetDatabase()}.WriteTx(sent)) { + throw std::runtime_error(std::string(__func__) + ": Wallet db error, transaction commit failed"); + } + NotifyTransactionChanged(sent.GetHash(), CT_UPDATED); + return {}; + } + + // Committing a transaction the wallet holds abandoned, such as an identical + // retry of a send whose broadcast was refused, takes it back as if new: its + // inputs are spent again, as when an abandoned transaction re-enters the + // mempool, and AddToWallet() finds the state it is given. + if (const auto it{mapWallet.find(tx->GetHash())}; it != mapWallet.end() && it->second.isAbandoned()) { + CWalletTx& abandoned{it->second}; + abandoned.m_state = TxStateInactive{}; + abandoned.mapValue.clear(); + abandoned.vOrderForm.clear(); + WalletBatch batch{GetDatabase()}; + ReconcileWalletUTXOs(abandoned.tx, batch); + } + + // Add tx to wallet, because if it has change it's also ours, + // otherwise just for transaction history. + CWalletTx* wtx = AddToWallet(tx, TxStateInactive{}, record_commit); // wtx can only be null if the db write failed. if (!wtx) { @@ -2523,14 +2557,26 @@ void CWallet::CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::ve if (!fBroadcastTransactions) { // Don't submit tx to the mempool - return; + return {}; } bilingual_str err_string; if (!SubmitTxMemoryPoolAndRelay(*wtx, err_string, true)) { + // A transaction already in the chain, e.g. mined through another path + // before this wallet processed its block, was sent. BroadcastTransaction + // refuses it without a reason, having found its outputs. + if (err_string.empty()) { + std::map outputs; + for (uint32_t i{0}; i < tx->vout.size(); ++i) outputs.emplace(COutPoint{tx->GetHash(), i}, Coin{}); + chain().findCoins(outputs); + if (std::any_of(outputs.begin(), outputs.end(), [](const auto& output) { return !output.second.IsSpent(); })) return {}; + } WalletLogPrintf("CommitTransaction(): Transaction cannot be broadcast immediately, %s\n", err_string.original); // TODO: if we expect the failure to be long term or permanent, instead delete wtx from the wallet and return failure. + // Not every refusal carries a reason, e.g. the -maxtxfee check in BroadcastTransaction. + return util::Error{err_string.empty() ? _("Transaction could not be broadcast") : err_string}; } + return {}; } DBErrors CWallet::LoadWallet() diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index b4a3fc4f5ca4..da5c42751130 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -847,8 +847,10 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati * @param[in] tx The transaction to be broadcast. * @param[in] mapValue key-values to be set on the transaction. * @param[in] orderForm BIP 70 / BIP 21 order form details to be set on the transaction. + * @return an error when the transaction could not be broadcast. It stays committed to + * the wallet either way, so the caller may abandon it to release its inputs. */ - void CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm); + util::Result CommitTransaction(CTransactionRef tx, mapValue_t mapValue, std::vector> orderForm); /** Will SubmitTxMemoryPoolAndRelay() consider wtx if supplied */ bool CanTxBeResent(const CWalletTx& wtx) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); From d9eab7e3e613a94b1e4decc6f2ead824e3af4faf Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:52:25 -0500 Subject: [PATCH 7/8] feat(wallet): Platform key eligibility, pruned-rescan start and a movable UnlockContext interfaces::Wallet::platformKeySourceStatus tells the GUI whether the wallet can derive Platform keys at all (a descriptor wallet with private keys and a single HD root) without deriving one, so it also answers while the wallet is locked, where getPlatformPubKey reports WALLET_LOCKED. interfaces::Wallet::rescanPrunedFrom returns the height a rescan from the birth of the wallet's first key would start at when this node has pruned blocks it would need to read, and nullopt when it has them all. startRescan(false) computes its start height through the same helper. WalletModel::UnlockContext becomes movable, so an unlock can be held across an asynchronous operation; the moved-from context no longer relocks, so the wallet relocks once, when its last owner goes away. Tests: platformKeySourceStatus rows in the ambiguous-source, child-xprv and legacy-wallet scenarios, and on a locked wallet in create_asset_lock_transaction; rescanPrunedFrom before and after pruning the oldest block file in scan_for_wallet_transactions; and WalletTests::unlockContextMove, which hands an unlock to a second owner and checks the wallet stays unlocked until that owner is gone. Co-Authored-By: Claude Opus 5.5 --- src/interfaces/wallet.h | 9 +++++++ src/qt/test/wallettests.cpp | 28 +++++++++++++++++++++ src/qt/test/wallettests.h | 1 + src/qt/walletmodel.cpp | 9 +++++++ src/qt/walletmodel.h | 10 ++++---- src/wallet/interfaces.cpp | 35 ++++++++++++++++++-------- src/wallet/test/platformkeys_tests.cpp | 4 +++ src/wallet/test/wallet_tests.cpp | 23 +++++++++++++++++ src/wallet/wallet.cpp | 7 ++++++ src/wallet/wallet.h | 2 ++ 10 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 2d38ac90e803..7f08ba98b510 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -115,6 +116,10 @@ class Wallet //! Abort a rescan. virtual void abortRescan() = 0; + //! The height startRescan(false) starts at, when this node has pruned + //! blocks it would read; nullopt when it has them all. + virtual std::optional rescanPrunedFrom() = 0; + //! Lock masternode collaterals virtual void autoLockMasternodeCollaterals() = 0; @@ -142,6 +147,10 @@ class Wallet //! Sign special transaction payload virtual bool signSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) = 0; + //! Whether this wallet can derive Platform keys: SUCCESS for a descriptor + //! wallet with private keys and a single HD root, whether locked or not. + virtual wallet::PlatformKeyStatus platformKeySourceStatus() = 0; + //! Derive and return a public Platform key. The mnemonic-owning key //! manager performs derivation without exposing the wallet's root seed. virtual wallet::PlatformKeyResult getPlatformPubKey(const wallet::PlatformKeyRequest& request) = 0; diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index f31adf43f1d9..768692e6759b 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include @@ -321,3 +322,30 @@ void WalletTests::walletTests() #endif TestGUI(m_node); } + +//! An unlock handed from one owner to another relocks the wallet once, when +//! its last owner goes away. +void WalletTests::unlockContextMove() +{ + TestChain100Setup test; + m_node.setContext(&test.m_node); + WalletContext& context{*m_node.walletLoader().context()}; + const auto wallet{MasternodeTestUtil::MakeCoinbaseWallet(m_node, context, test, "unlock", /*encrypt=*/true)}; + QVERIFY(wallet != nullptr); + MasternodeTestUtil::WalletGuard guard{context, wallet}; + MasternodeTestUtil::GuiModels models{m_node}; + QVERIFY(models.ok); + WalletModel wallet_model(interfaces::MakeWallet(context, wallet), models.client); + connect(&wallet_model, &WalletModel::requireUnlock, [&wallet_model] { + wallet_model.setWalletLocked(/*locked=*/false, "test passphrase"); + }); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Locked); + + // emplace moves the returned context; the moved-from one must not relock. + std::optional held; + held.emplace(wallet_model.requestUnlock()); + QVERIFY(held->isValid()); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Unlocked); + held.reset(); + QCOMPARE(wallet_model.getEncryptionStatus(), WalletModel::Locked); +} diff --git a/src/qt/test/wallettests.h b/src/qt/test/wallettests.h index 6044bedb1d0f..5cf843782b20 100644 --- a/src/qt/test/wallettests.h +++ b/src/qt/test/wallettests.h @@ -22,6 +22,7 @@ class WalletTests : public QObject private Q_SLOTS: void walletTests(); + void unlockContextMove(); }; #endif // BITCOIN_QT_TEST_WALLETTESTS_H diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index aa7b61d89d8f..5e146b6106dc 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -618,6 +618,15 @@ WalletModel::UnlockContext::UnlockContext(WalletModel *_wallet, bool _valid, boo { } +WalletModel::UnlockContext::UnlockContext(UnlockContext&& other) noexcept : + wallet(other.wallet), + valid(other.valid), + was_locked(other.was_locked), + was_mixing(other.was_mixing) +{ + other.valid = false; +} + WalletModel::UnlockContext::~UnlockContext() { if(valid && (was_locked || was_mixing)) diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 61666da45cb4..c540b0a0a2e4 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -123,17 +123,17 @@ class WalletModel : public QObject bool isValid() const { return valid; } - // Disable unused copy/move constructors/assignments explicitly. + // The moved-from context no longer relocks: exactly one owner does. + UnlockContext(UnlockContext&& other) noexcept; UnlockContext(const UnlockContext&) = delete; - UnlockContext(UnlockContext&&) = delete; UnlockContext& operator=(const UnlockContext&) = delete; UnlockContext& operator=(UnlockContext&&) = delete; private: WalletModel *wallet; - const bool valid; - const bool was_locked; - const bool was_mixing; + bool valid; + bool was_locked; + bool was_mixing; }; UnlockContext requestUnlock(bool fForMixingOnly = false); diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index e6b817870e4e..f03fd57b3d22 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -188,20 +188,24 @@ class WalletImpl : public Wallet { return m_wallet->ChangeWalletPassphrase(old_wallet_passphrase, new_wallet_passphrase); } - wallet::RescanStatus startRescan(bool from_genesis) override + //! Where a rescan from the birth of the wallet's first key starts. + int rescanStartHeight() { int rescan_height{0}; - if (!from_genesis) { - std::optional time_first_key; - for (auto spk_man : m_wallet->GetAllScriptPubKeyMans()) { - int64_t time = spk_man->GetTimeFirstKey(); - if (!time_first_key || time < *time_first_key) time_first_key = time; - } - if (time_first_key) { - m_wallet->chain().findFirstBlockWithTimeAndHeight(*time_first_key - TIMESTAMP_WINDOW, rescan_height, - FoundBlock().height(rescan_height)); - } + std::optional time_first_key; + for (auto spk_man : m_wallet->GetAllScriptPubKeyMans()) { + int64_t time = spk_man->GetTimeFirstKey(); + if (!time_first_key || time < *time_first_key) time_first_key = time; } + if (time_first_key) { + m_wallet->chain().findFirstBlockWithTimeAndHeight(*time_first_key - TIMESTAMP_WINDOW, rescan_height, + FoundBlock().height(rescan_height)); + } + return rescan_height; + } + wallet::RescanStatus startRescan(bool from_genesis) override + { + const int rescan_height{from_genesis ? 0 : rescanStartHeight()}; WalletRescanReserver reserver(*m_wallet); if (!reserver.reserve()) { @@ -220,6 +224,14 @@ class WalletImpl : public Wallet return wallet::RescanStatus::FAILURE; // fallback for release builds } void abortRescan() override { m_wallet->AbortRescan(); } + std::optional rescanPrunedFrom() override + { + if (!m_wallet->chain().havePruned()) return std::nullopt; + LOCK(m_wallet->cs_wallet); + const int rescan_height{rescanStartHeight()}; + if (m_wallet->chain().hasBlocks(m_wallet->GetLastBlockHash(), rescan_height)) return std::nullopt; + return rescan_height; + } void autoLockMasternodeCollaterals() override { m_wallet->AutoLockMasternodeCollaterals(); } bool backupWallet(const std::string& filename) override { return m_wallet->BackupWallet(filename); } bool autoBackupWallet(const fs::path& wallet_path, bilingual_str& error_string, std::vector& warnings) override @@ -249,6 +261,7 @@ class WalletImpl : public Wallet { return m_wallet->SignSpecialTxPayload(hash, keyid, vchSig); } + wallet::PlatformKeyStatus platformKeySourceStatus() override { return m_wallet->GetPlatformKeySourceStatus(); } wallet::PlatformKeyResult getPlatformPubKey(const wallet::PlatformKeyRequest& request) override { return m_wallet->GetPlatformPubKey(request); diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 033b1ae4e5a1..6c7631b6537a 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -377,6 +377,7 @@ BOOST_FIXTURE_TEST_CASE(platform_key_source_must_be_unambiguous, Dip14WalletSetu { const auto before{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_REQUIRE(before); + BOOST_CHECK(m_iface->platformKeySourceStatus() == PlatformKeyStatus::SUCCESS); // The internal hook must not permit an empty path to expose the BIP32 // master key, even though the typed public requests cannot form one. @@ -407,6 +408,7 @@ BOOST_FIXTURE_TEST_CASE(platform_key_source_must_be_unambiguous, Dip14WalletSetu const auto ambiguous{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(ambiguous.status == PlatformKeyStatus::AMBIGUOUS_SOURCE); + BOOST_CHECK(m_iface->platformKeySourceStatus() == PlatformKeyStatus::AMBIGUOUS_SOURCE); } //! An imported root xprv is sufficient for Platform derivation. The mnemonic @@ -433,6 +435,7 @@ BOOST_FIXTURE_TEST_CASE(child_xprv_has_no_platform_keys, FriendshipWalletSetup) const auto [wallet, iface]{MakeXprvWallet(child)}; const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); } //! A root source is the complete extended public key, including its chain @@ -525,6 +528,7 @@ BOOST_FIXTURE_TEST_CASE(legacy_wallet_has_no_platform_keys, FriendshipWalletSetu auto iface = interfaces::MakeWallet(m_context, wallet); const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); } //! Two independent wallet instances restored from the same recovery phrase: diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp index cd80bb62dc17..f5a379ddd896 100644 --- a/src/wallet/test/wallet_tests.cpp +++ b/src/wallet/test/wallet_tests.cpp @@ -266,6 +266,25 @@ BOOST_FIXTURE_TEST_CASE(scan_for_wallet_transactions, TestChain100Setup) } } + // Where a rescan from the birth of a wallet holding a genesis-era key + // would need blocks this node no longer has. + const auto rescan_pruned_from{[&] { + const auto wallet{std::make_shared(m_node.chain.get(), "", CreateMockWalletDatabase())}; + { + LOCK(wallet->cs_wallet); + wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS); + wallet->SetLastBlockProcessed(newTip->nHeight, newTip->GetBlockHash()); + } + AddKey(*wallet, coinbaseKey); + WalletContext context; + return interfaces::MakeWallet(context, wallet)->rescanPrunedFrom(); + }}; + BOOST_CHECK(!rescan_pruned_from()); + // A pruning node that still has every block the rescan reads. The flag is + // set as Chainstate::FlushStateToDisk does once it prunes. + WITH_LOCK(::cs_main, m_node.chainman->m_blockman.m_have_pruned = true); + BOOST_CHECK(!rescan_pruned_from()); + // Prune the older block file. int file_number; { @@ -274,6 +293,7 @@ BOOST_FIXTURE_TEST_CASE(scan_for_wallet_transactions, TestChain100Setup) Assert(m_node.chainman)->m_blockman.PruneOneBlockFile(file_number); } UnlinkPrunedFiles({file_number}); + BOOST_CHECK(rescan_pruned_from() == std::optional{0}); // Verify ScanForWalletTransactions only picks transactions in the new block // file. @@ -923,6 +943,9 @@ BOOST_FIXTURE_TEST_CASE(create_asset_lock_transaction, AssetLockTestingSetup) BOOST_REQUIRE(wallet->EncryptWallet(passphrase)); BOOST_REQUIRE(wallet->IsLocked()); BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); + // A locked wallet can still tell whether it is able to fund an identity. + BOOST_CHECK(iface->getPlatformPubKey(registration_key).status == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::SUCCESS); BOOST_REQUIRE(wallet->Unlock(passphrase, /*fForMixingOnly=*/true)); BOOST_REQUIRE(wallet->IsLocked() && !wallet->IsLocked(/*fForMixing=*/true)); BOOST_CHECK(!iface->createAssetLockTransaction(credit_amount, registration_key, coin_control, fee)); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 69d570be7530..9c28f611f528 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4060,6 +4060,13 @@ PlatformKeyStatus CWallet::DerivePlatformKey(const PlatformKeyRequest& request, return DerivePlatformKey(platformkeys::PlatformKeyPath(static_cast(Params().ExtCoinType()), request), out); } +PlatformKeyStatus CWallet::GetPlatformKeySourceStatus() const +{ + LOCK(cs_wallet); + const ScriptPubKeyMan* source{nullptr}; + return GetPlatformKeySource(source); +} + PlatformKeyResult CWallet::GetPlatformPubKey(const PlatformKeyRequest& request) const { LOCK(cs_wallet); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index da5c42751130..ee16c533d2de 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1109,6 +1109,8 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati /** All Platform data records whose key starts with prefix. */ std::map> GetPlatformData(const std::string& prefix) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Whether Platform keys can be derived, without deriving one (works while locked). */ + PlatformKeyStatus GetPlatformKeySourceStatus() const; PlatformKeyResult GetPlatformPubKey(const PlatformKeyRequest& request) const; PlatformKeyResult> SignPlatformDigest(const PlatformKeyRequest& request, const uint256& digest) const; From 972b4adb20d1c1da8950930b759c61a9ded123c7 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 02:29:04 -0500 Subject: [PATCH 8/8] 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 | 327 +++++++++++++++++++++++++++++ src/platform/client.h | 127 +++++++++++ src/platform/marshal.cpp | 209 ++++++++++++++++++ src/platform/marshal.h | 33 +++ src/platform/signer.cpp | 93 ++++++++ src/platform/signer.h | 46 ++++ src/platform/types.h | 173 +++++++++++++++ src/test/platform_client_tests.cpp | 323 ++++++++++++++++++++++++++++ src/test/util/platform_client.cpp | 110 ++++++++++ src/test/util/platform_client.h | 79 +++++++ test/util/data/non-backported.txt | 4 + 16 files changed, 1564 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 d002e2e4fe03..158c786ea670 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 @@ -720,6 +724,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 53eec4a79bde..f4502084137d 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 28474503589a..b0012ab93497 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -226,6 +226,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 \ @@ -272,6 +276,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..3e89f7fc4bab --- /dev/null +++ b/src/platform/client.cpp @@ -0,0 +1,327 @@ +// 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::FromFfi(m_sdk->contact_requests(identity, ffi_direction, 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..f11b5030ffb8 --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,127 @@ +// 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; + //! Best effort: the SDK examines at most 100 contests. + virtual void pendingContests(const Identifier& identity, Callback> cb) = 0; + //! Contact requests sent by or to identity, created strictly after + //! since_ms (0: all). + 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..a1cef4caf9d6 --- /dev/null +++ b/src/platform/marshal.cpp @@ -0,0 +1,209 @@ +// 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 + +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 = request.sender_key_index; + out.recipient_key_index = request.recipient_key_index; + out.account_reference = 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; +} + +} // 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> FromFfi(const dash_sdk::ContactRequestsResult& result) +{ + return {FromFfi(result.status), Each(result.requests)}; +} + +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..64e414fba8e2 --- /dev/null +++ b/src/platform/marshal.h @@ -0,0 +1,33 @@ +// 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 + +//! 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); +Result> FromFfi(const dash_sdk::ContactRequestsResult& result); + +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..8abb3b136cbc --- /dev/null +++ b/src/test/platform_client_tests.cpp @@ -0,0 +1,323 @@ +// 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 + +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::CreateMockWalletDatabase()); + 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)); +} + +//! 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 776d03b21d54..6297cb3ae6e1 100644 --- a/test/util/data/non-backported.txt +++ b/test/util/data/non-backported.txt @@ -35,6 +35,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.* @@ -92,10 +94,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.*