diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8332aa110e9..f0a81b1a82e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -138,8 +138,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 }} @@ -170,6 +169,17 @@ jobs: base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + 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'] }} + depends-mac: name: x86_64-apple-darwin uses: ./.github/workflows/build-depends.yml @@ -285,15 +295,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'] }} src-linux64_tsan: diff --git a/.gitignore b/.gitignore index eab9f3f83ec1..a192dc147b05 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ share/qt/Info.plist src/qt/*.moc src/qt/moc_*.cpp +src/qt/platform/moc_*.cpp src/qt/forms/ui_*.h src/qt/test/moc*.cpp diff --git a/ci/dash/build_src.sh b/ci/dash/build_src.sh index 2184966c28eb..0e3b750f3dfd 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-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..57178ceaef8b --- /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 the Dash Platform CXX bindings; 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/configure.ac b/configure.ac index edfb87a1db61..e979eaa4c748 100644 --- a/configure.ac +++ b/configure.ac @@ -301,6 +301,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 CXX bindings 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 the Dash Platform CXX bindings]) +AC_ARG_VAR([PLATFORM_CXX_LIBS], [Linker flags for the Dash Platform CXX bindings library (default: -ldash_platform_cxx); the system libraries it needs are always appended]) + dnl Enable different -fsanitize options AC_ARG_WITH([sanitizers], [AS_HELP_STRING([--with-sanitizers], @@ -1936,6 +1944,54 @@ 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 The Dash Platform CXX bindings are a Rust static library linked into dash-qt +dnl (and its tests) only. PLATFORM_CXX_LIBS names the library (default +dnl -ldash_platform_cxx, from the depends prefix); the system libraries rustc +dnl reports for the archive (--print native-static-libs), less the C++ runtime, +dnl are appended to it. +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 + if test -z "$PLATFORM_CXX_LIBS"; then + PLATFORM_CXX_LIBS="-ldash_platform_cxx" + fi + case $host in + *darwin*) + PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -framework Security -framework CoreFoundation" + ;; + *mingw*) + PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -lbcrypt -ladvapi32 -lkernel32 -lntdll -luserenv -lws2_32 -ldbghelp" + ;; + *) + PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -lpthread -ldl -lm" + ;; + esac + TEMP_CPPFLAGS="$CPPFLAGS" + TEMP_LIBS="$LIBS" + CPPFLAGS="$CPPFLAGS $PLATFORM_CXX_CFLAGS" + LIBS="$PLATFORM_CXX_LIBS $LIBS" + AC_LANG_PUSH([C++]) + AC_MSG_CHECKING([for the Dash Platform CXX bindings]) + AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + ]], [[ + rust::Box client{platform_ffi::new_platform_client(platform_ffi::Config{})}; + client->shutdown(); + ]])], + [AC_MSG_RESULT([yes])], + [AC_MSG_RESULT([no]) + AC_MSG_ERROR([--enable-platform-gui requires the Dash Platform CXX bindings (build depends with PLATFORM_GUI=1)])]) + 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"]) @@ -2132,6 +2188,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/contrib/devtools/README.md b/contrib/devtools/README.md index c7dbad85e8be..a2c7479f9b73 100644 --- a/contrib/devtools/README.md +++ b/contrib/devtools/README.md @@ -193,3 +193,37 @@ 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. + +platform-bundle.sh +================== + +Produces the crate bundle that `depends/packages/platform_cxx.mk` builds the +Dash Platform CXX bindings from, 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 + +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 +the Dash Platform CXX bindings. diff --git a/contrib/devtools/check-no-rust.py b/contrib/devtools/check-no-rust.py new file mode 100755 index 000000000000..9a04c27de743 --- /dev/null +++ b/contrib/devtools/check-no-rust.py @@ -0,0 +1,63 @@ +#!/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. + +The Dash Platform CXX bindings (--enable-platform-gui) are 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() diff --git a/contrib/devtools/platform-bundle.sh b/contrib/devtools/platform-bundle.sh new file mode 100755 index 000000000000..8b45ee15cb49 --- /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-platform-cxx from, for a given dashpay/platform commit. +# +# The bundle holds a Cargo workspace trimmed to packages/rs-platform-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-platform-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 platform-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-platform-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-platform-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="platform-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 < 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/contrib/guix/symbol-check.py b/contrib/guix/symbol-check.py index bca9029b24d8..440b2714ec91 100755 --- a/contrib/guix/symbol-check.py +++ b/contrib/guix/symbol-check.py @@ -171,6 +171,16 @@ 'VERSION.dll', # version checking 'WINMM.dll', # WinMM audio API 'WTSAPI32.dll', # Remote Desktop +# dash-qt only, and only with --enable-platform-gui (the Dash Platform CXX +# bindings); dash-qt without it, dashd and the tools never import these. +# The list is shared by every binary, so this is not enforced here: CI runs +# contrib/devtools/check-no-rust.py on the other binaries instead. +'api-ms-win-core-synch-l1-2-0.dll', # dash-qt: WaitOnAddress (Rust standard library) +'bcryptprimitives.dll', # dash-qt: ProcessPrng (Rust standard library) +'CRYPT32.dll', # dash-qt: system trust store (rustls-native-certs via schannel) +'ncrypt.dll', # dash-qt: CNG key storage (schannel) +'ntdll.dll', # dash-qt: NT native API (Rust standard library, mio) +'Secur32.dll', # dash-qt: SSPI (schannel) } def check_version(max_versions, version, arch) -> bool: @@ -251,8 +261,10 @@ def check_MACHO_lld(binary) -> bool: def check_PE_libraries(binary) -> bool: ok: bool = True + # DLL names are case-insensitive; Rust's windows-sys imports lowercase ones. + allowed = {lib.lower() for lib in PE_ALLOWED_LIBRARIES} for dylib in binary.libraries: - if dylib not in PE_ALLOWED_LIBRARIES: + if dylib.lower() not in allowed: print(f'{dylib} is not in ALLOWED_LIBRARIES!') ok = False return ok diff --git a/depends/Makefile b/depends/Makefile index c8510f4cc010..1bb83222ce86 100644 --- a/depends/Makefile +++ b/depends/Makefile @@ -44,6 +44,7 @@ NO_UPNP ?= NO_USDT ?= NO_NATPMP ?= MULTIPROCESS ?= +PLATFORM_GUI ?= LTO ?= NO_HARDEN ?= FALLBACK_DOWNLOAD_PATH ?= http://dash-depends-sources.s3-website-us-west-2.amazonaws.com @@ -175,6 +176,7 @@ natpmp_packages_$(NO_NATPMP) = $(natpmp_packages) zmq_packages_$(NO_ZMQ) = $(zmq_packages) multiprocess_packages_$(MULTIPROCESS) = $(multiprocess_packages) +platform_packages_$(PLATFORM_GUI) = $(platform_packages) usdt_packages_$(NO_USDT) = $(usdt_$(host_os)_packages) packages += $($(host_arch)_$(host_os)_packages) $($(host_os)_packages) $(boost_packages_) $(libevent_packages_) $(qt_packages_) $(wallet_packages_) $(upnp_packages_) $(natpmp_packages_) $(usdt_packages_) @@ -189,6 +191,14 @@ packages += $(multiprocess_packages) native_packages += $(multiprocess_native_packages) endif +ifeq ($(platform_packages_),) +ifneq ($(NO_QT)$(NO_WALLET),) +$(error PLATFORM_GUI needs the GUI and the wallet; unset NO_QT and NO_WALLET) +endif +packages += $(platform_packages) +native_packages += $(platform_native_packages) +endif + all_packages = $(packages) $(native_packages) meta_depends = Makefile config.guess config.sub funcs.mk builders/default.mk hosts/default.mk hosts/$(host_os).mk builders/$(build_os).mk @@ -257,6 +267,7 @@ $(host_prefix)/share/config.site : config.site.in $(host_prefix)/.stamp_$(final_ -e 's|@no_usdt@|$(NO_USDT)|' \ -e 's|@no_natpmp@|$(NO_NATPMP)|' \ -e 's|@multiprocess@|$(MULTIPROCESS)|' \ + -e 's|@platform_gui@|$(PLATFORM_GUI)|' \ -e 's|@lto@|$(LTO)|' \ -e 's|@no_harden@|$(NO_HARDEN)|' \ -e 's|@debug@|$(DEBUG)|' \ diff --git a/depends/README.md b/depends/README.md index f504627729f0..0f4e67f288f7 100644 --- a/depends/README.md +++ b/depends/README.md @@ -92,6 +92,9 @@ The following can be set when running make: `make FOO=bar` build script logic) are searched for among the host system packages using `pkg-config`. It allows building with packages of other (newer) versions - `MULTIPROCESS`: build libmultiprocess (experimental, requires cmake) +- `PLATFORM_GUI`: build the Rust toolchain and the Dash Platform CXX bindings needed for + `--enable-platform-gui` (which config.site then enables); cannot be combined with `NO_QT` + or `NO_WALLET` - `DEBUG`: Disable some optimizations and enable more runtime checking - `HOST_ID_SALT`: Optional salt to use when generating host package ids - `BUILD_ID_SALT`: Optional salt to use when generating build package ids diff --git a/depends/config.site.in b/depends/config.site.in index 398a09b74c63..ff54ed3db74b 100644 --- a/depends/config.site.in +++ b/depends/config.site.in @@ -50,6 +50,10 @@ if test -z "$enable_multiprocess" && test -n "@multiprocess@"; then enable_multiprocess=yes fi +if test -z "$enable_platform_gui" && test -n "@platform_gui@"; then + enable_platform_gui=yes +fi + if test -z "$with_miniupnpc" && test -n "@no_upnp@"; then with_miniupnpc=no fi diff --git a/depends/packages/native_protobuf.mk b/depends/packages/native_protobuf.mk new file mode 100644 index 000000000000..52c3745ee279 --- /dev/null +++ b/depends/packages/native_protobuf.mk @@ -0,0 +1,43 @@ +# 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. + +package=native_protobuf +$(package)_version=32.0 +$(package)_download_path=https://github.com/protocolbuffers/protobuf/releases/download/v$($(package)_version) + +# Linux (ARMv8) +$(package)_file_name_aarch64_linux=protoc-$($(package)_version)-linux-aarch_64.zip +$(package)_sha256_hash_aarch64_linux=56af3fc2e43a0230802e6fadb621d890ba506c5c17a1ae1070f685fe79ba12d0 + +# Linux (x86_64) +$(package)_file_name_x86_64_linux=protoc-$($(package)_version)-linux-x86_64.zip +$(package)_sha256_hash_x86_64_linux=7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88 + +# macOS (ARMv8) +$(package)_file_name_aarch64_darwin=protoc-$($(package)_version)-osx-aarch_64.zip +$(package)_sha256_hash_aarch64_darwin=09a2c729cc821215cc0d4c564b761760961fe338c52f24b302fd7e18e7b675d1 + +# macOS (x86_64) +$(package)_file_name_x86_64_darwin=protoc-$($(package)_version)-osx-x86_64.zip +$(package)_sha256_hash_x86_64_darwin=63eeba15ddc12ab11b0a8bce81fb2d46cc69022c3e6ad21fecde90d52139bff6 + +$(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_protobuf has no prebuilt protoc $($(package)_version) for $(build_arch)-$(build_os)) +endif + +define $(package)_extract_cmds + echo "$($(package)_sha256_hash) $($(package)_source)" > .$($(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/native_rust.mk b/depends/packages/native_rust.mk new file mode 100644 index 000000000000..c5a6bf275224 --- /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 +$(package)_patches:=fix-elf-interpreter.sh + +# 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 + +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/ && \ + bash $($(package)_patch_dir)/fix-elf-interpreter.sh \ + $($(package)_staging_prefix_dir)/lib \ + $($(package)_staging_prefix_dir)/bin/cargo \ + $($(package)_staging_prefix_dir)/bin/rustc +endef diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 7e0bb2633219..160fce36ef80 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -26,4 +26,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..9cd76d8d77c9 --- /dev/null +++ b/depends/packages/platform_cxx.mk @@ -0,0 +1,172 @@ +# 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 Platform CXX bindings (packages/rs-platform-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=02b1749cb6aefd75a6fd6a15cbd666fd7a58dfa8 +$(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=a5e4e7db2d2a11c8becb7bcd1fb206c956e75e52cfcb5d869b0a59b443c98e13 +$(package)_crates_file_name=platform-cxx-crates-$($(package)_version).tar.gz +$(package)_crates_sha256_hash=54586810d30debbe1406305c5bd6552439248346b9875bb5c558ca05a573f721 +$(package)_extra_sources=$($(package)_crates_file_name) +$(package)_dependencies=native_rust rust_stdlib native_protobuf +$(package)_patches=rustc-linker.sh build-linker.sh + +define $(package)_fetch_cmds +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_download_file),$($(package)_file_name),$($(package)_sha256_hash)) && \ +( test -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-platform-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, 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, so the Guix manifest must provide it. +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. +define $(package)_build_cmds + ( $($(package)_cargo) tree --frozen --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-platform-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-platform-cxx depends on a forbidden crate" >&2; exit 1; \ + fi && \ + ( $($(package)_cargo) build --frozen --offline --release --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-platform-cxx --target $($(package)_rust_target) ) +endef + +# The crate's build script stages the generated bridge header (ffi.h), the cxx +# runtime header and signer.h under target//release/include; that tree +# and the static archive are the installed interface. +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/include $($(package)_staging_prefix_dir)/lib && \ + cp -R target/$($(package)_rust_target)/release/include/. $($(package)_staging_prefix_dir)/include/ && \ + cp target/$($(package)_rust_target)/release/libdash_platform_cxx.a $($(package)_staging_prefix_dir)/lib/ +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 diff --git a/depends/patches/native_rust/fix-elf-interpreter.sh b/depends/patches/native_rust/fix-elf-interpreter.sh new file mode 100755 index 000000000000..dbedd5151198 --- /dev/null +++ b/depends/patches/native_rust/fix-elf-interpreter.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +export LC_ALL=C +set -euo pipefail + +# 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. + +# Usage: fix-elf-interpreter.sh ... +# +# The prebuilt Rust binaries expect a conventional /lib64/ld-linux-* loader +# and system library directories. Inside a Guix environment neither exists, so +# the binaries get the environment's loader, an $ORIGIN-relative RPATH, and +# copies of the runtime libraries they need. Anywhere else they run as they +# are, and this script does nothing. + +LIBDIR="$1" +shift + +case "$(readlink -f "$(command -v ls)")" in + /gnu/store/*) ;; + *) exit 0 ;; +esac + +if ! command -v patchelf >/dev/null 2>&1; then + echo "ERROR: patchelf is required inside the Guix environment but was not found" >&2 + exit 1 +fi + +# Get the interpreter from a known working binary (ls) +LS_PATH=$(command -v ls) +GUIX_INTERP=$(patchelf --print-interpreter "$LS_PATH") + +echo "Detected interpreter: $GUIX_INTERP" + +# Find and copy runtime libraries the prebuilt binaries need into our lib +# directory so the $ORIGIN-based RPATH can resolve them. +for libname in libgcc_s.so.1 libz.so.1; do + LIB_SRC="" + + # Method 1: Use gcc to find it + if command -v gcc >/dev/null 2>&1; then + CANDIDATE=$(gcc -print-file-name="$libname" 2>/dev/null || true) + if [ -f "$CANDIDATE" ]; then + LIB_SRC="$CANDIDATE" + else + GCC_PATH=$(command -v gcc) + GCC_PREFIX=$(dirname "$(dirname "$GCC_PATH")") + if [ -f "$GCC_PREFIX/lib/$libname" ]; then + LIB_SRC="$GCC_PREFIX/lib/$libname" + fi + fi + fi + + # Method 2: Search LIBRARY_PATH + if [ -z "$LIB_SRC" ] && [ -n "${LIBRARY_PATH:-}" ]; then + IFS=':' read -ra LIB_PATHS <<< "$LIBRARY_PATH" + for libpath in "${LIB_PATHS[@]}"; do + if [ -f "$libpath/$libname" ]; then + LIB_SRC="$libpath/$libname" + break + fi + done + fi + + # Method 3: the Guix profile. contrib/guix/libexec/build.sh narrows + # LIBRARY_PATH to the gcc-toolchain outputs, so libraries provisioned by + # contrib/guix/manifest.scm (zlib) are only reachable through the + # profile union that guix shell exposes as GUIX_ENVIRONMENT. + if [ -z "$LIB_SRC" ] && [ -n "${GUIX_ENVIRONMENT:-}" ] && [ -f "$GUIX_ENVIRONMENT/lib/$libname" ]; then + LIB_SRC="$GUIX_ENVIRONMENT/lib/$libname" + fi + + if [ -z "$LIB_SRC" ]; then + # There are no default library search paths inside Guix, so a + # toolchain missing one of these libraries is nonfunctional and must + # not be staged and cached. + echo "ERROR: $libname is required inside the Guix environment but was not found" >&2 + exit 1 + fi + # Resolve symlinks and copy the actual file + LIB_REAL=$(readlink -f "$LIB_SRC") + echo "Copying $libname from: $LIB_REAL" + cp "$LIB_REAL" "$LIBDIR/$libname" +done + +# RPATH just needs $ORIGIN/../lib - everything is self-contained +GUIX_RPATH="\$ORIGIN/../lib" +echo "Using RPATH: $GUIX_RPATH" + +for binary in "$@"; do + if [ -f "$binary" ]; then + echo "Patching: $binary" + patchelf --set-interpreter "$GUIX_INTERP" "$binary" + patchelf --set-rpath "$GUIX_RPATH" "$binary" + fi +done + +if [ $# -gt 0 ]; then + echo "Verifying first binary:" + patchelf --print-interpreter "$1" + patchelf --print-rpath "$1" +fi 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/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/README.md b/doc/README.md index 384ba6442f66..e80199264b96 100644 --- a/doc/README.md +++ b/doc/README.md @@ -58,6 +58,7 @@ The Dash Core repo's [root README](/README.md) contains relevant information on - [BIPS](bips.md) - [Dnsseed Policy](dnsseed-policy.md) - [Benchmarking](benchmarking.md) +- [Dash Platform in dash-qt](platform-gui.md) - [Internal Design Docs](design/) ### Resources diff --git a/doc/dependencies.md b/doc/dependencies.md index a54e60efbe03..b480aad1e5f7 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 Platform CXX bindings](../depends/packages/platform_cxx.mk) | [link](https://github.com/dashpay/platform) | commit [02b1749...](https://github.com/dashpay/platform/tree/02b1749cb6aefd75a6fd6a15cbd666fd7a58dfa8) | | No | + ### Networking | Dependency | Releases | Version used | Minimum required | Runtime | | --- | --- | --- | --- | --- | diff --git a/doc/platform-gui.md b/doc/platform-gui.md new file mode 100644 index 000000000000..2c1676bb9e09 --- /dev/null +++ b/doc/platform-gui.md @@ -0,0 +1,193 @@ +# Dash Platform in dash-qt + +`--enable-platform-gui` adds DashPay (usernames, profiles, contacts and +payments by username) to `dash-qt`. It is off by default, needs the GUI and +the wallet, and links the Platform-owned `dash-platform-cxx` archive (a thin +C++ shell over the Rust `dash-sdk`) into `dash-qt` (and its multiprocess +twin `dash-gui`), `test_dash` and `test_dash-qt` only. `dashd`, `dash-cli`, `dash-tx`, `dash-wallet` and the +fuzz binary never link it; a default build is byte-identical with or without +the option. + +Two layers implement it: + +- `src/platform/` (`libdash_platform.a`, Qt-free): the `PlatformClient` seam, + its SDK-backed implementation, the signing operation and wallet signer, the + state-transition adapters and the wallet record formats. +- `src/qt/platform/`: the per-wallet service, the identity, contact and + recovery state machines, and the pages and dialogs. + +## Trust model + +Every response byte comes from an untrusted evonode. Nothing in the linked +archive fetches from a trusted third-party service: there is no HTTP client, +no default seed list and no remote quorum source. Trust rests on inputs Core +pushes into the SDK: + +- **Endpoints** come from the valid entries of the deterministic masternode + list at the chain tip (`getPlatformHTTPSAddrs`), refreshed every minute. An + empty set removes every endpoint. +- **Quorum keys** are the public keys of the mined final commitments of the + network's Platform LLMQ type (`Consensus::Params::llmqTypePlatform`), in + Core's internal byte order; the shell normalizes them. The SDK refuses a + proof signed by any other LLMQ type or by a quorum Core has not pushed. +- **ChainLock height** is the best local ChainLock, pushed on the timer and + on every `NotifyChainLock`. No proved read is dispatched before the first + push, and a proof whose signed core-chain-locked height trails the local + one by more than 288 blocks is refused as stale. There is no ceiling: an + evonode one ChainLock ahead of this node is honest. + +Every read is proved: the SDK replays the GroveDB proof and verifies the +Tenderdash quorum signature before anything reaches the GUI, and absence is +a proven outcome (`StatusKind::PROVEN_ABSENT`), never inferred from a +failure. The broadcast reply is advisory; every write is confirmed by a +proved re-query of the object it created. + +### Freshness order + +For every proved read, in order: (1) the quorum signature, with the LLMQ +type, quorum set and ChainLock lag gates above; (2) the SDK's signed-time +window (10 minutes) and its protocol-version ratchet; (3) the shell's +monotonic Platform-height watermark (tolerance 3 blocks, `REJECTED`); (4) a +protocol version above what the build knows (`UNSUPPORTED_PROTOCOL_VERSION`): +the value is still returned, the GUI freezes writes and asks for an update. +The signed Tenderdash chain id is not compared to an expected value: it is +part of the message the quorum signs, and the quorum must be a Platform +quorum of this node's own chain. + +### Protocol version policy + +The SDK builds state transitions under the protocol version a verified read +has shown the network to run, and refuses to build before the first such +read. Each Platform protocol version bump therefore needs a Dash Core point +release that repins `dash-platform-cxx`; until then reads keep working and +writes are frozen. + +## Custody contract + +Private keys, the seed and the mnemonic never cross into Rust. The SDK +builders hand the wallet the full signable preimage of a transition +(`WalletSigner::signForKey`); Core computes the double SHA256 itself, checks +that the first byte (the bincode variant index of `StateTransition`: 2 for a +batch, 3 for an identity create) matches the operation it is in, refuses any +key outside the operation, and answers with the wallet's 65-byte compact +recoverable signature through `interfaces::Wallet::signPlatformDigest`. The +asset-lock sighash of an identity registration is the one digest path +(`signAssetLockSighash`), accepted once per operation for the flow's funding +key. The ECDH secret and the accountReference MAC of a contact request are +computed inside `CWallet` (`platformECDHSecret`, +`platformAccountReferenceMac`, both refusing the MASTER key) and only their +32-byte outputs are handed to the SDK, which zeroizes its copies. + +A builder can only be called with a `platform::SigningOperation`: move-only, +minted by `PlatformService` alone, carrying the operation kind, the key ids +it may sign with, the one-shot asset-lock flag and the wallet unlock scope. +That scope is released before any network wait, so an encrypted wallet is +unlocked only for the milliseconds a step signs. A locked wallet never signs; +the identity flow parks in `NEEDS_UNLOCK` and retries on the next user +action. + +## Threading + +- `SdkClient` runs every read and broadcast on one serial worker thread; one + enqueue is one SDK request (one page, one broadcast, one fetch), so the + worst-case head-of-line delay is one request budget (20 s, a 5 s connect or + 15 s through a proxy, 2 retries). Paging is a flow concern: a flow + re-enqueues with the cursor of the previous page on a later tick. +- Callbacks fire on the worker; the Qt layer re-posts them to the GUI thread. + The GUI never waits on the worker while holding `cs_wallet`. +- Builders run to completion on the calling (GUI) thread with no network + access; the SDK drives its async builders with a local executor, so the + `WalletSigner` is only invoked on the thread that called the builder. The + signer is nonetheless callable from any thread: it wraps wallet seams that + take `cs_wallet` and keeps no thread-local state. +- `shutdown()` aborts the in-flight request, stops the SDK runtime and joins + the worker before the flows are destroyed. + +## Privacy + +The service is created only for a wallet whose owner enabled DashPay in the +opt-in dialog (record `platform/enabled`), which states what the evonode +answering each request can see: the node's IP address, the wallet's identity +and what it looks up, and the usernames searched for. DashPay is turned on and +off per wallet in Options, Wallet. The service needs a descriptor wallet, a +synced node and a local ChainLock, and pushes an empty endpoint set while the +network is inactive or the node is still in initial block download. No avatar +is ever fetched or rendered. + +Platform connections follow the node's own network settings. When IPv4 or +IPv6 is reachable, every connection goes through `-proxy`, or directly when +none is set, and only evonodes on a reachable network are used; an onion +evonode only when the onion proxy is that same proxy. With only onion +reachable (`-onlynet=onion`), connections go through the onion proxy to onion +evonodes only. I2P and CJDNS evonodes are never used. The client's proxy is +fixed when the service is created, as the node's is at startup, and with +`-proxyrandomize` every connection gets fresh SOCKS5 credentials, so Tor +builds a circuit per connection. Evonode endpoints are IP addresses or onion +names, so nothing is ever resolved locally, and TLS is verified end to end +through the proxy. An onion evonode is therefore only usable with a +certificate valid for its onion name, which few certificate authorities +issue, so an onion-only node usually reaches no evonode and DashPay reports +Dash Platform as unavailable. A proxy that fails does not count against the +evonode. When no network DashPay can use is reachable the service is not +created, and when no evonode can be reached over the networks that are, it +pushes no endpoints; the page says why in either case. + +Every proved read discloses the identity it concerns to the evonode that +answers it, so the GUI reads only while the DashPay page is shown, never for a +hidden page: the dashboard's profile and balance when the page is shown or the +window becomes active (not again within 30 seconds); the contact list also on +a new ChainLock (at most once a minute) and on a five-minute fallback, backing +off to ten minutes while reads fail; and whatever the user's own change +touched. There is no manual refresh; a failed read offers Try again. A +contact's username and profile are re-read at most every five minutes; username +search results are kept in memory for the session and never written to the +wallet; and a recipient typed into the send form is looked up only once it can +no longer be the start of a Dash address, or when the entry is left. + +Debug logging of the library and the GUI layer uses the `platform` category +(`-debug=platform`). + +## Wallet records + +Records live in the wallet database under `platform/*`, `identity/*` and +`contact/*` and travel with backups. They carry one layout version +(`platform/version`); when it differs from what the build expects, every +Platform record is wiped and seed-only recovery reruns from the on-chain +state. There is no migration path. + +The identity record names the ids of the keys this wallet signs documents +with and runs the contact-request ECDH with. A registration sets 1, 2 and 3; +seed-only recovery takes them from the proved identity and only restores an +identity whose keys at those ids are the ones the wallet derives there, so +an identity registered by another wallet with a different key layout is +reported instead of surfacing as one that can never sign. A +`platform/recovery-pending` record marks a restored identity whose contacts +were not all restored yet; the next start resumes that phase. No new +identity registration starts before recovery has proved that the seed has +none, since Platform refuses a second identity with the same keys and the +asset lock funding it would be burned. + +A failed registration keeps what it put on chain: "try again" re-uses an +unconsumed asset lock, or asks for a new name paid from the balance on Dash +Platform of an identity that already exists. Unanswered or unverified reads never fail a +registration; the step is retried on the next tick. + +The rescan birth time of an imported friendship keychain is the time of our +own outgoing contact request, never the counterparty's document time, which +the sender controls. + +A request that answers one we sent establishes the contact on the next +refresh without broadcasting anything, as the mobile wallets do; a locked +wallet shows the contact as accepted until it is unlocked. + +A contact request can be neither rejected nor withdrawn on Platform, so +"Ignore request" and "Hide contact" only write a `contact/hidden/` +record in this wallet: the other side is never told, the record is not +restored by seed recovery, and sending that person a request clears it. + +## Known build limitations + +- Stable `rustc` emits no CET/IBT or BTI instrumentation, so a Platform + enabled `dash-qt` loses that hardening in the Rust closure. +- Windows builds link `crypt32`, `ntdll`, `secur32` and `ncrypt` for the Rust + TLS trust store and standard library. diff --git a/src/Makefile.am b/src/Makefile.am index 57d000012b00..a3a0492bcf32 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -62,6 +62,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 @@ -127,7 +130,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBDASH_PLATFORM) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -709,6 +713,27 @@ libbitcoin_zmq_a_SOURCES = \ endif # +# platform (Dash Platform client for the GUI; linked into dash-qt, dash-gui and the test binaries only) # +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/helpers.cpp \ + platform/helpers.h \ + platform/marshal.cpp \ + platform/marshal.h \ + platform/signer.cpp \ + platform/signer.h \ + platform/st.cpp \ + platform/st.h \ + platform/types.h \ + platform/walletrecords.cpp \ + platform/walletrecords.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 ca191acfef7b..95cee7e999a3 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -122,6 +122,22 @@ QT_MOC_CPP = \ qt/moc_walletmodel.cpp \ qt/moc_walletview.cpp +if ENABLE_PLATFORM_GUI +QT_MOC_CPP += \ + qt/platform/moc_contactflow.cpp \ + qt/platform/moc_contactsmodel.cpp \ + qt/platform/moc_contactspage.cpp \ + qt/platform/moc_createusernamewizard.cpp \ + qt/platform/moc_dashpayoptionswidget.cpp \ + qt/platform/moc_identityflow.cpp \ + qt/platform/moc_platformoptindialog.cpp \ + qt/platform/moc_platformpage.cpp \ + qt/platform/moc_platformservice.cpp \ + qt/platform/moc_platformui.cpp \ + qt/platform/moc_profiledialog.cpp \ + qt/platform/moc_usernamesearchdialog.cpp +endif + BITCOIN_MM = \ qt/macdockiconhandler.mm \ qt/macnotificationhandler.mm \ @@ -224,6 +240,22 @@ BITCOIN_QT_H = \ qt/walletview.h \ qt/winshutdownmonitor.h +if ENABLE_PLATFORM_GUI +BITCOIN_QT_H += \ + qt/platform/contactflow.h \ + qt/platform/contactsmodel.h \ + qt/platform/contactspage.h \ + qt/platform/createusernamewizard.h \ + qt/platform/dashpayoptionswidget.h \ + qt/platform/identityflow.h \ + qt/platform/platformoptindialog.h \ + qt/platform/platformpage.h \ + qt/platform/platformservice.h \ + qt/platform/platformui.h \ + qt/platform/profiledialog.h \ + qt/platform/usernamesearchdialog.h +endif + QT_RES_ICONS = \ qt/res/icons/address-book.png \ qt/res/icons/connect1_16.png \ @@ -357,12 +389,29 @@ BITCOIN_QT_WALLET_CPP = \ qt/walletmodeltransaction.cpp \ qt/walletview.cpp +BITCOIN_QT_PLATFORM_CPP = \ + qt/platform/contactflow.cpp \ + qt/platform/contactsmodel.cpp \ + qt/platform/contactspage.cpp \ + qt/platform/createusernamewizard.cpp \ + qt/platform/dashpayoptionswidget.cpp \ + qt/platform/identityflow.cpp \ + qt/platform/platformoptindialog.cpp \ + qt/platform/platformpage.cpp \ + qt/platform/platformservice.cpp \ + qt/platform/platformui.cpp \ + qt/platform/profiledialog.cpp \ + qt/platform/usernamesearchdialog.cpp + BITCOIN_QT_CPP = $(BITCOIN_QT_BASE_CPP) if TARGET_WINDOWS BITCOIN_QT_CPP += $(BITCOIN_QT_WINDOWS_CPP) endif if ENABLE_WALLET BITCOIN_QT_CPP += $(BITCOIN_QT_WALLET_CPP) +if ENABLE_PLATFORM_GUI +BITCOIN_QT_CPP += $(BITCOIN_QT_PLATFORM_CPP) +endif # ENABLE_PLATFORM_GUI endif # ENABLE_WALLET QT_RES_IMAGES = \ @@ -487,6 +536,9 @@ endif if ENABLE_ZMQ bitcoin_qt_ldadd += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(LIBDASH_PLATFORM) $(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) @@ -518,7 +570,7 @@ $(srcdir)/qt/dashstrings.cpp: FORCE # The resulted dash_en.xlf source file should follow Transifex requirements. # See: https://docs.transifex.com/formats/xliff#how-to-distinguish-between-a-source-file-and-a-translation-file -translate: $(srcdir)/qt/dashstrings.cpp $(QT_FORMS_UI) $(QT_FORMS_UI) $(BITCOIN_QT_BASE_CPP) qt/bitcoin.cpp $(BITCOIN_QT_WINDOWS_CPP) $(BITCOIN_QT_WALLET_CPP) $(BITCOIN_QT_H) $(BITCOIN_MM) +translate: $(srcdir)/qt/dashstrings.cpp $(QT_FORMS_UI) $(QT_FORMS_UI) $(BITCOIN_QT_BASE_CPP) qt/bitcoin.cpp $(BITCOIN_QT_WINDOWS_CPP) $(BITCOIN_QT_WALLET_CPP) $(BITCOIN_QT_PLATFORM_CPP) $(BITCOIN_QT_H) $(BITCOIN_MM) @test -n $(LUPDATE) || echo "lupdate is required for updating translations" $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(LUPDATE) -no-obsolete -I $(srcdir) -locations relative $^ -ts $(srcdir)/qt/locale/dash_en.ts @test -n $(LCONVERT) || echo "lconvert is required for updating translations" @@ -554,6 +606,7 @@ ui_%.h: %.ui $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(MOC) $(DEFAULT_INCLUDES) $(QT_INCLUDES_UNSUPPRESSED) $(MOC_DEFS) $< > $@ moc_%.cpp: %.h + @$(MKDIR_P) $(@D) $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(MOC) $(DEFAULT_INCLUDES) $(QT_INCLUDES_UNSUPPRESSED) $(MOC_DEFS) $< > $@ %.qm: %.ts diff --git a/src/Makefile.qttest.include b/src/Makefile.qttest.include index d4e84bf80057..7e9dafaf1241 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -25,6 +25,9 @@ TEST_QT_MOC_CPP += \ qt/test/moc_sharedmnwidgettests.cpp \ qt/test/moc_sharedmnwizardtests.cpp \ qt/test/moc_wallettests.cpp +if ENABLE_PLATFORM_GUI +TEST_QT_MOC_CPP += qt/test/moc_platformtests.cpp +endif endif # ENABLE_WALLET TEST_QT_H = \ @@ -35,6 +38,7 @@ TEST_QT_H = \ qt/test/masternodetestutil.h \ qt/test/masternodewidgettests.h \ qt/test/optiontests.h \ + qt/test/platformtests.h \ qt/test/proposalvotetests.h \ qt/test/providertransactiontests.h \ qt/test/rpcnestedtests.h \ @@ -73,6 +77,9 @@ qt_test_test_dash_qt_SOURCES += \ qt/test/sharedmnwizardtests.cpp \ qt/test/wallettests.cpp \ wallet/test/wallet_test_fixture.cpp +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_SOURCES += qt/test/platformtests.cpp +endif endif # ENABLE_WALLET nodist_qt_test_test_dash_qt_SOURCES = $(TEST_QT_MOC_CPP) @@ -84,6 +91,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 += $(LIBDASH_PLATFORM) $(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..f4a45b1d45e0 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -226,6 +226,11 @@ 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 \ @@ -276,6 +281,10 @@ if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) endif +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(LIBDASH_PLATFORM) $(PLATFORM_CXX_LIBS) +test_test_dash_CPPFLAGS += $(PLATFORM_CXX_CFLAGS) +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) @@ -404,6 +413,11 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/validation_load_mempool.cpp \ test/fuzz/vecdeque.cpp \ test/fuzz/versionbits.cpp +# Pure C++ (no bridge header), so the default fuzz build covers it: the +# fuzz binary never links the Platform SDK archive. +test_fuzz_fuzz_SOURCES += \ + platform/walletrecords.cpp \ + test/fuzz/platform_walletrecords.cpp endif # ENABLE_FUZZ_BINARY nodist_test_test_dash_SOURCES = $(GENERATED_TEST_FILES) 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/interfaces/node.h b/src/interfaces/node.h index 5713fceaa88e..deb9ce52b2c3 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -413,6 +413,10 @@ class Node //! Get proxy. virtual bool getProxy(Network net, Proxy& proxy_info) = 0; + //! Whether outbound connections to this network are allowed (-onlynet, + //! -onion, -noonion and the onion proxy the Tor controller configures). + virtual bool isReachable(Network net) = 0; + //! Get number of connections. virtual size_t getNodeCount(ConnectionDirection flags) = 0; diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index e42bfe09d29d..f35bdbe27986 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -14,6 +14,7 @@ #include #include #include +#include // For bilingual_str #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -35,7 +37,6 @@ class CKey; class CRPCCommand; enum class FeeReason; enum class TransactionError; -struct bilingual_str; struct PartiallySignedTransaction; namespace node { struct NodeContext; @@ -155,9 +156,17 @@ class Wallet //! ECDH shared secret between the identity authentication key //! and a counterparty public key, using the libsecp256k1 ECDH KDF. + //! Refuses key_index 0 (the identity MASTER key). virtual wallet::PlatformKeyResult platformECDHSecret(const wallet::IdentityAuthKey& key, const CPubKey& counterparty) = 0; + //! DIP-15 accountReference MAC: HMAC-SHA256 keyed by the identity + //! authentication (ENCRYPTION) private key over the compact xpub sent in + //! a contact request. Refuses key_index 0. The caller masks the account + //! index with the result; the key never leaves the wallet. + virtual wallet::PlatformKeyResult platformAccountReferenceMac(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. @@ -252,8 +261,21 @@ 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, + //! Create a signed (uncommitted) asset lock transaction converting + //! credit_amount duffs into Platform credits, with a single P2PKH credit + //! output to credit_pubkey (typically a registration funding key from + //! getPlatformPubKey). Broadcast the result with commitTransaction(). + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const CPubKey& credit_pubkey, + const wallet::CCoinControl& coin_control) = 0; + + //! Commit transaction. Returns the mempool rejection reason when the + //! transaction was committed to the wallet but could not be accepted to + //! the mempool for broadcast; the caller may abandon it to release its + //! inputs. Returns std::nullopt when the transaction was accepted or when + //! wallet broadcasting is disabled (-walletbroadcast=0), which the caller + //! cannot distinguish here. + virtual std::optional commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) = 0; @@ -563,6 +585,10 @@ struct WalletTxStatus bool is_in_main_chain; bool is_chainlocked; bool is_islocked; + //! A conflicting transaction is in a ChainLocked block, so this one can + //! never confirm; a conflict in a block not ChainLocked yet may still be + //! reorganized away. + bool is_conflict_chainlocked; }; //! Wallet transaction output. diff --git a/src/logging.cpp b/src/logging.cpp index 2e16a95685b7..4fdd4d1e3719 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -196,6 +196,7 @@ const CLogCategoryDesc LogCategories[] = {BCLog::SPORK, "spork"}, {BCLog::NETCONN, "netconn"}, {BCLog::CREDITPOOL, "creditpool"}, + {BCLog::PLATFORM, "platform"}, {BCLog::EHF, "ehf"}, {BCLog::DASH, "dash"}, //End Dash @@ -322,6 +323,8 @@ std::string LogCategoryToStr(BCLog::LogFlags category) return "netconn"; case BCLog::LogFlags::CREDITPOOL: return "creditpool"; + case BCLog::LogFlags::PLATFORM: + return "platform"; case BCLog::LogFlags::EHF: return "ehf"; case BCLog::LogFlags::DASH: diff --git a/src/logging.h b/src/logging.h index c5559225a626..da230e4bdc99 100644 --- a/src/logging.h +++ b/src/logging.h @@ -84,10 +84,11 @@ namespace BCLog { NETCONN = ((uint64_t)1 << 43), EHF = ((uint64_t)1 << 44), CREDITPOOL = ((uint64_t)1 << 45), + PLATFORM = ((uint64_t)1 << 46), DASH = CHAINLOCKS | GOBJECT | INSTANTSEND | LLMQ | LLMQ_DKG | LLMQ_SIGS | MNPAYMENTS | MNSYNC | COINJOIN | SPORK | NETCONN - | EHF | CREDITPOOL, + | EHF | CREDITPOOL | PLATFORM, NET_NETCONN = NET | NETCONN, // use this to have something logged in NET and NETCONN as well //End Dash diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index fce39a312367..c1f3b0a9ab77 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -957,6 +957,7 @@ class NodeImpl : public Node } void mapPort(bool use_upnp, bool use_natpmp) override { StartMapPort(use_upnp, use_natpmp); } bool getProxy(Network net, Proxy& proxy_info) override { return GetProxy(net, proxy_info); } + bool isReachable(Network net) override { return g_reachable_nets.Contains(net); } size_t getNodeCount(ConnectionDirection flags) override { return m_context->connman ? m_context->connman->GetNodeCount(flags) : 0; diff --git a/src/platform/client.cpp b/src/platform/client.cpp new file mode 100644 index 000000000000..a18f5bba54ca --- /dev/null +++ b/src/platform/client.cpp @@ -0,0 +1,271 @@ +// 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 + +namespace platform { + +namespace { + +//! Runs one bridge call on the worker thread and delivers its outcome. The +//! bridge reports failures through Status; what still escapes as a C++ +//! exception is a bug (a rust::Error from a marshalling failure such as +//! invalid UTF-8) and is delivered as INTERNAL. +template +void Deliver(const PlatformClient::Callback& cb, const Fn& fn) +{ + Result out; + try { + out = fn(); + } catch (const std::exception& e) { + out = Result{}; + out.status.kind = StatusKind::INTERNAL; + out.status.message = e.what(); + } + cb(std::move(out)); +} + +class SdkClient final : public PlatformClient +{ +public: + SdkClient(const ClientConfig& config, rust::Box sdk) : + m_llmq_type(config.platform_llmq_type), + m_sdk(std::move(sdk)) + { + m_worker = std::thread([this] { Run(); }); + } + ~SdkClient() override { shutdown(); } + + void shutdown() override + { + if (m_stop.exchange(true)) return; + { + std::lock_guard lk(m_mtx); + m_queue.clear(); + } + m_cv.notify_all(); + // Aborts the SDK request the worker may be blocked in, then joins. + m_sdk->shutdown(); + if (m_worker.joinable()) m_worker.join(); + } + + void updateEndpoints(std::vector endpoints) override + { + std::vector uris; + uris.reserve(endpoints.size()); + for (const Endpoint& endpoint : endpoints) { + uris.emplace_back("https://" + endpoint.service.ToStringAddrPort()); + } + try { + // Replace the SDK set even when it is empty, so endpoints removed + // from the deterministic masternode list cannot remain usable. + m_sdk->set_endpoints(rust::Slice{uris.data(), uris.size()}); + } catch (const std::exception& e) { + LogPrintf("Platform client: unable to update endpoints: %s\n", e.what()); + } + } + + void updateQuorumKeys(uint8_t llmq_type, std::vector keys) override + { + if (llmq_type != m_llmq_type) { + LogPrintf("Platform client: ignoring quorum keys of LLMQ type %d (Platform type is %d)\n", llmq_type, + m_llmq_type); + return; + } + // The shell takes Core's internal uint256 order and normalizes it to + // the order proofs carry; nothing here knows the wire representation. + std::vector ffi_keys; + ffi_keys.reserve(keys.size()); + for (const QuorumKey& key : keys) { + platform_ffi::QuorumKey ffi_key; + if (key.pubkey.size() != ffi_key.pubkey.size()) continue; + std::copy(key.quorum_hash.begin(), key.quorum_hash.end(), ffi_key.hash.begin()); + std::copy(key.pubkey.begin(), key.pubkey.end(), ffi_key.pubkey.begin()); + ffi_keys.push_back(std::move(ffi_key)); + } + try { + m_sdk->set_quorum_keys(rust::Slice{ffi_keys.data(), ffi_keys.size()}); + } catch (const std::exception& e) { + LogPrintf("Platform client: unable to update quorum keys: %s\n", e.what()); + } + } + + void updateCoreChainLockedHeight(int32_t height) override + { + if (height <= 0) return; + try { + m_sdk->set_chainlock_height(static_cast(height)); + } catch (const std::exception& e) { + LogPrintf("Platform client: unable to update the ChainLock height: %s\n", e.what()); + } + } + + void resolveName(const std::string& normalized_label, Callback cb) override + { + Enqueue([=, this] { Deliver(cb, [&] { return marshal::FromFfi(m_sdk->resolve_name(normalized_label)); }); }); + } + void searchNames(const std::string& prefix, uint32_t limit, const Identifier& start_after, + Callback> cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { return marshal::FromFfi(m_sdk->search_names(prefix, limit, start_after)); }); + }); + } + void namesOfIdentity(const Identifier& identity, const Identifier& start_after, Callback> cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { return marshal::FromFfi(m_sdk->names_of_identity(identity, start_after)); }); + }); + } + void getIdentity(const Identifier& id, Callback cb) override + { + Enqueue([=, this] { Deliver(cb, [&] { return marshal::FromFfi(m_sdk->get_identity(id)); }); }); + } + void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { return marshal::FromFfi(m_sdk->get_identity_by_pubkey_hash(pubkey_hash)); }); + }); + } + void getIdentityContractNonce(const Identifier& id, const Identifier& contract_id, Callback cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { return marshal::FromFfi(m_sdk->get_identity_contract_nonce(id, contract_id)); }); + }); + } + void getProfile(const Identifier& owner_id, Callback cb) override + { + Enqueue([=, this] { Deliver(cb, [&] { return marshal::FromFfi(m_sdk->get_profile(owner_id)); }); }); + } + void getContactRequests(const Identifier& identity, bool to_me, uint64_t since_ms, const Identifier& start_after, + Callback> cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { + return marshal::FromFfi(m_sdk->get_contact_requests(identity, to_me, since_ms, start_after)); + }); + }); + } + void getContestedNameState(const std::string& normalized_label, Callback cb) override + { + Enqueue([=, this] { + Deliver(cb, [&] { return marshal::FromFfi(m_sdk->get_contested_vote_state(normalized_label)); }); + }); + } + void broadcastStateTransition(const std::vector& state_transition, BroadcastCallback cb) override + { + Enqueue([=, this] { + Status status; + try { + status = marshal::FromFfi( + m_sdk->broadcast(rust::Slice{state_transition.data(), state_transition.size()}).status); + } catch (const std::exception& e) { + status.kind = StatusKind::INTERNAL; + status.message = e.what(); + } + cb(std::move(status)); + }); + } + + util::Result buildIdentityCreate(const SigningOperation& op, const AssetLockProof& proof, + const std::vector& keys) override + { + return st::BuildIdentityCreate(*m_sdk, op, proof, keys); + } + util::Result buildDpnsPreorder(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const std::string& label, const std::array& salt) override + { + return st::BuildDpnsPreorder(*m_sdk, op, owner, nonce, label, salt); + } + util::Result buildDpnsDomain(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const std::string& label, const std::array& salt) override + { + return st::BuildDpnsDomain(*m_sdk, op, owner, nonce, label, salt); + } + util::Result buildProfile(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const Profile& existing, const ProfileInput& input) override + { + return st::BuildProfile(*m_sdk, op, owner, nonce, existing, input); + } + util::Result buildContactRequest(const SigningOperation& op, const Identity& sender, const Identity& recipient, + uint64_t nonce, const ContactRequestInput& input) override + { + return st::BuildContactRequest(*m_sdk, op, sender, recipient, nonce, input); + } + util::Result contestedVoteFundCredits() override + { + try { + return m_sdk->contested_vote_fund_credits(); + } catch (const std::exception& e) { + return util::Error{Untranslated(e.what())}; + } + } + +private: + void Enqueue(std::function task) + { + { + std::lock_guard lk(m_mtx); + if (m_stop) return; + m_queue.push_back(std::move(task)); + } + m_cv.notify_one(); + } + void Run() + { + for (;;) { + std::function task; + { + std::unique_lock lk(m_mtx); + m_cv.wait(lk, [this] { return m_stop || !m_queue.empty(); }); + if (m_stop) return; + task = std::move(m_queue.front()); + m_queue.pop_front(); + } + task(); + } + } + + const uint8_t m_llmq_type; + rust::Box m_sdk; + std::thread m_worker; + std::mutex m_mtx; + std::condition_variable m_cv; + std::deque> m_queue; + std::atomic_bool m_stop{false}; +}; + +} // namespace + +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config) +{ + platform_ffi::Config ffi_config; + ffi_config.network = static_cast(config.network); + ffi_config.platform_llmq_type = config.platform_llmq_type; + ffi_config.proxy = marshal::ToFfi(config.proxy); + try { + return std::make_unique(config, platform_ffi::new_platform_client(ffi_config)); + } catch (const std::exception& e) { + LogPrintf("Platform client: unable to initialize the SDK: %s\n", e.what()); + return nullptr; + } +} + +} // namespace platform diff --git a/src/platform/client.h b/src/platform/client.h new file mode 100644 index 000000000000..642df99f8ed5 --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,149 @@ +// 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 +#include +#include + +namespace platform { + +//! A quorum public key the client may verify proofs against, fed from the +//! node's locally synced LLMQ data (interfaces::Node::LLMQ). The hash is in +//! Core's internal uint256 byte order; the SDK shell normalizes it. +struct QuorumKey { + uint256 quorum_hash; + std::vector pubkey; //!< serialized BLS public key (basic scheme) +}; + +//! An evonode DAPI endpoint, fed from the deterministic masternode list. +struct Endpoint { + CService service; //!< platform HTTPS (gRPC gateway) addr:port +}; + +//! Which network a client serves (the SDK's view of Core's chain) and how +//! it connects, which is fixed for the client's lifetime. +struct ClientConfig { + enum class Network : uint8_t { + MAIN = 0, + TESTNET = 1, + DEVNET = 2, + REGTEST = 3 + }; + Network network{Network::REGTEST}; + uint8_t platform_llmq_type{0}; + //! nullopt: connect to evonodes directly. + std::optional proxy; +}; + +//! Abstract asynchronous Dash Platform (DAPI) client. +//! +//! Implementations own their I/O thread; callbacks fire on that thread and +//! consumers marshal to their own (the Qt layer uses QMetaObject::invokeMethod). +//! Every read is proved: the SDK replays the GroveDB proof and verifies the +//! Tenderdash quorum signature against the keys pushed through +//! updateQuorumKeys before a callback sees the result, and absence is a +//! proven outcome (Result::provenAbsent()), never inferred from a failure. +//! +//! One call is one SDK request: paged reads return a single page and the +//! caller continues from Paged::next_start_after on a later call. +class PlatformClient +{ +public: + template + using Callback = std::function)>; + using BroadcastCallback = std::function; + + virtual ~PlatformClient() = default; + + //! Resolve an exact normalized label under the "dash" parent domain. + virtual void resolveName(const std::string& normalized_label, Callback cb) = 0; + + //! One page of names starting with prefix (normalizedLabel startsWith), + //! ascending, at most limit (clamped to 1..100). + virtual void searchNames(const std::string& prefix, uint32_t limit, const Identifier& start_after, + Callback> cb) = 0; + + //! One page of the names whose records.identity == identity. + virtual void namesOfIdentity(const Identifier& identity, const Identifier& start_after, + Callback> cb) = 0; + + virtual void getIdentity(const Identifier& id, Callback cb) = 0; + virtual void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) = 0; + + //! The identity's nonce for a contract. PROVEN_ABSENT means the identity + //! has not used the contract yet: the next nonce is 1, as after 0. + virtual void getIdentityContractNonce(const Identifier& id, const Identifier& contract_id, Callback cb) = 0; + + virtual void getProfile(const Identifier& owner_id, Callback cb) = 0; + + //! One page of the contact requests sent to (to_me) or by identity, + //! created at or after since_ms (0 = all), oldest first. + virtual void getContactRequests(const Identifier& identity, bool to_me, uint64_t since_ms, + const Identifier& start_after, Callback> cb) = 0; + + virtual void getContestedNameState(const std::string& normalized_label, Callback cb) = 0; + + //! Broadcast a serialized state transition. The reply is advisory and + //! typed (OK or ALREADY_EXISTS is success, CONSENSUS carries the code); + //! every write is confirmed by a proved re-query of the created object. + virtual void broadcastStateTransition(const std::vector& state_transition, BroadcastCallback cb) = 0; + + //! State-transition builders: no network, run to completion on the + //! calling thread, signed through the WalletSigner the operation binds. + //! The SDK builds under the protocol version a verified read has shown + //! the network to run and fails before the first such read. + virtual util::Result buildIdentityCreate(const SigningOperation& op, const AssetLockProof& proof, + const std::vector& keys) = 0; + virtual util::Result buildDpnsPreorder(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const std::string& label, const std::array& salt) = 0; + virtual util::Result buildDpnsDomain(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const std::string& label, const std::array& salt) = 0; + virtual util::Result buildProfile(const SigningOperation& op, const Identifier& owner, uint64_t nonce, + const Profile& existing, const ProfileInput& input) = 0; + virtual util::Result buildContactRequest(const SigningOperation& op, const Identity& sender, + const Identity& recipient, uint64_t nonce, + const ContactRequestInput& input) = 0; + + //! Credits a contested name registration must prefund, under the + //! protocol version a verified read has shown the network to run; an + //! error before that. + virtual util::Result contestedVoteFundCredits() = 0; + + //! Node-local trust inputs, pushed by the consumer. An empty endpoint + //! set is allowed and removes every endpoint. An endpoint must be an IP + //! address, or an onion address when the client has a proxy. + virtual void updateEndpoints(std::vector endpoints) = 0; + //! Replace the Platform quorum keys; keys of another LLMQ type are ignored. + virtual void updateQuorumKeys(uint8_t llmq_type, std::vector keys) = 0; + //! The node's best ChainLock height, the anchor of the proof staleness + //! floor. Heights <= 0 are ignored. + virtual void updateCoreChainLockedHeight(int32_t height) = 0; + + //! Stop all I/O and drop pending callbacks (must be called before the + //! consumer is destroyed). + virtual void shutdown() = 0; +}; + +//! Create the production client over the Dash Platform SDK (dash-platform-cxx), +//! fed endpoints, quorum keys and ChainLock heights from this node. Returns +//! nullptr when the SDK refuses the configuration (a proxy it cannot use +//! included: it never falls back to connecting directly). +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_CLIENT_H diff --git a/src/platform/helpers.cpp b/src/platform/helpers.cpp new file mode 100644 index 000000000000..2be63f108808 --- /dev/null +++ b/src/platform/helpers.cpp @@ -0,0 +1,75 @@ +// 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 + +namespace platform::helpers { + +std::string NormalizeLabel(const std::string& label) { return std::string(platform_ffi::normalize_label(label)); } + +bool IsValidUsername(const std::string& label) { return platform_ffi::is_valid_username(label); } + +bool IsContestedUsername(const std::string& label) { return platform_ffi::is_contested_username(label); } + +Identifier SystemContractId(SystemContract which) +{ + // The ids are protocol constants of compiled-in contracts; the bridge + // only fails for an unknown enumerator, which cannot be passed here. + return platform_ffi::system_contract_id(static_cast(which)); +} + +uint64_t CreditsPerDuff() { return platform_ffi::credits_per_duff(); } + +util::Result> Dip15DecryptXpub(const std::array& shared_secret, + const std::vector& ciphertext) +{ + try { + const platform_ffi::CompactXpub xpub{ + platform_ffi::dip15_decrypt_xpub(shared_secret, + rust::Slice{ciphertext.data(), ciphertext.size()})}; + std::array out; + auto it{std::copy(xpub.parent_fingerprint.begin(), xpub.parent_fingerprint.end(), out.begin())}; + it = std::copy(xpub.chain_code.begin(), xpub.chain_code.end(), it); + std::copy(xpub.public_key.begin(), xpub.public_key.end(), it); + return out; + } catch (const std::exception& e) { + return util::Error{Untranslated(e.what())}; + } +} + +uint32_t Dip15AccountReferenceFromMac(const std::array& mac, uint32_t account_index, uint32_t version) +{ + return platform_ffi::dip15_account_reference_from_mac(mac, account_index, version); +} + +AccountReference Dip15UnmaskAccountReference(const std::array& mac, uint32_t reference) +{ + const platform_ffi::AccountRef unmasked{platform_ffi::dip15_unmask_account_reference_from_mac(mac, reference)}; + return {unmasked.version, unmasked.account_index}; +} + +std::optional Dip15SelectRecipientKey(const Identity& identity) +{ + try { + return platform_ffi::dip15_select_recipient_key(marshal::ToFfi(identity)); + } catch (const std::exception&) { + return std::nullopt; + } +} + +bool Dip15ReceiveKeysAcceptable(IdentityPublicKey::Purpose sender_purpose, IdentityPublicKey::Purpose recipient_purpose, + uint32_t recipient_key_id) +{ + return platform_ffi::dip15_receive_keys_acceptable(static_cast(sender_purpose), + static_cast(recipient_purpose), recipient_key_id); +} + +} // namespace platform::helpers diff --git a/src/platform/helpers.h b/src/platform/helpers.h new file mode 100644 index 000000000000..3996a3d9ba57 --- /dev/null +++ b/src/platform/helpers.h @@ -0,0 +1,61 @@ +// 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_HELPERS_H +#define BITCOIN_PLATFORM_HELPERS_H + +#include +#include + +#include +#include +#include +#include +#include + +//! Pure helpers over the SDK shell: the DPNS label rules, protocol +//! constants and the DIP-15 pieces that need nothing beyond the 32-byte +//! outputs the wallet hands over. No protocol byte is computed in C++. +namespace platform::helpers { + +enum class SystemContract : uint8_t { + DPNS = 0, + DASHPAY = 1 +}; + +//! Homograph-safe normalization of a DPNS label (o->0, i/l->1, lower case). +std::string NormalizeLabel(const std::string& label); +bool IsValidUsername(const std::string& label); +//! Whether a label is contested (masternode vote) under the DPNS contract. +bool IsContestedUsername(const std::string& label); + +Identifier SystemContractId(SystemContract which); +uint64_t CreditsPerDuff(); + +//! Decrypts a contact request's encryptedPublicKey with the ECDH secret the +//! wallet derived, into the 69-byte DIP-15 compact xpub. +util::Result> Dip15DecryptXpub(const std::array& shared_secret, + const std::vector& ciphertext); + +//! DIP-15 accountReference masking over the MAC the wallet computed. +uint32_t Dip15AccountReferenceFromMac(const std::array& mac, uint32_t account_index, uint32_t version); +struct AccountReference { + uint32_t version{0}; + uint32_t account_index{0}; +}; +AccountReference Dip15UnmaskAccountReference(const std::array& mac, uint32_t reference); + +//! The recipient key a contact request to identity should reference, per +//! the SDK's mint-side purpose policy; nullopt when it has none. +std::optional Dip15SelectRecipientKey(const Identity& identity); + +//! Whether an inbound contact request's key references are acceptable for +//! the ECDH that unwraps its encryptedPublicKey: the SDK's receive-side +//! purpose policy plus "never ECDH with the MASTER key". +bool Dip15ReceiveKeysAcceptable(IdentityPublicKey::Purpose sender_purpose, IdentityPublicKey::Purpose recipient_purpose, + uint32_t recipient_key_id); + +} // namespace platform::helpers + +#endif // BITCOIN_PLATFORM_HELPERS_H diff --git a/src/platform/marshal.cpp b/src/platform/marshal.cpp new file mode 100644 index 000000000000..b0203b13388c --- /dev/null +++ b/src/platform/marshal.cpp @@ -0,0 +1,322 @@ +// 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 + +namespace platform::marshal { + +namespace { + +std::vector Bytes(const rust::Vec& in) { return {in.begin(), in.end()}; } + +rust::Vec RustBytes(const std::vector& in) +{ + rust::Vec out; + out.reserve(in.size()); + for (const uint8_t byte : in) + out.push_back(byte); + return out; +} + +bool ValueIsMeaningful(const Status& status) +{ + return status.kind == StatusKind::OK || status.kind == StatusKind::UNSUPPORTED_PROTOCOL_VERSION; +} + +template +Result Verified(const V& verified, const Fn& fill) +{ + Result out; + out.status = FromFfi(verified.status); + out.metadata = FromFfi(verified.meta); + if (ValueIsMeaningful(out.status)) out.value = fill(); + return out; +} + +template +Result> VerifiedPage(const V& verified) +{ + return Verified>(verified, [&] { + Paged page; + page.items.reserve(verified.items.size()); + for (const auto& item : verified.items) + page.items.push_back(FromFfi(item)); + page.next_start_after = verified.page.next_start_after; + page.has_more = verified.page.has_more; + return page; + }); +} + +} // namespace + +Status FromFfi(const platform_ffi::Status& status) +{ + Status out; + // A kind a newer shell adds is still a failure this build cannot act on. + const auto kind{static_cast(status.kind)}; + out.kind = kind <= static_cast(StatusKind::INTERNAL) ? static_cast(kind) : StatusKind::INTERNAL; + out.consensus_code = status.consensus_code; + out.message = std::string(status.message); + return out; +} + +ResponseMetadata FromFfi(const platform_ffi::Meta& meta) +{ + ResponseMetadata out; + out.height = meta.height; + out.core_chain_locked_height = meta.core_chain_locked_height; + out.time_ms = meta.time_ms; + out.protocol_version = meta.protocol_version; + return out; +} + +ContractBounds FromFfi(const platform_ffi::ContractBounds& bounds) +{ + ContractBounds out; + out.kind = static_cast(bounds.kind); + out.contract_id = bounds.contract_id; + out.document_type = std::string(bounds.document_type); + return out; +} + +IdentityPublicKey FromFfi(const platform_ffi::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); + if (key.disabled_at != 0) out.disabled_at = key.disabled_at; + out.contract_bounds = FromFfi(key.bounds); + return out; +} + +Identity FromFfi(const platform_ffi::Identity& identity) +{ + Identity out; + out.id = identity.id; + out.balance = identity.balance; + out.revision = identity.revision; + out.public_keys.reserve(identity.keys.size()); + for (const auto& key : identity.keys) + out.public_keys.push_back(FromFfi(key)); + return out; +} + +DpnsName FromFfi(const platform_ffi::DpnsName& name) +{ + DpnsName out; + out.label = std::string(name.label); + out.normalized_label = std::string(name.normalized_label); + out.parent_domain = std::string(name.parent); + out.identity = name.identity; + out.document_id = name.document_id; + out.owner_id = name.owner; + return out; +} + +Profile FromFfi(const platform_ffi::Profile& profile) +{ + Profile out; + out.document_id = profile.document_id; + out.owner_id = profile.owner; + out.revision = profile.revision; + out.display_name = std::string(profile.display_name); + out.public_message = std::string(profile.public_message); + out.avatar_url = std::string(profile.avatar_url); + out.avatar_hash = Bytes(profile.avatar_hash); + out.avatar_fingerprint = Bytes(profile.avatar_fingerprint); + out.core_payment_address = Bytes(profile.core_payment_address); + out.platform_payment_address = Bytes(profile.platform_payment_address); + out.shielded_address = Bytes(profile.shielded_address); + out.created_at = profile.created_at; + out.updated_at = profile.updated_at; + return out; +} + +ContactRequest FromFfi(const platform_ffi::ContactRequest& request) +{ + ContactRequest out; + out.document_id = request.document_id; + out.owner_id = 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 = request.created_at; + out.core_height_created_at = request.core_height_created_at; + return out; +} + +ContestedNameState FromFfi(const platform_ffi::ContestedState& state) +{ + ContestedNameState out; + out.contenders.reserve(state.contenders.size()); + for (const auto& contender : state.contenders) { + out.contenders.push_back({contender.identity, contender.has_votes ? contender.votes : 0}); + } + out.abstain_votes = state.abstain; + out.lock_votes = state.lock; + switch (state.winner_kind) { + case platform_ffi::WinnerKind::WonByIdentity: + out.outcome = ContestedNameState::Outcome::WON; + out.winner = state.winner; + break; + case platform_ffi::WinnerKind::Locked: + out.outcome = ContestedNameState::Outcome::LOCKED; + break; + case platform_ffi::WinnerKind::NoWinner: + out.outcome = ContestedNameState::Outcome::OPEN; + break; + } + out.ends_at = state.ends_at; + return out; +} + +Built FromFfi(const platform_ffi::Built& built) +{ + Built out; + out.bytes = Bytes(built.bytes); + out.hash = uint256{built.hash}; + out.object_id = built.object_id; + return out; +} + +Result FromFfi(const platform_ffi::VerifiedIdentity& verified) +{ + return Verified(verified, [&] { return FromFfi(verified.value); }); +} + +Result FromFfi(const platform_ffi::VerifiedU64& verified) +{ + return Verified(verified, [&] { return verified.value; }); +} + +Result FromFfi(const platform_ffi::VerifiedDpnsName& verified) +{ + return Verified(verified, [&] { return FromFfi(verified.value); }); +} + +Result> FromFfi(const platform_ffi::VerifiedDpnsNames& verified) +{ + return VerifiedPage(verified); +} + +Result FromFfi(const platform_ffi::VerifiedProfile& verified) +{ + return Verified(verified, [&] { return FromFfi(verified.value); }); +} + +Result> FromFfi(const platform_ffi::VerifiedContactRequests& verified) +{ + return VerifiedPage(verified); +} + +Result FromFfi(const platform_ffi::VerifiedContested& verified) +{ + return Verified(verified, [&] { return FromFfi(verified.value); }); +} + +platform_ffi::ContractBounds ToFfi(const ContractBounds& bounds) +{ + platform_ffi::ContractBounds out; + out.kind = static_cast(bounds.kind); + out.contract_id = bounds.contract_id; + out.document_type = bounds.document_type; + return out; +} + +platform_ffi::IdentityKey ToFfi(const IdentityPublicKey& key) +{ + platform_ffi::IdentityKey out; + out.id = key.id; + out.purpose = static_cast(key.purpose); + out.security_level = static_cast(key.security_level); + out.key_type = static_cast(key.type); + out.read_only = key.read_only; + out.data = RustBytes(key.data); + out.disabled_at = key.disabled_at.value_or(0); + out.bounds = ToFfi(key.contract_bounds); + return out; +} + +platform_ffi::Identity ToFfi(const Identity& identity) +{ + platform_ffi::Identity out; + out.id = identity.id; + out.balance = identity.balance; + out.revision = identity.revision; + out.keys.reserve(identity.public_keys.size()); + for (const auto& key : identity.public_keys) + out.keys.push_back(ToFfi(key)); + return out; +} + +platform_ffi::Profile ToFfi(const Profile& profile) +{ + platform_ffi::Profile out; + out.document_id = profile.document_id; + out.owner = profile.owner_id; + out.revision = profile.revision; + out.display_name = profile.display_name; + out.public_message = profile.public_message; + out.avatar_url = profile.avatar_url; + out.avatar_hash = RustBytes(profile.avatar_hash); + out.avatar_fingerprint = RustBytes(profile.avatar_fingerprint); + out.core_payment_address = RustBytes(profile.core_payment_address); + out.platform_payment_address = RustBytes(profile.platform_payment_address); + out.shielded_address = RustBytes(profile.shielded_address); + out.created_at = profile.created_at; + out.updated_at = profile.updated_at; + return out; +} + +platform_ffi::NewIdentityKey ToFfi(const NewIdentityKey& key) +{ + platform_ffi::NewIdentityKey out; + out.id = key.id; + out.purpose = static_cast(key.purpose); + out.security_level = static_cast(key.security_level); + std::copy(key.pubkey.begin(), key.pubkey.end(), out.pubkey.begin()); + out.bounds = ToFfi(key.contract_bounds); + return out; +} + +platform_ffi::AssetLockProofInput ToFfi(const AssetLockProof& proof) +{ + platform_ffi::AssetLockProofInput out; + out.is_instant = proof.is_instant; + out.transaction = RustBytes(proof.transaction); + out.instant_lock = RustBytes(proof.instant_lock); + out.output_index = proof.output_index; + out.core_chain_locked_height = proof.core_chain_locked_height; + out.out_point = proof.out_point; + return out; +} + +platform_ffi::Proxy ToFfi(const std::optional& proxy) +{ + platform_ffi::Proxy out; + out.kind = 0; + out.isolate = false; + if (!proxy) return out; + if (const auto* service{std::get_if(&proxy->address)}) { + out.kind = 1; + out.address = service->ToStringAddrPort(); + } else { + out.kind = 2; + out.address = std::get(proxy->address); + } + out.isolate = proxy->randomize_credentials; + return out; +} + +} // namespace platform::marshal diff --git a/src/platform/marshal.h b/src/platform/marshal.h new file mode 100644 index 000000000000..8531189682ea --- /dev/null +++ b/src/platform/marshal.h @@ -0,0 +1,52 @@ +// 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 + +//! Conversions between the bridge structs of dash-platform-cxx and the +//! platform:: types the rest of Core uses. Only the client library includes +//! this header; nothing above it sees the bridge. +namespace platform::marshal { + +Status FromFfi(const platform_ffi::Status& status); +ResponseMetadata FromFfi(const platform_ffi::Meta& meta); +ContractBounds FromFfi(const platform_ffi::ContractBounds& bounds); +IdentityPublicKey FromFfi(const platform_ffi::IdentityKey& key); +Identity FromFfi(const platform_ffi::Identity& identity); +DpnsName FromFfi(const platform_ffi::DpnsName& name); +Profile FromFfi(const platform_ffi::Profile& profile); +ContactRequest FromFfi(const platform_ffi::ContactRequest& request); +ContestedNameState FromFfi(const platform_ffi::ContestedState& state); +Built FromFfi(const platform_ffi::Built& built); + +//! A verified read: the value is filled under OK and +//! UNSUPPORTED_PROTOCOL_VERSION, the metadata always. +Result FromFfi(const platform_ffi::VerifiedIdentity& verified); +Result FromFfi(const platform_ffi::VerifiedU64& verified); +Result FromFfi(const platform_ffi::VerifiedDpnsName& verified); +Result> FromFfi(const platform_ffi::VerifiedDpnsNames& verified); +Result FromFfi(const platform_ffi::VerifiedProfile& verified); +Result> FromFfi(const platform_ffi::VerifiedContactRequests& verified); +Result FromFfi(const platform_ffi::VerifiedContested& verified); + +platform_ffi::ContractBounds ToFfi(const ContractBounds& bounds); +platform_ffi::IdentityKey ToFfi(const IdentityPublicKey& key); +platform_ffi::Identity ToFfi(const Identity& identity); +platform_ffi::Profile ToFfi(const Profile& profile); +platform_ffi::NewIdentityKey ToFfi(const NewIdentityKey& key); +platform_ffi::AssetLockProofInput ToFfi(const AssetLockProof& proof); +//! nullopt is kind 0 (connect directly). +platform_ffi::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..0e7eae920f70 --- /dev/null +++ b/src/platform/signer.cpp @@ -0,0 +1,122 @@ +// 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 { + +namespace { + +constexpr size_t COMPACT_SIGNATURE_SIZE{65}; + +const char* KindName(OperationKind kind) +{ + switch (kind) { + case OperationKind::IDENTITY_CREATE: + return "identity create"; + case OperationKind::DPNS_PREORDER: + return "dpns preorder"; + case OperationKind::DPNS_DOMAIN: + return "dpns domain"; + case OperationKind::PROFILE: + return "profile"; + case OperationKind::CONTACT_REQUEST: + return "contact request"; + } + return "unknown"; +} + +uint8_t ExpectedVariant(OperationKind kind) +{ + return kind == OperationKind::IDENTITY_CREATE ? STATE_TRANSITION_VARIANT_IDENTITY_CREATE + : STATE_TRANSITION_VARIANT_BATCH; +} + +} // namespace + +SigningOperation::SigningOperation(interfaces::Wallet& wallet, OperationKind kind, std::vector key_ids, + std::optional document_key, + std::optional funding_key, + std::unique_ptr unlock) : + m_wallet(wallet), + m_kind(kind), + m_key_ids(std::move(key_ids)), + m_document_key(std::move(document_key)), + m_funding_key(funding_key), + m_unlock(std::move(unlock)) +{ +} + +SigningOperation::SigningOperation(SigningOperation&& other) noexcept : + m_wallet(other.m_wallet), + m_kind(other.m_kind), + m_key_ids(std::move(other.m_key_ids)), + m_document_key(std::move(other.m_document_key)), + m_funding_key(std::move(other.m_funding_key)), + // The moved-from operation can no longer claim the asset-lock signature. + m_asset_lock_signed(other.m_asset_lock_signed.exchange(true)), + m_unlock(std::move(other.m_unlock)) +{ +} + +bool SigningOperation::allowsKey(uint32_t key_id) const +{ + return std::find(m_key_ids.begin(), m_key_ids.end(), key_id) != m_key_ids.end(); +} + +bool SigningOperation::claimAssetLockSignature() const { return m_funding_key && !m_asset_lock_signed.exchange(true); } + +bool WalletSigner::signForKey(uint32_t key_id, Span signable, std::vector& signature_out) const +{ + const OperationKind kind{m_operation.kind()}; + if (!m_operation.allowsKey(key_id)) { + LogPrintf("Platform signer: refusing key %u outside the %s operation\n", key_id, KindName(kind)); + return false; + } + if (signable.empty() || signable[0] != ExpectedVariant(kind)) { + LogPrintf("Platform signer: refusing a preimage of %u bytes that is not a %s transition\n", signable.size(), + KindName(kind)); + return false; + } + uint256 digest; + CHash256().Write(signable).Finalize(digest); + auto result{m_operation.wallet().signPlatformDigest(wallet::IdentityAuthKey{0, key_id}, digest)}; + if (!result || result.value.size() != COMPACT_SIGNATURE_SIZE) { + LogPrintf("Platform signer: the wallet did not sign the %s transition with key %u (status %d)\n", + KindName(kind), key_id, static_cast(result.status)); + return false; + } + LogPrint(BCLog::PLATFORM, "Platform signer: signed %s transition (%u bytes) with key %u\n", KindName(kind), + signable.size(), key_id); + signature_out = std::move(result.value); + return true; +} + +bool WalletSigner::signAssetLockSighash(const std::array& sighash, std::vector& signature_out) const +{ + const auto& funding_key{m_operation.fundingKey()}; + if (!m_operation.claimAssetLockSignature()) { + LogPrintf("Platform signer: refusing a%s asset lock signature for the %s operation\n", + funding_key ? " second" : "n", KindName(m_operation.kind())); + return false; + } + auto result{m_operation.wallet().signPlatformDigest(*funding_key, uint256{sighash})}; + if (!result || result.value.size() != COMPACT_SIGNATURE_SIZE) { + LogPrintf("Platform signer: the wallet did not sign the asset lock (status %d)\n", static_cast(result.status)); + return false; + } + LogPrint(BCLog::PLATFORM, "Platform signer: signed the asset lock sighash with funding key %u\n", + funding_key->identity_index); + signature_out = std::move(result.value); + return true; +} + +} // namespace platform diff --git a/src/platform/signer.h b/src/platform/signer.h new file mode 100644 index 000000000000..36a66116d0d7 --- /dev/null +++ b/src/platform/signer.h @@ -0,0 +1,124 @@ +// 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 +#include +#include +#include + +class PlatformService; + +namespace interfaces { +class Wallet; +} // namespace interfaces + +namespace platform { + +//! Keeps the wallet unlocked for as long as a SigningOperation lives. The Qt +//! layer wraps WalletModel::UnlockContext; this library never sees Qt. +class UnlockScope +{ +public: + virtual ~UnlockScope() = default; +}; + +enum class OperationKind : uint8_t { + IDENTITY_CREATE, + DPNS_PREORDER, + DPNS_DOMAIN, + PROFILE, + CONTACT_REQUEST, +}; + +//! The bincode variant index a serialized StateTransition starts with, by +//! declaration order of rs-dpp's StateTransition enum (not the +//! StateTransitionType numbering). Pinned by dash-platform-cxx's +//! test_data/state_transition_first_byte.json. +inline constexpr uint8_t STATE_TRANSITION_VARIANT_BATCH{2}; +inline constexpr uint8_t STATE_TRANSITION_VARIANT_IDENTITY_CREATE{3}; + +//! A user-initiated, unlocked, kind-scoped signing operation: the only thing +//! the state-transition builders accept. It is move-only and can only be +//! minted by PlatformService, so a builder cannot run outside an operation +//! the user started, and the wallet is relocked when the operation ends, +//! which the flows do before any network wait. +class SigningOperation +{ +public: + SigningOperation(SigningOperation&& other) noexcept; + SigningOperation(const SigningOperation&) = delete; + SigningOperation& operator=(const SigningOperation&) = delete; + SigningOperation& operator=(SigningOperation&&) = delete; + ~SigningOperation() = default; + + OperationKind kind() const { return m_kind; } + interfaces::Wallet& wallet() const { return m_wallet; } + //! The identity keys the operation may sign with. + const std::vector& keyIds() const { return m_key_ids; } + bool allowsKey(uint32_t key_id) const; + //! The identity key document transitions are signed with; unset for an + //! identity registration, which signs with every key it registers. + const std::optional& documentKey() const { return m_document_key; } + //! The asset-lock funding key of an identity registration, accepted for + //! exactly one signature; unset for every other kind. + const std::optional& fundingKey() const { return m_funding_key; } + //! Claims the single asset-lock signature; false once it was claimed. + bool claimAssetLockSignature() const; + +private: + friend class ::PlatformService; + //! Test access to the constructor, as ConnmanTestMsg for CConnman. + friend struct SigningOperationTestAccess; + + SigningOperation(interfaces::Wallet& wallet, OperationKind kind, std::vector key_ids, + std::optional document_key, + std::optional funding_key, std::unique_ptr unlock); + + interfaces::Wallet& m_wallet; + OperationKind m_kind; + std::vector m_key_ids; + std::optional m_document_key; + std::optional m_funding_key; + mutable std::atomic_bool m_asset_lock_signed{false}; + std::unique_ptr m_unlock; +}; + +//! The wallet's answer to the SDK builders, bound to one operation. Signable +//! bytes come in and 65-byte compact recoverable signatures go out; the +//! double SHA256 is computed here, the operation kind is checked against the +//! first byte of the preimage, and keys outside the operation are refused. +//! Private keys never leave the wallet. Callable from any thread: the wallet +//! seams take cs_wallet, and the builders call it on the calling thread. +class WalletSigner +{ +public: + explicit WalletSigner(const SigningOperation& operation) : + m_operation(operation) + { + } + + //! Signs the full signable preimage of a state transition with identity + //! key key_id. Refuses a key outside the operation, a preimage whose + //! variant byte does not match the operation's kind, or a locked wallet. + bool signForKey(uint32_t key_id, Span signable, std::vector& signature_out) const; + + //! Signs the asset-lock sighash with the operation's funding key, once. + bool signAssetLockSighash(const std::array& sighash, std::vector& signature_out) const; + +private: + const SigningOperation& m_operation; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_SIGNER_H diff --git a/src/platform/st.cpp b/src/platform/st.cpp new file mode 100644 index 000000000000..720eaefa8ae1 --- /dev/null +++ b/src/platform/st.cpp @@ -0,0 +1,145 @@ +// 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 + +namespace platform::st { + +namespace { + +//! The bridge's signer over the operation's WalletSigner. The bridge calls +//! it synchronously on this thread; C++ exceptions are swallowed into a +//! refusal by the bridge header. +platform_ffi::WalletSigner BridgeSigner(const WalletSigner& signer) +{ + return platform_ffi::WalletSigner( + [&signer](uint32_t key_id, std::span signable, std::vector& sig_out) { + return signer.signForKey(key_id, Span{signable.data(), signable.size()}, sig_out); + }, + [&signer](const std::array& sighash, std::vector& sig_out) { + return signer.signAssetLockSighash(sighash, sig_out); + }); +} + +template +util::Result Build(const SigningOperation& op, OperationKind kind, const Fn& fn) +{ + if (op.kind() != kind) { + return util::Error{Untranslated("state transition builder called under the wrong signing operation")}; + } + const WalletSigner signer{op}; + const platform_ffi::WalletSigner bridge_signer{BridgeSigner(signer)}; + try { + return marshal::FromFfi(fn(bridge_signer)); + } catch (const std::exception& e) { + return util::Error{Untranslated(e.what())}; + } +} + +//! The identity key document transitions are signed with; the operation +//! carries it from the proved identity read. +util::Result DocumentKey(const SigningOperation& op) +{ + if (!op.documentKey()) return util::Error{Untranslated("signing operation carries no document key")}; + return marshal::ToFfi(*op.documentKey()); +} + +} // namespace + +util::Result BuildIdentityCreate(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const AssetLockProof& proof, const std::vector& keys) +{ + std::vector ffi_keys; + ffi_keys.reserve(keys.size()); + for (const auto& key : keys) { + if (!key.pubkey.IsCompressed()) { + return util::Error{Untranslated("identity keys must be compressed public keys")}; + } + ffi_keys.push_back(marshal::ToFfi(key)); + } + const platform_ffi::AssetLockProofInput ffi_proof{marshal::ToFfi(proof)}; + return Build(op, OperationKind::IDENTITY_CREATE, [&](const platform_ffi::WalletSigner& signer) { + return sdk.build_identity_create(ffi_proof, + rust::Slice{ffi_keys.data(), ffi_keys.size()}, + signer); + }); +} + +util::Result BuildDpnsPreorder(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const std::string& label, + const std::array& salt) +{ + auto key{DocumentKey(op)}; + if (!key) return util::Error{util::ErrorString(key)}; + return Build(op, OperationKind::DPNS_PREORDER, [&](const platform_ffi::WalletSigner& signer) { + return sdk.build_dpns_preorder(owner, nonce, label, salt, *key, signer); + }); +} + +util::Result BuildDpnsDomain(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const std::string& label, + const std::array& salt) +{ + auto key{DocumentKey(op)}; + if (!key) return util::Error{util::ErrorString(key)}; + return Build(op, OperationKind::DPNS_DOMAIN, [&](const platform_ffi::WalletSigner& signer) { + return sdk.build_dpns_domain(owner, nonce, label, salt, *key, signer); + }); +} + +util::Result BuildProfile(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const Profile& existing, + const ProfileInput& input) +{ + auto key{DocumentKey(op)}; + if (!key) return util::Error{util::ErrorString(key)}; + const platform_ffi::Profile ffi_existing{marshal::ToFfi(existing)}; + platform_ffi::ProfileInput ffi_input; + ffi_input.display_name = input.display_name; + ffi_input.public_message = input.public_message; + return Build(op, OperationKind::PROFILE, [&](const platform_ffi::WalletSigner& signer) { + return sdk.build_profile(owner, nonce, ffi_existing, ffi_input, *key, signer); + }); +} + +util::Result BuildContactRequest(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identity& sender, const Identity& recipient, uint64_t nonce, + const ContactRequestInput& input) +{ + auto key{DocumentKey(op)}; + if (!key) return util::Error{util::ErrorString(key)}; + if (!input.recipient_pubkey.IsCompressed()) { + return util::Error{Untranslated("the recipient key is not a compressed public key")}; + } + const platform_ffi::Identity ffi_sender{marshal::ToFfi(sender)}; + const platform_ffi::Identity ffi_recipient{marshal::ToFfi(recipient)}; + platform_ffi::ContactRequestInput ffi_input; + ffi_input.to_user_id = input.to_user_id; + ffi_input.sender_key_index = input.sender_key_index; + ffi_input.recipient_key_index = input.recipient_key_index; + std::copy(input.recipient_pubkey.begin(), input.recipient_pubkey.end(), ffi_input.recipient_pubkey.begin()); + ffi_input.account_reference = input.account_reference; + std::copy_n(input.compact_xpub.begin(), 4, ffi_input.compact_xpub.parent_fingerprint.begin()); + std::copy_n(input.compact_xpub.begin() + 4, 32, ffi_input.compact_xpub.chain_code.begin()); + std::copy_n(input.compact_xpub.begin() + 36, 33, ffi_input.compact_xpub.public_key.begin()); + ffi_input.shared_secret = input.shared_secret; + ffi_input.account_label = input.account_label; + auto built{Build(op, OperationKind::CONTACT_REQUEST, [&](const platform_ffi::WalletSigner& signer) { + return sdk.build_contact_request(ffi_sender, ffi_recipient, nonce, ffi_input, *key, signer); + })}; + // The bridge zeroizes its own copies of the secret; this one is ours. + memory_cleanse(ffi_input.shared_secret.data(), ffi_input.shared_secret.size()); + return built; +} + +} // namespace platform::st diff --git a/src/platform/st.h b/src/platform/st.h new file mode 100644 index 000000000000..6ff027df3604 --- /dev/null +++ b/src/platform/st.h @@ -0,0 +1,60 @@ +// 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_ST_H +#define BITCOIN_PLATFORM_ST_H + +#include +#include +#include + +#include +#include +#include +#include + +namespace platform_ffi { +struct PlatformClient; +} // namespace platform_ffi + +//! Thin adapters over the SDK's state-transition builders. Every builder +//! takes a SigningOperation, so it can only run inside a user-initiated, +//! unlocked, kind-scoped operation, and signs through the WalletSigner the +//! operation binds. The builders run to completion on the calling thread +//! with no network access; the SDK assembles, serializes and signs the +//! transition under the protocol version it has verified the network runs. +namespace platform::st { + +//! Registers keys funded by the asset lock; every key signs its own +//! possession proof and the funding key signs the asset lock once. +util::Result BuildIdentityCreate(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const AssetLockProof& proof, const std::vector& keys); + +//! DPNS preorder document for label under the given salt, which the caller +//! persists before broadcasting so the domain step can reuse it. +util::Result BuildDpnsPreorder(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const std::string& label, + const std::array& salt); + +//! DPNS domain document for label with the salt of its preorder. +util::Result BuildDpnsDomain(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const std::string& label, + const std::array& salt); + +//! Creates the profile when existing.document_id is all zero, otherwise +//! replaces existing at its revision + 1, keeping every field input does +//! not edit. +util::Result BuildProfile(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identifier& owner, uint64_t nonce, const Profile& existing, + const ProfileInput& input); + +//! DashPay contactRequest from sender to recipient; the SDK encrypts the +//! compact xpub with the ECDH secret the wallet derived. +util::Result BuildContactRequest(const platform_ffi::PlatformClient& sdk, const SigningOperation& op, + const Identity& sender, const Identity& recipient, uint64_t nonce, + const ContactRequestInput& input); + +} // namespace platform::st + +#endif // BITCOIN_PLATFORM_ST_H diff --git a/src/platform/types.h b/src/platform/types.h new file mode 100644 index 000000000000..f105db377111 --- /dev/null +++ b/src/platform/types.h @@ -0,0 +1,269 @@ +// 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 +#include +#include +#include + +namespace platform { + +using Identifier = std::array; + +//! Outcome class of a Platform client call, as the SDK shell classifies it. +//! Every failure is typed by kind; the message is for logs only. +enum class StatusKind : uint8_t { + OK = 0, + //! The proof shows the queried object does not exist. The only path to + //! "available", "no contacts" and "not registered". + PROVEN_ABSENT = 1, + //! Broadcast: a node already holds this state transition. + ALREADY_EXISTS = 2, + //! Broadcast: a node rejected the state transition with a consensus + //! error carried in Status::consensus_code. + CONSENSUS = 3, + //! No endpoints, no local ChainLock anchor, the client is shut down, a + //! transport failure or timeout, or every address is banned. + UNAVAILABLE = 4, + //! A proof, signature, quorum or freshness check refused the response, + //! or a node definitively refused the request. + REJECTED = 5, + //! A verified response from a protocol version this build does not + //! know: the value is still returned, writes must stop until an update. + UNSUPPORTED_PROTOCOL_VERSION = 6, + //! A bug: bad input, a contained panic, or an unclassified SDK error. + INTERNAL = 7, +}; + +struct Status { + StatusKind kind{StatusKind::INTERNAL}; + uint32_t consensus_code{0}; + std::string message; +}; + +//! Metadata every proved response is verified against. All fields are +//! covered by the Tenderdash quorum signature. +struct ResponseMetadata { + uint64_t height{0}; //!< platform block height + uint32_t core_chain_locked_height{0}; + uint64_t time_ms{0}; + uint32_t protocol_version{0}; +}; + +//! Outcome of a proved read. The value is present under OK and, so the GUI +//! can still show it, under UNSUPPORTED_PROTOCOL_VERSION; absent otherwise. +template +struct Result { + Status status; + ResponseMetadata metadata; + std::optional value; + + bool ok() const { return status.kind == StatusKind::OK; } + bool provenAbsent() const { return status.kind == StatusKind::PROVEN_ABSENT; } +}; + +//! Cursor of a paged read: pass next_start_after to the next call while +//! has_more. One client call is one page. +template +struct Paged { + std::vector items; + Identifier next_start_after{}; + bool has_more{false}; +}; + +//! Contract bounds of an identity key. +struct ContractBounds { + enum class Kind : uint8_t { + NONE = 0, + SINGLE_CONTRACT = 1, + SINGLE_CONTRACT_DOCUMENT_TYPE = 2, + CONTRACT_GROUP = 3 + }; + Kind kind{Kind::NONE}; + Identifier contract_id{}; + std::string document_type; +}; + +//! Identity public key record (DPP IdentityPublicKey, subset the GUI needs). +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, + VOTING = 5 + }; + 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; //!< serialized public key + std::optional disabled_at; + ContractBounds contract_bounds; +}; + +//! A Platform identity as the GUI sees it. +struct Identity { + Identifier id{}; + uint64_t balance{0}; //!< platform credits + uint64_t revision{0}; + std::vector public_keys; +}; + +//! A resolved DPNS name (domain document subset). +struct DpnsName { + std::string label; //!< as registered, e.g. "Alice" + std::string normalized_label; //!< homograph-safe lower-case, e.g. "a11ce" + std::string parent_domain; //!< normalized parent, e.g. "dash" + Identifier identity{}; //!< records.identity + Identifier document_id{}; + Identifier owner_id{}; +}; + +//! DashPay profile document. The GUI edits and renders the display name and +//! public message; the remaining fields are carried so a replace keeps what +//! another wallet set. +struct Profile { + Identifier document_id{}; + Identifier owner_id{}; + uint64_t revision{0}; + std::string display_name; + std::string public_message; + std::string avatar_url; + std::vector avatar_hash; + std::vector avatar_fingerprint; + std::vector core_payment_address; + std::vector platform_payment_address; + std::vector shielded_address; + uint64_t created_at{0}; //!< ms since epoch + uint64_t updated_at{0}; +}; + +//! DashPay contactRequest document. +struct ContactRequest { + Identifier document_id{}; + Identifier owner_id{}; //!< sender identity + Identifier to_user_id{}; //!< recipient identity + std::vector encrypted_public_key; //!< 96B: IV(16) || AES-CBC(DIP-15 compact xpub) + uint32_t sender_key_index{0}; + uint32_t recipient_key_index{0}; + uint32_t account_reference{0}; + std::vector encrypted_account_label; //!< optional + std::vector auto_accept_proof; //!< optional + uint64_t created_at{0}; //!< ms since epoch, sender-authored + uint32_t core_height_created_at{0}; +}; + +//! Contested-resource (premium username) vote state. A proven absence of +//! the contest is reported through Result::provenAbsent(). +struct ContestedNameState { + enum class Outcome : uint8_t { + OPEN = 0, + WON = 1, + LOCKED = 2 + }; + struct Contender { + Identifier identity{}; + uint32_t votes{0}; + }; + Outcome outcome{Outcome::OPEN}; + std::vector contenders; + uint32_t abstain_votes{0}; + uint32_t lock_votes{0}; + Identifier winner{}; //!< set when outcome == WON + uint64_t ends_at{0}; //!< ms since epoch of the finalizing block, 0 while open +}; + +//! A signed state transition. hash is its transaction id; object_id is the +//! identity id or document id it creates or replaces. +struct Built { + std::vector bytes; + uint256 hash; + Identifier object_id{}; +}; + +//! Funding of an identity registration: an InstantSend-locked asset lock +//! (consensus-encoded transaction and islock, output_index of the credit +//! output) or a ChainLocked outpoint (txid || vout). +struct AssetLockProof { + bool is_instant{false}; + std::vector transaction; + std::vector instant_lock; + uint32_t output_index{0}; + uint32_t core_chain_locked_height{0}; + std::array out_point{}; +}; + +//! A key to register with a new identity; always ECDSA secp256k1. +struct NewIdentityKey { + uint32_t id{0}; + IdentityPublicKey::Purpose purpose{IdentityPublicKey::Purpose::AUTHENTICATION}; + IdentityPublicKey::SecurityLevel security_level{IdentityPublicKey::SecurityLevel::MASTER}; + CPubKey pubkey; //!< compressed + ContractBounds contract_bounds; +}; + +//! The profile fields the GUI edits. An empty string leaves the field out of +//! the document: a replace carries only these and the fields the GUI does not +//! edit, so it clears an edited field the profile had. +struct ProfileInput { + std::string display_name; + std::string public_message; +}; + +//! A contact request to mint. shared_secret is the ECDH secret between our +//! sender_key_index key and the recipient's key at recipient_key_index, +//! cleansed when the input is destroyed; account_reference is already masked. +struct ContactRequestInput { + ~ContactRequestInput() { memory_cleanse(shared_secret.data(), shared_secret.size()); } + + Identifier to_user_id{}; + uint32_t sender_key_index{0}; + uint32_t recipient_key_index{0}; + CPubKey recipient_pubkey; + uint32_t account_reference{0}; + std::array compact_xpub{}; //!< parentFingerprint || chainCode || pubKey + std::array shared_secret{}; + std::string account_label; +}; + +//! The SOCKS5 proxy every connection of a client is tunnelled through. +struct ProxyConfig { + //! A numeric address, or the path of a Unix socket (without "unix:"). + std::variant address; + //! -proxyrandomize: fresh credentials per connection, so Tor gives each + //! its own circuit. + bool randomize_credentials{true}; + + bool operator==(const ProxyConfig&) const = default; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_TYPES_H diff --git a/src/platform/walletrecords.cpp b/src/platform/walletrecords.cpp new file mode 100644 index 000000000000..1e7556500251 --- /dev/null +++ b/src/platform/walletrecords.cpp @@ -0,0 +1,180 @@ +// 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 + +namespace platform { + +namespace { +//! Whether a persisted state byte names an enumerator. A record from another +//! build is refused rather than mapped onto a step the flow never wrote. +bool KnownState(uint8_t state) +{ + using State = IdentityRecord::State; + switch (static_cast(state)) { + case State::NONE: + case State::FUNDING_SENT: + case State::FUNDING_LOCKED: + case State::IDENTITY_BROADCAST: + case State::IDENTITY_CONFIRMED: + case State::PREORDER_BROADCAST: + case State::PREORDER_WAIT: + case State::DOMAIN_BROADCAST: + case State::REGISTERED: + case State::CONTESTED_PENDING: + case State::NEEDS_UNLOCK: + case State::FAILED: + return true; + } + return false; +} + +bool KnownStatusKind(uint8_t kind) { return kind <= static_cast(StatusKind::INTERNAL); } + +//! The fields written after started_at. A record without any of them ends +//! there, which is also how records written before they existed look. +bool HasExtension(const IdentityRecord& r) +{ + return !r.signed_identity_create.empty() || !r.signed_preorder.empty() || !r.signed_domain.empty() || + !r.signed_profile.empty() || !r.profile_display_name.empty() || r.last_failure.has_value(); +} + +bool CanonicalSigned(const IdentityRecord::SignedTransition& signed_transition) +{ + return !signed_transition.empty() || (signed_transition.nonce == 0 && signed_transition.protocol_version == 0); +} +} // namespace + +std::vector SerializeIdentityRecord(const IdentityRecord& r) +{ + CDataStream s(SER_DISK, CLIENT_VERSION); + s << records::CURRENT_VERSION << static_cast(r.state) << r.funding_txid << r.funding_key_index + << r.funding_amount << r.identity_id << r.auth_key_id << r.encryption_key_id << r.decryption_key_id << r.label + << r.normalized_label << r.preorder_salt << static_cast(r.contested) + << static_cast(r.resume_state) << r.last_error << r.started_at; + if (HasExtension(r)) { + s << r.signed_identity_create; + for (const auto* signed_transition : {&r.signed_preorder, &r.signed_domain, &r.signed_profile}) { + s << signed_transition->bytes << signed_transition->nonce << signed_transition->protocol_version; + } + s << r.profile_display_name << static_cast(r.last_failure.has_value()); + if (r.last_failure) { + const auto& failure{*r.last_failure}; + s << failure.operation << failure.time << static_cast(failure.status.has_value()); + if (failure.status) { + s << static_cast(failure.status->kind) << failure.status->consensus_code + << failure.status->message; + } + } + } + const auto span = MakeUCharSpan(s); + return {span.begin(), span.end()}; +} + +bool DeserializeIdentityRecord(const std::vector& data, IdentityRecord& r) +{ + try { + CDataStream s(data, SER_DISK, CLIENT_VERSION); + uint8_t version{0}, state{0}, contested{0}, resume_state{0}; + s >> version; + if (version != records::CURRENT_VERSION) return false; + IdentityRecord out; + s >> state >> out.funding_txid >> out.funding_key_index >> out.funding_amount >> out.identity_id >> + out.auth_key_id >> out.encryption_key_id >> out.decryption_key_id >> out.label >> out.normalized_label >> + out.preorder_salt >> contested >> resume_state >> out.last_error >> out.started_at; + if (!s.empty()) { + uint8_t has_failure{0}; + s >> out.signed_identity_create; + for (auto* signed_transition : {&out.signed_preorder, &out.signed_domain, &out.signed_profile}) { + s >> signed_transition->bytes >> signed_transition->nonce >> signed_transition->protocol_version; + } + s >> out.profile_display_name >> has_failure; + if (has_failure > 1) return false; + if (has_failure == 1) { + IdentityRecord::Failure failure; + uint8_t has_status{0}; + s >> failure.operation >> failure.time >> has_status; + if (has_status > 1) return false; + if (has_status == 1) { + uint8_t kind{0}; + Status status; + s >> kind >> status.consensus_code >> status.message; + if (!KnownStatusKind(kind)) return false; + status.kind = static_cast(kind); + failure.status = std::move(status); + } + out.last_failure = std::move(failure); + } + if (!HasExtension(out)) return false; + } + // Every byte must be the canonical encoding of a value this build + // writes, so what is accepted serializes back identically. + if (!s.empty() || contested > 1 || !KnownState(state) || !KnownState(resume_state) || + !CanonicalSigned(out.signed_preorder) || !CanonicalSigned(out.signed_domain) || + !CanonicalSigned(out.signed_profile)) { + return false; + } + out.state = static_cast(state); + out.contested = contested == 1; + out.resume_state = static_cast(resume_state); + r = std::move(out); + return true; + } catch (const std::exception&) { + return false; + } +} + +std::vector EncodeContactOutRecord(int64_t created_at) +{ + std::vector out(8); + WriteLE64(out.data(), static_cast(created_at)); + return out; +} + +std::optional DecodeContactOutRecord(const std::vector& data) +{ + if (data.size() != 8) return std::nullopt; + return static_cast(ReadLE64(data.data())); +} + +std::vector EncodePaymentCursor(uint32_t next_index) +{ + std::vector out(4); + WriteLE32(out.data(), next_index); + return out; +} + +uint32_t DecodePaymentCursor(const std::vector& data) +{ + if (data.size() != 4) return 0; + return ReadLE32(data.data()); +} + +uint32_t ComputePaymentCursor(uint32_t gap, const std::function(uint32_t)>& derive, + const std::set& wallet_output_scripts) +{ + uint32_t cursor{0}; + for (uint32_t index = 0; index - cursor < gap; ++index) { + const auto script{derive(index)}; + if (!script) break; + if (wallet_output_scripts.count(*script)) cursor = index + 1; + } + return cursor; +} + +bool IsRecordSetCurrent(const std::vector& version_record, bool have_platform_records) +{ + if (version_record.empty()) return !have_platform_records; + return version_record == EncodeRecordVersion(); +} + +std::vector EncodeRecordVersion() { return {records::CURRENT_VERSION}; } + +} // namespace platform diff --git a/src/platform/walletrecords.h b/src/platform/walletrecords.h new file mode 100644 index 000000000000..330ed93e3c11 --- /dev/null +++ b/src/platform/walletrecords.h @@ -0,0 +1,168 @@ +// 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_WALLETRECORDS_H +#define BITCOIN_PLATFORM_WALLETRECORDS_H + +#include +#include +#include