diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index fdd09c425545..fb5b5f4d4a99 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -170,8 +170,8 @@ jobs: uses: actions/cache/restore@v5 with: path: depends/sources - key: depends-sources-${{ hashFiles('depends/packages/*') }} - restore-keys: depends-sources- + key: depends-sources-v2-${{ hashFiles('depends/packages/*') }} + restore-keys: depends-sources-v2- - name: Restore SDKs cache id: sdk-cache diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52811ebc8276..7dc94ae9e8b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -227,8 +227,7 @@ jobs: if: | vars.SKIP_LINUX64 == '' || vars.SKIP_LINUX64_ASAN == '' || - vars.SKIP_LINUX64_FUZZ == '' || - vars.SKIP_LINUX64_SQLITE == '' + vars.SKIP_LINUX64_FUZZ == '' with: build-target: linux64 container-path: ${{ needs.container.outputs.path }} @@ -263,6 +262,18 @@ jobs: runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} runs-on-check: ${{ needs.check-skip.outputs['runner-check-skip'] }} + depends-linux64_platform_gui: + name: x86_64-pc-linux-gnu_platform_gui + uses: ./.github/workflows/build-depends.yml + needs: [check-skip, container, cache-sources] + if: ${{ vars.SKIP_LINUX64_SQLITE == '' }} + with: + build-target: linux64_platform_gui + container-path: ${{ needs.container.outputs.path }} + base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + runs-on-check: ${{ needs.check-skip.outputs['runner-check-skip'] }} + depends-mac: name: x86_64-apple-darwin uses: ./.github/workflows/build-depends.yml @@ -387,15 +398,15 @@ jobs: src-linux64_sqlite: name: linux64_sqlite-build uses: ./.github/workflows/build-src.yml - needs: [check-skip, container, depends-linux64] + needs: [check-skip, container, depends-linux64_platform_gui] if: ${{ vars.SKIP_LINUX64_SQLITE == '' }} with: build-target: linux64_sqlite container-path: ${{ needs.container.outputs.path }} - depends-key: ${{ needs.depends-linux64.outputs.key }} - depends-host: ${{ needs.depends-linux64.outputs.host }} - depends-dep-opts: ${{ needs.depends-linux64.outputs.dep-opts }} - depends-artifact: ${{ needs.depends-linux64.outputs.built-artifact }} + depends-key: ${{ needs.depends-linux64_platform_gui.outputs.key }} + depends-host: ${{ needs.depends-linux64_platform_gui.outputs.host }} + depends-dep-opts: ${{ needs.depends-linux64_platform_gui.outputs.dep-opts }} + depends-artifact: ${{ needs.depends-linux64_platform_gui.outputs.built-artifact }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} secrets: *remote-ccache-auth diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index d8fb54b67ee9..7681d2a5b9ba 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -31,15 +31,23 @@ jobs: allow-unsafe-pr-checkout: true persist-credentials: false + # v2: caches from before the Platform GUI step below lack its sources. - name: Check for cached sources id: cache-check uses: actions/cache@v5 with: path: depends/sources - key: depends-sources-${{ hashFiles('depends/packages/*') }} - restore-keys: depends-sources- + key: depends-sources-v2-${{ hashFiles('depends/packages/*') }} + restore-keys: depends-sources-v2- lookup-only: true - name: Download sources if: steps.cache-check.outputs.cache-hit != 'true' run: make -C depends download + + # The linux64_platform_gui depends job runs on x86_64, but this runner + # may not: name the build machine, since the Rust toolchain and protoc + # archives are chosen by it. download-linux would not pass BUILD on. + - name: Download Platform GUI sources for x86_64 Linux + if: steps.cache-check.outputs.cache-hit != 'true' + run: make -C depends download-one PLATFORM_GUI=1 BUILD=x86_64-pc-linux-gnu HOST=x86_64-unknown-linux-gnu diff --git a/.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..e7db41d27345 100755 --- a/ci/dash/build_src.sh +++ b/ci/dash/build_src.sh @@ -54,6 +54,10 @@ if [ "${RUN_STDLIB_PATH_CHECK}" = "true" ]; then make -C src --jobs=1 check-stdlib-paths fi +if [ "${RUN_CHECK_NO_RUST}" = "true" ]; then + "${BASE_ROOT_DIR}/contrib/devtools/check-no-rust.py" src/dashd src/dash-cli src/dash-tx src/dash-util src/dash-wallet src/test/fuzz/fuzz +fi + if [ -n "$USE_VALGRIND" ]; then echo "valgrind in USE!" "${BASE_ROOT_DIR}/ci/test/wrap-valgrind.sh" diff --git a/ci/dash/matrix.sh b/ci/dash/matrix.sh index 26ea6d1fc10f..7fdf2b20b849 100755 --- a/ci/dash/matrix.sh +++ b/ci/dash/matrix.sh @@ -30,6 +30,8 @@ elif [ "$BUILD_TARGET" = "linux64_multiprocess" ]; then source ./ci/test/00_setup_env_native_multiprocess.sh elif [ "$BUILD_TARGET" = "linux64_nowallet" ]; then source ./ci/test/00_setup_env_native_nowallet_libbitcoinkernel.sh +elif [ "$BUILD_TARGET" = "linux64_platform_gui" ]; then + source ./ci/test/00_setup_env_native_platform_gui.sh elif [ "$BUILD_TARGET" = "linux64_sqlite" ]; then source ./ci/test/00_setup_env_native_sqlite.sh elif [ "$BUILD_TARGET" = "linux64_tsan" ]; then diff --git a/ci/test/00_setup_env_native_platform_gui.sh b/ci/test/00_setup_env_native_platform_gui.sh new file mode 100755 index 000000000000..6fc4c8cd2d5a --- /dev/null +++ b/ci/test/00_setup_env_native_platform_gui.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +export LC_ALL=C.UTF-8 + +# Depends with dash-sdk-cxx (PLATFORM_GUI=1); linux64_sqlite builds against it. +export CONTAINER_NAME=ci_native_platform_gui +export HOST=x86_64-pc-linux-gnu +export DEP_OPTS="PLATFORM_GUI=1" diff --git a/ci/test/00_setup_env_native_sqlite.sh b/ci/test/00_setup_env_native_sqlite.sh index 8af6d3fd9ae9..32f42e300a7e 100755 --- a/ci/test/00_setup_env_native_sqlite.sh +++ b/ci/test/00_setup_env_native_sqlite.sh @@ -11,3 +11,4 @@ export PACKAGES="python3-zmq qtbase5-dev qttools5-dev-tools libdbus-1-dev libhar export DEP_OPTS="NO_BDB=1 NO_UPNP=1 DEBUG=1" export GOAL="install" export BITCOIN_CONFIG="--enable-zmq --enable-reduce-exports --with-sqlite --without-bdb CC=gcc-11 CXX=g++-11" +export RUN_CHECK_NO_RUST="true" diff --git a/configure.ac b/configure.ac index d36c49ebfdf6..cb2a2126aee9 100644 --- a/configure.ac +++ b/configure.ac @@ -307,6 +307,14 @@ if test "$enable_miner" = "yes"; then AC_DEFINE(ENABLE_MINER, 1, [Define this symbol if in-wallet miner should be enabled]) fi +AC_ARG_ENABLE([platform-gui], + [AS_HELP_STRING([--enable-platform-gui], + [enable Dash Platform (usernames, DashPay contacts) in the GUI; requires the GUI, the wallet and the Dash Platform SDK's C++ binding (dash-sdk-cxx) built by depends with PLATFORM_GUI=1 (default is no)])], + [enable_platform_gui=$enableval], + [enable_platform_gui=no]) +AC_ARG_VAR([PLATFORM_CXX_CFLAGS], [C++ compiler flags for dash-sdk-cxx, overriding pkg-config]) +AC_ARG_VAR([PLATFORM_CXX_LIBS], [Linker flags for dash-sdk-cxx, overriding pkg-config (default: the library and the vetted system libraries from the depends dash_sdk_cxx.pc)]) + dnl Enable different -fsanitize options AC_ARG_WITH([sanitizers], [AS_HELP_STRING([--with-sanitizers], @@ -1926,6 +1934,41 @@ if test "$build_bitcoin_wallet$build_bitcoin_cli$build_bitcoin_tx$build_bitcoin_ AC_MSG_ERROR([No targets! Please specify at least one of: --with-utils --with-libs --with-daemon --with-gui --enable-fuzz(-binary) --enable-bench or --enable-tests]) fi +dnl dash-sdk-cxx, the C++ binding of the Dash Platform SDK, is a Rust static +dnl library linked into dash-qt (and its tests) only. depends installs +dnl dash_sdk_cxx.pc, whose Libs are the library and the vetted system libraries +dnl it needs; depends checks those against rustc's --print=native-static-libs. +if test "$enable_platform_gui" = "yes"; then + if test "$bitcoin_enable_qt" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires the GUI]) + fi + if test "$enable_wallet" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires the wallet]) + fi + PKG_CHECK_MODULES([PLATFORM_CXX], [dash_sdk_cxx], [], + [AC_MSG_ERROR([--enable-platform-gui requires dash-sdk-cxx (build depends with PLATFORM_GUI=1)])]) + TEMP_CPPFLAGS="$CPPFLAGS" + TEMP_LIBS="$LIBS" + CPPFLAGS="$CPPFLAGS $PLATFORM_CXX_CFLAGS" + LIBS="$PLATFORM_CXX_LIBS $LIBS" + AC_LANG_PUSH([C++]) + AC_MSG_CHECKING([whether a program using dash-sdk-cxx links]) + AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + ]], [[ + rust::Box client{dash_sdk::new_platform_client(dash_sdk::Config{}, nullptr)}; + client->shutdown(); + ]])], + [AC_MSG_RESULT([yes])], + [AC_MSG_RESULT([no]) + AC_MSG_ERROR([a program using dash-sdk-cxx does not link; see config.log])]) + AC_LANG_POP([C++]) + CPPFLAGS="$TEMP_CPPFLAGS" + LIBS="$TEMP_LIBS" + AC_DEFINE([ENABLE_PLATFORM_GUI], [1], [Define this symbol to enable Dash Platform support in the GUI]) +fi +AM_CONDITIONAL([ENABLE_PLATFORM_GUI], [test "$enable_platform_gui" = "yes"]) + AM_CONDITIONAL([TARGET_DARWIN], [test "$TARGET_OS" = "darwin"]) AM_CONDITIONAL([BUILD_DARWIN], [test "$BUILD_OS" = "darwin"]) AM_CONDITIONAL([TARGET_LINUX], [test "$TARGET_OS" = "linux"]) @@ -2121,6 +2164,7 @@ echo " debug enabled = $enable_debug" echo " stacktraces = $enable_stacktraces" echo " crash hooks = $enable_crashhooks" echo " miner enabled = $enable_miner" +echo " platform gui = $enable_platform_gui" echo " werror = $enable_werror" echo echo " target os = $host_os" diff --git a/contrib/devtools/README.md b/contrib/devtools/README.md index c7dbad85e8be..49fea496b958 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 from which `depends/packages/platform_cxx.mk` builds +`dash-sdk-cxx` (the C++ binding of the Dash Platform SDK) for a given +`dashpay/platform` commit, and prints the hashes to pin in `platform_cxx.mk`. +The bundle includes the Tenderdash source archive for the tag the commit's +`Cargo.lock` pins, so the two cannot disagree. Requires the Cargo version pinned in `native_rust.mk`, +GNU tar, GNU gzip (set `TAR` and `GZIP_PROG` if they are installed under other +names) and a `TMPDIR` with no `.cargo/config.toml` in any parent directory. +The output is reproducible: rerunning it for the same commit yields the same +bundle hash on any machine. The Platform tarball and the bundle are then +uploaded to the depends sources mirror. + + contrib/devtools/platform-bundle.sh + +check-no-rust.py +================ + +Fails if any of the given executables contain Rust code. Run by the +`linux64_sqlite` CI job, which builds against depends with `PLATFORM_GUI=1`, +on `dashd`, the command-line tools and the fuzz binary, which must never link +`dash-sdk-cxx`. diff --git a/contrib/devtools/check-no-rust.py b/contrib/devtools/check-no-rust.py new file mode 100755 index 000000000000..7572df20f04b --- /dev/null +++ b/contrib/devtools/check-no-rust.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +''' +Check that executables do not link Rust code. + +dash-sdk-cxx, the Dash Platform SDK binding (--enable-platform-gui), is for +dash-qt only; dashd, the command-line tools and the fuzz binary must not +contain any of it. +A binary fails if its symbol table has cxx bridge, Rust runtime or Rust +standard library symbols, or has no symbols at all (a stripped binary cannot +be checked). + +Example usage: + + contrib/devtools/check-no-rust.py src/dashd src/dash-cli src/dash-tx src/dash-wallet src/test/fuzz/fuzz +''' +import re +import subprocess +import sys + +from utils import determine_wellknown_cmd + +# cxx bridge symbols carry "cxxbridge1$". Rust code is either legacy-mangled +# under a crate namespace (rust, std, core, alloc) or v0-mangled ("_R" +# followed by a path tag and a crate root "Cs_"), which C and +# C++ symbols never match. The allocator shim, the panic handler and the +# unwinding personality are plain C names every Rust program links. +RUST_SYMBOL = re.compile( + r'cxxbridge1\$' + r'|_ZN(4rust|3std|4core|5alloc)[0-9]' + r'|\b_?_R[A-Za-z0-9_]*?Cs[A-Za-z0-9]*_[0-9]' + r'|\b_?(__rust_alloc|rust_begin_unwind|rust_eh_personality)' +) + + +def rust_symbols(nm, path): + result = subprocess.run(nm + [path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False) + if result.returncode != 0: + sys.exit(f'{path}: {result.stderr.strip()}') + lines = result.stdout.splitlines() + if not lines: + sys.exit(f'{path}: no symbols, so it cannot be checked (stripped?)') + return [line for line in lines if RUST_SYMBOL.search(line)] + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + nm = determine_wellknown_cmd('NM', 'nm') + failed = False + for path in sys.argv[1:]: + found = rust_symbols(nm, path) + if found: + failed = True + print(f'{path}: links Rust code, for example:') + for line in found[:5]: + print(f' {line}') + sys.exit(1 if failed else 0) + + +if __name__ == '__main__': + main() diff --git a/contrib/devtools/platform-bundle.sh b/contrib/devtools/platform-bundle.sh new file mode 100755 index 000000000000..ec68ea48469d --- /dev/null +++ b/contrib/devtools/platform-bundle.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +export LC_ALL=C +set -euo pipefail + +# Produces the crate bundle that depends/packages/platform_cxx.mk builds +# dash-sdk-cxx from, for a given dashpay/platform commit. +# +# The bundle holds a Cargo workspace trimmed to packages/rs-sdk-cxx, the +# commit's Cargo.lock pruned to that workspace (entries are only removed, never +# changed), every locked crate vendored with `cargo vendor --locked +# --versioned-dirs`, the Tenderdash source archive tenderdash-proto's build +# script generates its protobuf code from (tenderdash/tenderdash-.zip, for +# the rs-tenderdash-abci tag the lock pins), and a .cargo/config.toml that +# replaces all crate sources with the vendored directory and sets +# TENDERDASH_COMMITISH to that same tag. Vendored crates outside the build +# closure of dash-sdk-cxx (dev-dependencies and crates only other +# workspace members use) are reduced to their manifest and empty target files: +# Cargo needs them to resolve the lock, never to build. The archive is written +# with a fixed file order, owner, mode and mtime, so the same commit yields the +# same sha256 on every machine that uses the pinned Rust version, GNU tar and +# GNU gzip (and as long as GitHub serves the same Tenderdash zip). +# +# This is the only step that downloads crates. The maintainer bumping the +# pin runs it, updates the hashes in platform_cxx.mk from its output, and +# uploads the Platform tarball and the bundle to the depends sources mirror; +# reviewers rerun it to reproduce the hash. + +usage() { + echo "Usage: $0 " >&2 + echo >&2 + echo "Writes platform-.tar.gz and dash-sdk-cxx-crates-.tar.gz to" >&2 + echo "SOURCES_PATH (default: depends/sources), and keeps the downloaded" >&2 + echo "tenderdash-.zip there." >&2 + exit 1 +} + +[ $# -eq 1 ] || usage +COMMIT="$1" +[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "error: expected a full 40-character commit hash" >&2; exit 1; } + +TOPDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SOURCES_PATH="${SOURCES_PATH:-$TOPDIR/depends/sources}" +mkdir -p "$SOURCES_PATH" +# The script changes directory below; a relative path must not follow it. +SOURCES_PATH="$(cd "$SOURCES_PATH" && pwd)" +# shellcheck disable=SC2016 +RUST_VERSION="$(sed -n 's/^\$(package)_version:=//p' "$TOPDIR/depends/packages/native_rust.mk")" +CARGO="${CARGO:-cargo}" +TAR="${TAR:-tar}" +GZIP_PROG="${GZIP_PROG:-gzip}" + +# cargo vendor output (normalized manifests) depends on the Cargo version. +export RUSTUP_TOOLCHAIN="${RUSTUP_TOOLCHAIN:-$RUST_VERSION}" +case "$("$CARGO" --version)" in + "cargo $RUST_VERSION "*) ;; + *) echo "error: $CARGO is not Cargo $RUST_VERSION (the version pinned in native_rust.mk)" >&2; exit 1 ;; +esac +case "$("$TAR" --version)" in + *"GNU tar"*) ;; + *) echo "error: $TAR is not GNU tar; set TAR" >&2; exit 1 ;; +esac +case "$("$GZIP_PROG" --version)" in + *"Free Software Foundation"*) ;; + *) echo "error: $GZIP_PROG is not GNU gzip; set GZIP_PROG" >&2; exit 1 ;; +esac + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/platform-bundle.XXXXXX")" +trap 'rm -rf "$WORKDIR"' EXIT +# Cargo looks for configuration above the physical directory it runs in. +WORKDIR="$(cd "$WORKDIR" && pwd -P)" +# A private Cargo home keeps the cached registries and the Cargo home's +# config.toml out of the bundle. Cargo also reads .cargo/config.toml from every +# parent of the directory it runs in, so the work directory must have none +# (TMPDIR under a home directory with ~/.cargo/config.toml fails here). +export CARGO_HOME="$WORKDIR/cargo-home" +dir="$WORKDIR" +while :; do + dir="$(dirname "$dir")" + for config in "$dir/.cargo/config" "$dir/.cargo/config.toml"; do + if [ -e "$config" ]; then + echo "error: $config would configure Cargo; set TMPDIR to a directory outside its tree" >&2 + exit 1 + fi + done + [ "$dir" != / ] || break +done + +# Downloads $1 to SOURCES_PATH/$2, unless a copy there passes the integrity +# test $3 (a partial or corrupt one is downloaded again). +fetch() { + local url="$1" file="$2" test="$3" + if [ -f "$SOURCES_PATH/$file" ] && $test "$SOURCES_PATH/$file" 2> /dev/null; then + return + fi + rm -f "$SOURCES_PATH/$file" + curl --location --fail --silent --show-error --retry 3 -o "$SOURCES_PATH/$file.temp" "$url" + $test "$SOURCES_PATH/$file.temp" + mv "$SOURCES_PATH/$file.temp" "$SOURCES_PATH/$file" +} + +gzip_test() { + "$GZIP_PROG" -t "$1" +} + +zip_test() { + python3 -c 'import sys, zipfile; sys.exit(zipfile.ZipFile(sys.argv[1]).testzip() is not None)' "$1" +} + +sha256() { + if command -v sha256sum >/dev/null; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# Prints "name version source" for every [[package]] of a Cargo.lock. +lock_entries() { + awk '/^\[\[package\]\]$/ { if (name) print name, version, source; name = version = source = "" } + /^name = / { name = $3 } + /^version = / { version = $3 } + /^source = / { source = $3 } + END { if (name) print name, version, source }' "$1" | sort +} + +PLATFORM_ARCHIVE="platform-$COMMIT.tar.gz" +fetch "https://github.com/dashpay/platform/archive/$COMMIT.tar.gz" "$PLATFORM_ARCHIVE" gzip_test + +SRC="$WORKDIR/src" +mkdir -p "$SRC" +"$TAR" --strip-components=1 -xzf "$SOURCES_PATH/$PLATFORM_ARCHIVE" -C "$SRC" +cd "$SRC" + +# Trim the workspace to the one crate depends builds and prune the lock to it. +cp Cargo.lock "$WORKDIR/Cargo.lock.pinned" +awk '/^members = \[/ { print "members = [\"packages/rs-sdk-cxx\"]"; skip = !/\]/; next } + skip && /^\]/ { skip = 0; next } + !skip' Cargo.toml > "$WORKDIR/Cargo.toml" +mv "$WORKDIR/Cargo.toml" Cargo.toml +"$CARGO" metadata --format-version 1 > /dev/null +if lock_entries Cargo.lock | comm -13 <(lock_entries "$WORKDIR/Cargo.lock.pinned") - | grep .; then + echo "error: trimming the workspace changed the locked packages above" >&2 + exit 1 +fi + +TENDERDASH_TAG="$(awk '/^name = "tenderdash-proto"$/ { found = 1 } + found && /^source = / { print; exit }' Cargo.lock | + sed -n 's|.*/rs-tenderdash-abci?tag=\(v[^#]*\)#.*|\1|p')" +[ -n "$TENDERDASH_TAG" ] || { echo "error: no tagged tenderdash-proto entry in Cargo.lock" >&2; exit 1; } +TENDERDASH_ARCHIVE="tenderdash-$TENDERDASH_TAG.zip" +fetch "https://github.com/dashpay/tenderdash/archive/$TENDERDASH_TAG.zip" "$TENDERDASH_ARCHIVE" zip_test +mkdir tenderdash +cp "$SOURCES_PATH/$TENDERDASH_ARCHIVE" tenderdash/ + +rm -rf .cargo +mkdir .cargo +{ + echo "# Generated by contrib/devtools/platform-bundle.sh for dashpay/platform@$COMMIT" + "$CARGO" vendor --locked --versioned-dirs vendor + echo + echo "[env]" + echo "TENDERDASH_COMMITISH = \"$TENDERDASH_TAG\"" +} > "$WORKDIR/config.toml" +grep -qx 'directory = "vendor"' "$WORKDIR/config.toml" || { echo "error: cargo vendor printed no source replacement" >&2; exit 1; } +mv "$WORKDIR/config.toml" .cargo/config.toml +# From here on every crate must come from vendor/, never from the Cargo home. +rm -rf "$CARGO_HOME" + +closure() { + "$CARGO" tree --frozen -p dash-sdk-cxx -e normal,build --target all --prefix none --format '{p}' | + awk '{ sub(/^v/, "", $2); print $1 "-" $2 }' | sort -u +} +closure > "$WORKDIR/closure" +for dir in vendor/*/; do + dir="${dir%/}" + grep -qxF "${dir#vendor/}" "$WORKDIR/closure" && continue + checksum="$(sed -En 's/.*"package":(null|"[0-9a-f]*")}$/\1/p' "$dir/.cargo-checksum.json")" + find "$dir" ! -type d ! -name '*.rs' ! -path "$dir/Cargo.toml" -delete + find "$dir" -type f -name '*.rs' -print0 | while IFS= read -r -d '' file; do : > "$file"; done + find "$dir" -type d -empty -delete + printf '{"files":{},"package":%s}' "$checksum" > "$dir/.cargo-checksum.json" +done +closure | cmp -s - "$WORKDIR/closure" || { echo "error: the build closure changed after pruning" >&2; exit 1; } + +BUNDLE="dash-sdk-cxx-crates-$COMMIT.tar.gz" +"$TAR" --create --format=gnu --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner \ + --mode='u+rw,go+r-w,a+X' Cargo.toml Cargo.lock .cargo/config.toml tenderdash vendor | + "$GZIP_PROG" -9n > "$SOURCES_PATH/$BUNDLE.temp" +mv "$SOURCES_PATH/$BUNDLE.temp" "$SOURCES_PATH/$BUNDLE" + +cat < dict: + """Return {id: value} for every `$(package)__:=` line.""" + return dict(re.findall(rf"^\$\(package\)_{kind}_(\w+):=(\S+)$", content, re.MULTILINE)) + + +def published_sha256(url: str) -> str: + """Return the hash in the ` ` line of `.sha256`.""" + with urllib.request.urlopen(f"{url}.sha256", timeout=TIMEOUT) as response: + return response.read().decode().split()[0] + + +def sha256_of(url: str, attempts: int = 3) -> str: + for _ in range(attempts): + hasher = hashlib.sha256() + received = 0 + try: + with urllib.request.urlopen(url, timeout=TIMEOUT) as response: + length = response.headers["Content-Length"] + while chunk := response.read(1 << 20): + hasher.update(chunk) + received += len(chunk) + except (http.client.IncompleteRead, OSError) as error: + print(f"warning: {url}: {error}", file=sys.stderr) + continue + if length is None or received == int(length): + return hasher.hexdigest() + print(f"warning: {url}: download ended after {received} of {length} bytes", file=sys.stderr) + sys.exit(f"error: {url}: no complete download in {attempts} attempts") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help="verify the pinned hashes instead of rewriting them") + args = parser.parse_args() + + native = NATIVE_RUST_MK.read_text(encoding="utf-8") + stdlib = RUST_STDLIB_MK.read_text(encoding="utf-8") + version_pin = re.search(r"^\$\(package\)_version:=(\S+)$", native, re.MULTILINE) + if version_pin is None: + sys.exit(f"error: no version pin in {NATIVE_RUST_MK}") + version = version_pin.group(1) + + archives = [] # (makefile, pin id, archive name) + build_targets = pins(native, "build_target") + for build_id, file_name in pins(native, "file_name").items(): + file_name = file_name.replace("$($(package)_version)", version) + if build_id not in build_targets: + sys.exit(f"error: {NATIVE_RUST_MK} has no build_target_{build_id} pin") + file_name = file_name.replace(f"$($(package)_build_target_{build_id})", build_targets[build_id]) + archives.append((NATIVE_RUST_MK, build_id, file_name)) + for host_id, target in pins(stdlib, "target").items(): + archives.append((RUST_STDLIB_MK, host_id, f"rust-std-{version}-{target}.tar.gz")) + + contents = {NATIVE_RUST_MK: native, RUST_STDLIB_MK: stdlib} + stale = [] + for makefile, pin_id, archive in archives: + pinned = pins(contents[makefile], "sha256_hash").get(pin_id) + if pinned is None: + sys.exit(f"error: {makefile} has no sha256_hash_{pin_id} pin") + url = f"{DIST_URL}/{archive}" + actual = published_sha256(url) + if not args.check: + downloaded = sha256_of(url) + if downloaded != actual: + sys.exit(f"error: {archive} hashes to {downloaded}, but its published .sha256 says {actual}") + print(f"{actual} {archive}") + if actual != pinned: + stale.append(archive) + contents[makefile] = re.sub(rf"^(\$\(package\)_sha256_hash_{pin_id}:=)\S*$", + rf"\g<1>{actual}", contents[makefile], flags=re.MULTILINE) + + if args.check: + for archive in stale: + print(f"error: pinned hash for {archive} does not match", file=sys.stderr) + return 1 if stale else 0 + + for makefile, content in contents.items(): + makefile.write_text(content, encoding="utf-8") + print(f"Updated {len(stale)} of {len(archives)} pins for Rust {version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/depends/Makefile b/depends/Makefile index a50aabc0bb27..6d4faaefd95c 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 @@ -171,6 +172,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_) @@ -185,6 +187,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 @@ -253,6 +263,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 ef640a65998b..e97905ea2d9c 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 `dash-sdk-cxx` (the Dash Platform SDK's C++ binding) 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/funcs.mk b/depends/funcs.mk index 566f83a9868e..e8aa530f2c3c 100644 --- a/depends/funcs.mk +++ b/depends/funcs.mk @@ -71,7 +71,7 @@ $(1)_build_log:=$(BASEDIR)/$(1)-$($(1)_version)-$($(1)_build_id).log $(1)_all_sources=$($(1)_file_name) $($(1)_extra_sources) #stamps -$(1)_fetched=$(SOURCES_PATH)/download-stamps/.stamp_fetched-$(1)-$($(1)_version)-$($(1)_sha256_hash).hash +$(1)_fetched=$(SOURCES_PATH)/download-stamps/.stamp_fetched-$(1)-$($(1)_version)-$($(1)_sha256_hash)$(if $($(1)_fetched_id),-$($(1)_fetched_id)).hash $(1)_extracted=$$($(1)_extract_dir)/.stamp_extracted $(1)_preprocessed=$$($(1)_extract_dir)/.stamp_preprocessed $(1)_cleaned=$$($(1)_extract_dir)/.stamp_cleaned diff --git a/depends/packages.md b/depends/packages.md index 4232bb1006bd..4e4fe155e516 100644 --- a/depends/packages.md +++ b/depends/packages.md @@ -48,6 +48,13 @@ These variables are optional: Any extra files that will be fetched via $(package)_fetch_cmds. These are specified so that they can be fetched and verified via 'make download'. + $(package)_fetched_id: + Added to the name of the package's download stamp, which otherwise holds + only $(package)_version and $(package)_sha256_hash. Set it to the hash of + an extra source that can change while those stay the same, so that a + change refetches it. It must be a single word: join several hashes with + "-", not spaces. + ## Build Variables: After defining the main identifiers, build variables may be added or customized 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..5d853f094ac8 --- /dev/null +++ b/depends/packages/native_rust.mk @@ -0,0 +1,52 @@ +# Copyright (c) 2016-2025 The Zcash developers +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +# To update the Rust compiler, change the version below and then run the script +# ./contrib/devtools/update-rust-hashes.py + +package:=native_rust +$(package)_version:=1.98.1 +$(package)_download_path:=https://static.rust-lang.org/dist + +# Linux (ARMv8) +$(package)_build_target_aarch64_linux:=aarch64-unknown-linux-gnu +$(package)_file_name_aarch64_linux:=rust-$($(package)_version)-$($(package)_build_target_aarch64_linux).tar.gz +$(package)_sha256_hash_aarch64_linux:=f00ba576645cef658e1deed96fab8f707958e9d58808b16343448b5d1c4f7407 + +# Linux (x86_64) +$(package)_build_target_x86_64_linux:=x86_64-unknown-linux-gnu +$(package)_file_name_x86_64_linux:=rust-$($(package)_version)-$($(package)_build_target_x86_64_linux).tar.gz +$(package)_sha256_hash_x86_64_linux:=24ba1338a2d35c5a3247936546429e163fa674d726102af18bdf624582c57aea + +# macOS (ARMv8) +$(package)_build_target_aarch64_darwin:=aarch64-apple-darwin +$(package)_file_name_aarch64_darwin:=rust-$($(package)_version)-$($(package)_build_target_aarch64_darwin).tar.gz +$(package)_sha256_hash_aarch64_darwin:=cfc171d8120d401b10a1028c52646dd8e00e3e66852f949061ce087845f55afd + +# macOS (x86_64) +$(package)_build_target_x86_64_darwin:=x86_64-apple-darwin +$(package)_file_name_x86_64_darwin:=rust-$($(package)_version)-$($(package)_build_target_x86_64_darwin).tar.gz +$(package)_sha256_hash_x86_64_darwin:=443a1165abbac41c9143b83ff837c0fb1d8c03d2f8fb1da27427bc9fc646aad3 + +# The Rust target triple of the build machine. +$(package)_build_target:=$($(package)_build_target_$(build_arch)_$(build_os)) +$(package)_file_name=$($(package)_file_name_$(build_arch)_$(build_os)) +$(package)_sha256_hash=$($(package)_sha256_hash_$(build_arch)_$(build_os)) + +ifeq ($($(package)_file_name),) +$(error native_rust has no prebuilt Rust $($(package)_version) for $(build_arch)-$(build_os)) +endif + +# The prebuilt toolchain cannot run inside a Guix environment as is. +ifneq ($(GUIX_ENVIRONMENT),) +$(error native_rust: PLATFORM_GUI is not supported in Guix builds yet; see ) +endif + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/bin $($(package)_staging_prefix_dir)/lib/rustlib && \ + cp cargo/bin/cargo rustc/bin/rustc $($(package)_staging_prefix_dir)/bin/ && \ + cp -R rustc/lib/. $($(package)_staging_prefix_dir)/lib/ && \ + cp -R rust-std-*/lib/rustlib/. $($(package)_staging_prefix_dir)/lib/rustlib/ +endef diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 59232930eb38..e3299ea041ff 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -24,4 +24,7 @@ natpmp_packages=libnatpmp multiprocess_packages = libmultiprocess capnp multiprocess_native_packages = native_libmultiprocess native_capnp +platform_packages = rust_stdlib platform_cxx +platform_native_packages = native_rust native_protobuf + usdt_linux_packages=systemtap diff --git a/depends/packages/platform_cxx.mk b/depends/packages/platform_cxx.mk new file mode 100644 index 000000000000..362d88383574 --- /dev/null +++ b/depends/packages/platform_cxx.mk @@ -0,0 +1,219 @@ +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +# dash-sdk-cxx, the C++ binding of the Dash Platform SDK (packages/rs-sdk-cxx +# of dashpay/platform), built from two sha256-pinned archives: the Platform +# source tarball at the pinned commit, and the crate bundle that +# contrib/devtools/platform-bundle.sh produces for that commit (trimmed +# workspace, pruned Cargo.lock, vendored crates, the Tenderdash sources +# tenderdash-proto generates its protobuf code from, Cargo configuration). +# Cargo runs with --frozen and never touches the network. +# +# Both archives must be on the depends sources mirror (FALLBACK_DOWNLOAD_PATH) +# before this is merged. The bundle has no upstream URL, and GitHub does not +# promise stable bytes for the archive endpoint the tarball comes from. +# Whoever bumps the pin runs platform-bundle.sh, which prints the lines to +# update here, and uploads both. Until then, a bundle the script wrote to +# SOURCES_PATH is used as is; its hash is checked on extraction either way. + +package=platform_cxx +$(package)_version=dc4dc8e80ef8c495be693d4cc19c378a94f5d116 +$(package)_download_path=https://github.com/dashpay/platform/archive +$(package)_download_file=$($(package)_version).tar.gz +$(package)_file_name=platform-$($(package)_version).tar.gz +$(package)_sha256_hash=fca5a310c8322924734ae7f8c522bffe4622bb4eb8394a6e054f56129ffbe592 +$(package)_crates_file_name=dash-sdk-cxx-crates-$($(package)_version).tar.gz +$(package)_crates_sha256_hash=6f9d50e3c82406956b4b769874824bac7c28e8f1bcf1b3d514f7d381ea672f55 +$(package)_fetched_id=$($(package)_crates_sha256_hash) +$(package)_extra_sources=$($(package)_crates_file_name) +$(package)_dependencies=native_rust rust_stdlib native_protobuf +$(package)_patches=rustc-linker.sh build-linker.sh check-native-libs.sh + +# The system libraries the archive needs, which dash_sdk_cxx.pc passes on +# to configure. Each is vetted against contrib/guix/symbol-check.py, and the +# build fails if rustc reports one (--print=native-static-libs) that is +# neither here nor in the ignored list below. +$(package)_native_libs_linux=-lpthread -ldl -lm +$(package)_native_libs_darwin=-framework Security -framework CoreFoundation +$(package)_native_libs_mingw32=-lbcrypt -ladvapi32 -lkernel32 -lntdll -luserenv -lws2_32 -ldbghelp + +# Libraries rustc reports that are deliberately not passed on: +# - the C++ runtime the cxx bridge needs, which the C++ link of dash-qt +# already has: -lstdc++, and -lc++ on macOS; +# - the toolchain's own C runtime, which every link already has: -lc, +# -lgcc_s, -lgcc_eh and -lgcc, and on mingw-w64 -lmsvcrt, -lmingwex, +# -lmingw32 and -l:libpthread.a; +# - glibc's -lutil and -lrt: symbol-check.py does not allow them, and the +# glibc standard library imports nothing from them (every symbol it uses +# from glibc 2.31 is in libc, libpthread, libdl or libm); +# - macOS's -liconv, -lSystem, -lc and -lm, which the macOS linker adds anyway. +$(package)_native_libs_ignored_linux=-lstdc++ -lc -lgcc_s -lgcc_eh -lgcc -lutil -lrt +$(package)_native_libs_ignored_darwin=-lc++ -liconv -lSystem -lc -lm +$(package)_native_libs_ignored_mingw32=-lstdc++ -lmsvcrt -lmingwex -lmingw32 -lgcc_eh -lgcc -l:libpthread.a + +define $(package)_fetch_cmds +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_download_file),$($(package)_file_name),$($(package)_sha256_hash)) && \ +( echo "$($(package)_crates_sha256_hash) $($(package)_source_dir)/$($(package)_crates_file_name)" | $(build_SHA256SUM) -c >/dev/null 2>&1 || \ + ( rm -f $($(package)_source_dir)/$($(package)_crates_file_name) && \ + $(call fetch_file_inner,$(package),$(FALLBACK_DOWNLOAD_PATH),$($(package)_crates_file_name),$($(package)_crates_file_name),$($(package)_crates_sha256_hash)) ) ) +endef + +# Only packages/ is needed from the Platform tarball; the bundle supplies the +# workspace manifest, lock and Cargo configuration. +define $(package)_extract_cmds + echo "$($(package)_sha256_hash) $($(package)_source)" > .$($(package)_file_name).hash && \ + echo "$($(package)_crates_sha256_hash) $($(package)_source_dir)/$($(package)_crates_file_name)" >> .$($(package)_file_name).hash && \ + $(build_SHA256SUM) -c .$($(package)_file_name).hash && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source) platform-$($(package)_version)/packages && \ + $(build_TAR) --no-same-owner -xf $($(package)_source_dir)/$($(package)_crates_file_name) +endef + +$(package)_rust_target=$(rust_stdlib_target) +$(package)_cc_target=$(subst -,_,$($(package)_rust_target)) +$(package)_build_linker_var:=CARGO_TARGET_$(shell echo $(native_rust_build_target) | tr a-z- A-Z_)_LINKER + +# Only the bundle's .cargo/config.toml configures Cargo: +# - Cargo reads .cargo/config.toml in the directory it runs in and in every +# parent, whatever --manifest-path says, so it runs from / and is given the +# bundle's file with --config. Configuration above the depends tree, such as +# ~/.cargo/config.toml when the tree is under a home directory, is therefore +# never read, and the build works wherever the tree is; preprocess fails if +# / itself has one. +# - The Cargo home is private and empty. +# - Environment variables that would change the build are removed: +# RUSTC_WORKSPACE_WRAPPER, RUSTC_BOOTSTRAP, CARGO_ENCODED_RUSTFLAGS, +# __CARGO_DEFAULT_LIB_METADATA, and all CARGO_BUILD_*, CARGO_PROFILE_*, +# CARGO_TARGET_* and CARGO_UNSTABLE_* ones, and the _ forms +# (such as CC_x86_64-unknown-linux-gnu) that cc-rs prefers over the +# _ ones set below; TENDERDASH_DIR and +# TENDERDASH_COMMITISH, which the bundle's configuration sets to the tag of +# the Tenderdash archive it carries. +# - The release profile is pinned to Platform's, which is Cargo's default: +# opt-level 3, no debug info, no LTO, 16 codegen units, no incremental +# compilation, and unwinding panics (the crate refuses panic=abort). Nothing +# Cargo links here is installed (build scripts, the cdylib dash-sdk also +# declares), so nothing is stripped; on macOS that would need rust-objcopy +# and an LLVM library native_rust does not stage. +$(package)_unset_env:=RUSTC_WORKSPACE_WRAPPER RUSTC_BOOTSTRAP CARGO_ENCODED_RUSTFLAGS __CARGO_DEFAULT_LIB_METADATA +$(package)_unset_env+=TENDERDASH_DIR TENDERDASH_COMMITISH +$(package)_unset_env+=$(filter CARGO_BUILD_% CARGO_PROFILE_% CARGO_TARGET_% CARGO_UNSTABLE_%,$(.VARIABLES)) +$(package)_unset_env+=$(filter %_$(rust_stdlib_target) %_$(native_rust_build_target),$(.VARIABLES)) +$(package)_profile_env:=CARGO_PROFILE_RELEASE_OPT_LEVEL=3 CARGO_PROFILE_RELEASE_DEBUG=false +$(package)_profile_env+=CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +$(package)_profile_env+=CARGO_PROFILE_RELEASE_PANIC=unwind CARGO_PROFILE_RELEASE_INCREMENTAL=false +$(package)_profile_env+=CARGO_PROFILE_RELEASE_STRIP=false CARGO_INCREMENTAL=0 + +# RUSTFLAGS takes the place of Platform's .cargo/config.toml, which the bundle +# does not carry. Of what that file sets, --cfg tokio_unstable only enables +# tokio APIs (runtime metrics, task hooks, io-uring) that nothing in +# dash-sdk-cxx's dependency graph uses, target-feature=-crt-static only +# changes musl targets (glibc targets link the C runtime dynamically anyway), +# and target-cpu=x86-64 and -lstdc++ are the defaults or only matter for +# executables; none of them is set here. +# +# For the host, the depends compiler links (through rustc-linker.sh), the C and +# C++ sources in the crate closure (ring, blst, blake3, secp256k1, the cxx +# bridge) are compiled with the depends compiler and flags, and the build +# directory is remapped out of both the Rust and the C objects. Build scripts +# and proc macros run on the build machine; build-linker.sh links them with the +# depends build compiler, which also compiles any C they need (CC_ and HOST_CC; when the build and host triples are the same, the host's +# CC_ wins and the host and build compilers are the same machine's). +# +# For windows-gnu, rustc creates the import libraries of raw-dylib imports +# (windows-link, which the Rust standard library and windows-sys use) with +# the mingw-w64 dlltool. +define $(package)_set_vars +$(package)_cargo_env = CARGO_HOME=$$($(package)_build_dir)/.cargo-home +$(package)_cargo_env += CARGO_TARGET_DIR=$$($(package)_build_dir)/target +$(package)_cargo_env += $($(package)_profile_env) +$(package)_cargo_env += $($(package)_build_linker_var)=$$($(package)_build_dir)/build-linker.sh +$(package)_cargo = cd / && env $$(addprefix -u ,$$($(package)_unset_env)) $$($(package)_cargo_env) \ + cargo --config $$($(package)_build_dir)/.cargo/config.toml +$(package)_rustflags = -C linker=$$($(package)_build_dir)/rustc-linker.sh --remap-path-prefix=$(BASEDIR)=/build +$(package)_rustflags_mingw32 = -C dlltool=$(host_toolchain)dlltool +$(package)_build_env += RUSTC="$(build_prefix)/bin/rustc" RUSTC_WRAPPER= +$(package)_build_env += RUSTFLAGS="$$($(package)_rustflags) $$($(package)_rustflags_$(host_os))" +$(package)_build_env += DEPENDS_CC="$$($(package)_cc)" DEPENDS_LDFLAGS="$$($(package)_ldflags)" +$(package)_build_env += DEPENDS_BUILD_CC="$(build_CC)" +$(package)_build_env += CC_$($(package)_cc_target)="$$($(package)_cc)" CXX_$($(package)_cc_target)="$$($(package)_cxx)" +$(package)_build_env += AR_$($(package)_cc_target)="$$($(package)_ar)" +$(package)_build_env += CFLAGS_$($(package)_cc_target)="$$($(package)_cppflags) $$($(package)_cflags) -ffile-prefix-map=$(BASEDIR)=/build" +$(package)_build_env += CXXFLAGS_$($(package)_cc_target)="$$($(package)_cppflags) $$($(package)_cxxflags) -ffile-prefix-map=$(BASEDIR)=/build" +$(package)_build_env += HOST_CC="$(build_CC)" HOST_CXX="$(build_CXX)" +ifneq ($(native_rust_build_target),$($(package)_rust_target)) +$(package)_build_env += CC_$(subst -,_,$(native_rust_build_target))="$(build_CC)" CXX_$(subst -,_,$(native_rust_build_target))="$(build_CXX)" +endif +$(package)_build_env += PROTOC="$(build_prefix)/bin/protoc" PROTOC_INCLUDE="$(build_prefix)/include" +ifeq ($(host_os),darwin) +$(package)_build_env += MACOSX_DEPLOYMENT_TARGET=$(OSX_MIN_VERSION) +ifneq ($(build_os),darwin) +$(package)_build_env += SDKROOT="$(OSX_SDK)" +endif +endif +endef + +# tenderdash-proto's build script extracts its sources from +# tenderdash-$$TENDERDASH_COMMITISH.zip in its cache directory +# (CARGO_TARGET_DIR) and would download the archive if it were missing; the +# bundle's configuration sets the tag of the archive the bundle carries. +define $(package)_preprocess_cmds + for config in /.cargo/config /.cargo/config.toml; do \ + if test -e $$$$config; then echo "$$$$config would configure Cargo; remove it" >&2; exit 1; fi; \ + done && \ + cp $($(package)_patch_dir)/rustc-linker.sh $($(package)_patch_dir)/build-linker.sh . && \ + chmod +x rustc-linker.sh build-linker.sh && \ + tag=$$$$(sed -n 's/^TENDERDASH_COMMITISH = "\(.*\)"$$$$/\1/p' .cargo/config.toml) && \ + if ! test -f "tenderdash/tenderdash-$$$$tag.zip"; then \ + echo "the crate bundle has no Tenderdash archive for TENDERDASH_COMMITISH \"$$$$tag\"" >&2; exit 1; \ + fi && \ + mkdir -p target && \ + cp "tenderdash/tenderdash-$$$$tag.zip" target/ +endef + +# The build fails if the dependency graph (normal and build dependencies, both +# of which Cargo compiles) reaches a trusted third-party context provider, an +# HTTP client or OpenSSL: every trust input comes from Core. The graph goes to +# a file first, so that a failing cargo tree fails the build. +# +# The crate is built as a static library only, with rustc printing the system +# libraries the archive needs; check-native-libs.sh compares them with the +# vetted and ignored lists above. The build log goes to a file as well, and +# the exit status to another, since a pipeline has the status of tee. +define $(package)_build_cmds + ( $($(package)_cargo) tree --frozen --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-sdk-cxx -e normal,build --target $($(package)_rust_target) ) > cargo-tree.txt && \ + if grep -E 'rs-sdk-trusted-context-provider|reqwest|openssl-sys' cargo-tree.txt; then \ + echo "dash-sdk-cxx depends on a forbidden crate" >&2; exit 1; \ + fi && \ + ( ( $($(package)_cargo) rustc --frozen --offline --release --manifest-path $($(package)_build_dir)/Cargo.toml \ + -p dash-sdk-cxx --lib --crate-type staticlib --target $($(package)_rust_target) \ + -- --print=native-static-libs ); echo $$$$? > cargo-rustc.status ) 2>&1 | tee cargo-rustc.log && \ + test "$$$$(cat cargo-rustc.status)" = 0 && \ + sh $($(package)_patch_dir)/check-native-libs.sh cargo-rustc.log \ + "$($(package)_native_libs_$(host_os))" "$($(package)_native_libs_ignored_$(host_os))" +endef + +# The crate's build script stages the generated bridge header dash/sdk/ffi.h, +# dash/sdk/callbacks.h and the cxx runtime header rust/cxx.h under +# target//release/include; that tree, the static archive and +# dash_sdk_cxx.pc are the installed interface. The +# .pc file locates the prefix from its own directory. +define $(package)_stage_cmds + mkdir -p $($(package)_staging_prefix_dir)/include $($(package)_staging_prefix_dir)/lib/pkgconfig && \ + cp -R target/$($(package)_rust_target)/release/include/. $($(package)_staging_prefix_dir)/include/ && \ + cp target/$($(package)_rust_target)/release/libdash_sdk_cxx.a $($(package)_staging_prefix_dir)/lib/ && \ + printf '%s\n' \ + 'prefix=$$$${pcfiledir}/../..' \ + 'exec_prefix=$$$${prefix}' \ + 'libdir=$$$${prefix}/lib' \ + 'includedir=$$$${prefix}/include' \ + '' \ + 'Name: dash_sdk_cxx' \ + 'Description: C++ binding of the Dash Platform SDK' \ + 'Version: $($(package)_version)' \ + 'Cflags: -I$$$${includedir}' \ + 'Libs: -L$$$${libdir} -ldash_sdk_cxx $($(package)_native_libs_$(host_os))' \ + > $($(package)_staging_prefix_dir)/lib/pkgconfig/dash_sdk_cxx.pc +endef diff --git a/depends/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/platform_cxx/build-linker.sh b/depends/patches/platform_cxx/build-linker.sh new file mode 100755 index 000000000000..da7d04b6a9cc --- /dev/null +++ b/depends/patches/platform_cxx/build-linker.sh @@ -0,0 +1,8 @@ +#!/bin/sh +export LC_ALL=C +set -f +# Links build scripts and proc macros, which run on the build machine, with the +# depends build compiler. rustc's `-C linker=` takes a single executable, but +# DEPENDS_BUILD_CC is a command line (on macOS with an -isysroot flag). +# shellcheck disable=SC2086 +exec $DEPENDS_BUILD_CC "$@" diff --git a/depends/patches/platform_cxx/check-native-libs.sh b/depends/patches/platform_cxx/check-native-libs.sh new file mode 100755 index 000000000000..51d1bad850f3 --- /dev/null +++ b/depends/patches/platform_cxx/check-native-libs.sh @@ -0,0 +1,42 @@ +#!/bin/sh +export LC_ALL=C +set -f +# Usage: check-native-libs.sh +# +# Fails unless every system library rustc reports for the static archive (the +# "native-static-libs" note in the build log) is vetted or ignored, so that +# whoever bumps the Platform pin or the Rust toolchain notices a new one. +log=$1 +vetted=$2 +ignored=$3 + +libs=$(sed -n 's/^note: native-static-libs: //p' "$log") +if test -z "$libs"; then + echo "rustc reported no native-static-libs in $log" >&2 + exit 1 +fi +echo "rustc native-static-libs: $libs" + +unvetted= +framework= +for word in $libs; do + if test "$word" = -framework; then + framework=yes + continue + fi + lib=$word + if test -n "$framework"; then + lib="-framework $word" + framework= + fi + case " $vetted $ignored " in + *" $lib "*) ;; + *) unvetted="$unvetted $lib" ;; + esac +done + +if test -n "$unvetted"; then + echo "rustc reports system libraries platform_cxx.mk has not vetted:$unvetted" >&2 + echo "Check each against contrib/guix/symbol-check.py, then add it to platform_cxx_native_libs_, or with the reason to platform_cxx_native_libs_ignored_." >&2 + exit 1 +fi diff --git a/depends/patches/platform_cxx/rustc-linker.sh b/depends/patches/platform_cxx/rustc-linker.sh new file mode 100755 index 000000000000..db2063792520 --- /dev/null +++ b/depends/patches/platform_cxx/rustc-linker.sh @@ -0,0 +1,10 @@ +#!/bin/sh +export LC_ALL=C +set -f +# rustc's `-C linker=` takes a single executable, but the depends compiler is +# a command line (target and sysroot flags, under Guix an `env -u ...` prefix). +# platform_cxx.mk passes that command in DEPENDS_CC and the link flags in +# DEPENDS_LDFLAGS; word splitting them here keeps every part, and set -f keeps +# a flag with a glob character from being expanded. +# shellcheck disable=SC2086 +exec $DEPENDS_CC $DEPENDS_LDFLAGS "$@" diff --git a/doc/dependencies.md b/doc/dependencies.md index a54e60efbe03..ed49a3fec2bd 100644 --- a/doc/dependencies.md +++ b/doc/dependencies.md @@ -36,6 +36,13 @@ You can find installation instructions in the `build-*.md` file for your platfor | [qrencode](../depends/packages/qrencode.mk) | [link](https://fukuchi.org/works/qrencode/) | [4.1.1](https://github.com/bitcoin/bitcoin/pull/27312) | | No | | [Qt](../depends/packages/qt.mk) | [link](https://download.qt.io/official_releases/qt/) | [5.15.18](https://github.com/dashpay/dash/pull/6949) | [5.11.3](https://github.com/bitcoin/bitcoin/pull/24132) | No | +### Dash Platform GUI (`--enable-platform-gui`, depends `PLATFORM_GUI=1` only) +| Dependency | Releases | Version used | Minimum required | Runtime | +| --- | --- | --- | --- | --- | +| [Rust](../depends/packages/native_rust.mk) (compiler and standard library) | [link](https://forge.rust-lang.org/infra/other-installation-methods.html#standalone-installers) | 1.98.1 | 1.98.1 | No | +| [protoc](../depends/packages/native_protobuf.mk) (build tool) | [link](https://github.com/protocolbuffers/protobuf/releases) | 32.0 | 25.0 | No | +| [dash-sdk-cxx](../depends/packages/platform_cxx.mk) (Dash Platform SDK C++ binding) | [link](https://github.com/dashpay/platform) | commit [dc4dc8e...](https://github.com/dashpay/platform/tree/dc4dc8e80ef8c495be693d4cc19c378a94f5d116) | | No | + ### Networking | Dependency | Releases | Version used | Minimum required | Runtime | | --- | --- | --- | --- | --- | diff --git a/src/Makefile.am b/src/Makefile.am index d002e2e4fe03..158c786ea670 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -70,6 +70,9 @@ LIBSECP256K1=secp256k1/libsecp256k1.la if ENABLE_ZMQ LIBBITCOIN_ZMQ=libbitcoin_zmq.a endif +if ENABLE_PLATFORM_GUI +LIBDASH_PLATFORM=libdash_platform.a +endif if BUILD_BITCOIN_LIBS LIBBITCOINCONSENSUS=libdashconsensus.la endif @@ -135,7 +138,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBDASH_PLATFORM) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -720,6 +724,21 @@ libbitcoin_zmq_a_SOURCES = \ endif # +# platform: the Dash Platform client of dash-qt and its tests # +if ENABLE_PLATFORM_GUI +libdash_platform_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(PLATFORM_CXX_CFLAGS) +libdash_platform_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +libdash_platform_a_SOURCES = \ + platform/client.cpp \ + platform/client.h \ + platform/marshal.cpp \ + platform/marshal.h \ + platform/signer.cpp \ + platform/signer.h \ + platform/types.h +endif +# + # wallet # libbitcoin_wallet_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(BDB_CPPFLAGS) $(SQLITE_CFLAGS) libbitcoin_wallet_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index ca191acfef7b..d8b96412bde7 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -122,6 +122,14 @@ QT_MOC_CPP = \ qt/moc_walletmodel.cpp \ qt/moc_walletview.cpp +if ENABLE_PLATFORM_GUI +QT_MOC_CPP += \ + qt/platform/moc_identityflow.cpp \ + qt/platform/moc_platformpage.cpp \ + qt/platform/moc_platformservice.cpp \ + qt/platform/moc_usernameflow.cpp +endif + BITCOIN_MM = \ qt/macdockiconhandler.mm \ qt/macnotificationhandler.mm \ @@ -224,6 +232,15 @@ BITCOIN_QT_H = \ qt/walletview.h \ qt/winshutdownmonitor.h +if ENABLE_PLATFORM_GUI +BITCOIN_QT_H += \ + qt/platform/identityflow.h \ + qt/platform/platformpage.h \ + qt/platform/platformservice.h \ + qt/platform/platformui.h \ + qt/platform/usernameflow.h +endif + QT_RES_ICONS = \ qt/res/icons/address-book.png \ qt/res/icons/connect1_16.png \ @@ -357,12 +374,22 @@ BITCOIN_QT_WALLET_CPP = \ qt/walletmodeltransaction.cpp \ qt/walletview.cpp +BITCOIN_QT_PLATFORM_CPP = \ + qt/platform/identityflow.cpp \ + qt/platform/platformpage.cpp \ + qt/platform/platformservice.cpp \ + qt/platform/platformui.cpp \ + qt/platform/usernameflow.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 = \ @@ -481,12 +508,18 @@ if TARGET_WINDOWS bitcoin_qt_sources += $(BITCOIN_QT_RC) endif bitcoin_qt_ldadd = qt/libbitcoinqt.a $(LIBBITCOIN_NODE) +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(LIBDASH_PLATFORM) +endif if ENABLE_WALLET bitcoin_qt_ldadd += $(LIBBITCOIN_UTIL) $(LIBBITCOIN_WALLET) endif if ENABLE_ZMQ bitcoin_qt_ldadd += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(PLATFORM_CXX_LIBS) +endif bitcoin_qt_ldadd += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) $(LIBMEMENV) \ $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(GMP_LIBS) @@ -518,7 +551,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" @@ -536,7 +569,7 @@ $(QT_QRC_CPP): $(QT_QRC) $(QT_FORMS_H) $(QT_RES_ICONS) $(QT_RES_IMAGES) $(QT_RES @test -f $(RCC) || (echo "rcc $(RCC) not found, but is required for generating qrc cpp files"; exit 1) $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(RCC) -name dash --format-version 1 $< > $@ -CLEAN_QT = $(nodist_qt_libbitcoinqt_a_SOURCES) $(QT_QM) $(QT_FORMS_H) qt/*.gcda qt/*.gcno qt/temp_dash_locale.qrc qt/res/css/colors/* +CLEAN_QT = $(nodist_qt_libbitcoinqt_a_SOURCES) $(QT_QM) $(QT_FORMS_H) qt/*.gcda qt/*.gcno qt/platform/*.gcda qt/platform/*.gcno qt/temp_dash_locale.qrc qt/res/css/colors/* CLEANFILES += $(CLEAN_QT) @@ -554,6 +587,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 4690c07a5ea0..4debae880141 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -26,6 +26,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 = \ @@ -37,6 +40,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 \ @@ -76,17 +80,26 @@ 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) qt_test_test_dash_qt_LDADD = $(LIBBITCOINQT) $(LIBBITCOIN_NODE) $(LIBTEST_UTIL) +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_LDADD += $(LIBDASH_PLATFORM) +endif if ENABLE_WALLET qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_UTIL) $(LIBBITCOIN_WALLET) endif if ENABLE_ZMQ qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_LDADD += $(PLATFORM_CXX_LIBS) +endif qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) \ $(LIBMEMENV) $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QT_TEST_LIBS) \ $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 90a0ed75ed64..b0012ab93497 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -226,6 +226,10 @@ BITCOIN_TESTS =\ test/versionbits_tests.cpp \ test/xoroshiro128plusplus_tests.cpp +if ENABLE_PLATFORM_GUI +BITCOIN_TESTS += test/platform_client_tests.cpp +endif + if ENABLE_WALLET BITCOIN_TESTS += \ wallet/test/bip39_tests.cpp \ @@ -272,10 +276,17 @@ endif # ENABLE_WALLET test_test_dash_SOURCES = $(BITCOIN_TEST_SUITE) $(BITCOIN_TESTS) $(JSON_TEST_FILES) $(RAW_TEST_FILES) test_test_dash_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(TESTDEFS) $(BOOST_CPPFLAGS) $(EVENT_CFLAGS) test_test_dash_LDADD = $(LIBTEST_UTIL) +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(LIBDASH_PLATFORM) +test_test_dash_CPPFLAGS += $(PLATFORM_CXX_CFLAGS) +endif if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) endif +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(PLATFORM_CXX_LIBS) +endif test_test_dash_LDADD += $(LIBBITCOIN_NODE) $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBUNIVALUE) \ $(LIBDASHBLS) $(LIBLEVELDB) $(LIBMEMENV) $(BACKTRACE_LIBS) $(LIBSECP256K1) $(EVENT_LIBS) $(EVENT_PTHREADS_LIBS) $(MINISKETCH_LIBS) test_test_dash_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) 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 641579241315..7f08ba98b510 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -115,6 +116,10 @@ class Wallet //! Abort a rescan. virtual void abortRescan() = 0; + //! The height startRescan(false) starts at, when this node has pruned + //! blocks it would read; nullopt when it has them all. + virtual std::optional rescanPrunedFrom() = 0; + //! Lock masternode collaterals virtual void autoLockMasternodeCollaterals() = 0; @@ -142,6 +147,10 @@ class Wallet //! Sign special transaction payload virtual bool signSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) = 0; + //! Whether this wallet can derive Platform keys: SUCCESS for a descriptor + //! wallet with private keys and a single HD root, whether locked or not. + virtual wallet::PlatformKeyStatus platformKeySourceStatus() = 0; + //! Derive and return a public Platform key. The mnemonic-owning key //! manager performs derivation without exposing the wallet's root seed. virtual wallet::PlatformKeyResult getPlatformPubKey(const wallet::PlatformKeyRequest& request) = 0; @@ -156,6 +165,12 @@ class Wallet virtual wallet::PlatformKeyResult platformECDHSecret(const wallet::IdentityAuthKey& key, const CPubKey& counterparty) = 0; + //! DIP-15 accountReference of a contact request sending compact_xpub, + //! keyed by the identity ENCRYPTION key, for account 0 and version 0 as + //! DashPay iOS sends it. The key never leaves the wallet. + virtual wallet::PlatformKeyResult platformAccountReference(const wallet::IdentityAuthKey& key, + const wallet::CompactXpub& compact_xpub) = 0; + //! Ensure our private DIP-15 receiving chain is imported as a ranged //! descriptor and return the corresponding public chain. Derivation, //! descriptor update and result publication are one wallet-locked action. @@ -238,6 +253,15 @@ class Wallet int& change_pos, CAmount& fee) = 0; + //! Create a signed, uncommitted asset lock converting credit_amount duffs + //! into Platform credits. Its single P2PKH credit output pays the key the + //! wallet derives for funding_key, so the credits stay recoverable from + //! the wallet's seed. + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const wallet::AssetLockFundingKey& funding_key, + const wallet::CCoinControl& coin_control, + CAmount& fee) = 0; + //! Fund a transaction template per the provider-transaction funding policy: //! inputs are selected only from coins received at the single specified //! fee-source destination, change is returned to that same destination, and @@ -250,8 +274,9 @@ class Wallet //! Sign every wallet-owned input of a transaction and report whether signing is complete. virtual util::Result signTransaction(const CMutableTransaction& tx) = 0; - //! Commit transaction. - virtual void commitTransaction(CTransactionRef tx, + //! Commit transaction to the wallet and broadcast it. Returns an error + //! when it could not be broadcast; it stays committed to the wallet. + virtual util::Result commitTransaction(CTransactionRef tx, WalletValueMap value_map, WalletOrderForm order_form) = 0; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 2819541d62c2..31e680a1a58a 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -956,6 +956,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..3e89f7fc4bab --- /dev/null +++ b/src/platform/client.cpp @@ -0,0 +1,327 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace platform { + +namespace { + +//! Enough for a write in flight not to hold up the reads a wallet load makes. +constexpr int WORKER_THREADS{4}; + +//! A call. Run() makes the blocking SDK call on a worker; Deliver() releases +//! the unlock scope and runs the callback on the executor. A task is +//! destroyed on the executor or by shutdown(), never on a worker. +class Task +{ +public: + virtual ~Task() = default; + virtual void Run() = 0; + virtual void Deliver() = 0; +}; + +template +class Call final : public Task +{ +public: + Call(std::function()> call, PlatformClient::Callback cb, std::unique_ptr unlock) : + m_call(std::move(call)), + m_cb(std::move(cb)), + m_unlock(std::move(unlock)) + { + } + + void Run() override + { + try { + m_result = m_call(); + } catch (const std::exception& e) { + // A rust::Str or rust::String refused a string that is not UTF-8. + m_result.status.message = e.what(); + } + } + + void Deliver() override + { + // Relock first, so that a write the callback starts unlocks afresh. + m_unlock.reset(); + m_cb(std::move(m_result)); + } + +private: + std::function()> m_call; + PlatformClient::Callback m_cb; + std::unique_ptr m_unlock; + Result m_result; +}; + +class TaskQueue +{ +public: + //! A task pushed after Close() is destroyed here. + void Push(std::unique_ptr task) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + { + LOCK(m_mutex); + if (m_closed) return; + m_tasks.push_back(std::move(task)); + } + m_cv.notify_one(); + } + + //! The next task, waiting for one if wait is set; nullptr once closed. + std::unique_ptr Pop(bool wait) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + WAIT_LOCK(m_mutex, lock); + if (wait) m_cv.wait(lock, [this]() EXCLUSIVE_LOCKS_REQUIRED(m_mutex) { return m_closed || !m_tasks.empty(); }); + if (m_closed || m_tasks.empty()) return nullptr; + auto task{std::move(m_tasks.front())}; + m_tasks.pop_front(); + return task; + } + + //! Destroys the queued tasks here, outside the lock. False if it was + //! already closed. + bool Close() EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + // cppcheck-suppress variableScope + std::deque> dropped; + { + LOCK(m_mutex); + if (m_closed) return false; + m_closed = true; + dropped.swap(m_tasks); + } + m_cv.notify_all(); + return true; + } + +private: + Mutex m_mutex; + std::condition_variable m_cv; + std::deque> m_tasks GUARDED_BY(m_mutex); + bool m_closed GUARDED_BY(m_mutex){false}; +}; + +class SdkClient final : public PlatformClient +{ +public: + SdkClient(rust::Box sdk, Executor executor) : + m_sdk(std::move(sdk)), + m_executor(std::move(executor)) + { + for (int i = 0; i < WORKER_THREADS; ++i) { + m_workers.emplace_back(&util::TraceThread, "platform", [this] { WorkerLoop(); }); + } + } + + ~SdkClient() override { shutdown(); } + + void getIdentity(const Identifier& id, Callback cb) override + { + Submit([this, id] { return marshal::FromFfi(m_sdk->get_identity(id)); }, std::move(cb)); + } + + void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) override + { + Submit([this, + pubkey_hash] { return marshal::FromFfi(m_sdk->get_identity_by_pubkey_hash(pubkey_hash)); }, + std::move(cb)); + } + + void resolveName(const std::string& label, Callback cb) override + { + Submit([this, label] { return marshal::FromFfi(m_sdk->resolve_name(label)); }, std::move(cb)); + } + + void namesOfIdentity(const Identifier& identity, uint32_t page_limit, uint32_t max_pages, Callback cb) override + { + Submit([this, identity, page_limit, + max_pages] { return marshal::FromFfi(m_sdk->names_of_identity(identity, page_limit, max_pages)); }, + std::move(cb)); + } + + void pendingContests(const Identifier& identity, Callback> cb) override + { + Submit>( + [this, identity] { return marshal::FromFfi(m_sdk->pending_contests_for_identity(identity)); }, std::move(cb)); + } + + void contactRequests(const Identifier& identity, ContactDirection direction, uint64_t since_ms, + Callback> cb) override + { + const auto ffi_direction{direction == ContactDirection::SENT ? dash_sdk::ContactDirection::Sent + : dash_sdk::ContactDirection::Received}; + Submit>( + [this, identity, ffi_direction, since_ms] { + return marshal::FromFfi(m_sdk->contact_requests(identity, ffi_direction, since_ms)); + }, + std::move(cb)); + } + + void registerIdentity(std::vector keys, AssetLockProof proof, Signing signing, + Callback cb) override + { + Submit( + [this, keys = std::move(keys), proof = std::move(proof), &wallet = signing.wallet, + index = signing.identity_index] { + const auto ffi_keys{marshal::ToFfi(keys)}; + return marshal::FromFfi( + m_sdk->register_identity({ffi_keys.data(), ffi_keys.size()}, marshal::ToFfi(proof), + /*asset_lock_key_ref=*/index, + MakeWalletSigner(wallet, index, wallet::RegistrationFundingKey{index}))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void topUpIdentity(const Identifier& identity, AssetLockProof proof, wallet::TopupFundingKey funding, + Signing signing, Callback cb) override + { + Submit( + [this, identity, proof = std::move(proof), funding, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->top_up_identity(identity, marshal::ToFfi(proof), + /*asset_lock_key_ref=*/funding.funding_index, + MakeWalletSigner(wallet, index, funding))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void registerName(const std::string& label, const Identifier& identity, uint32_t key_id, Signing signing, + Callback cb) override + { + Submit( + [this, label, identity, key_id, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->register_dpns_name(label, identity, key_id, /*contest_fund=*/0, + MakeWalletSigner(wallet, index, std::nullopt))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void sendContactRequest(const ContactRequestInput& input, Signing signing, Callback cb) override + { + Submit( + [this, input, &wallet = signing.wallet, index = signing.identity_index] { + return marshal::FromFfi(m_sdk->send_contact_request(marshal::ToFfi(input), + MakeWalletSigner(wallet, index, std::nullopt), + MakeContactKeys(wallet, index, input.sender))); + }, + std::move(cb), std::move(signing.unlock)); + } + + void shutdown() override + { + if (!m_calls.Close()) return; + // Cancels the calls in flight and waits for them to return. + m_sdk->shutdown(); + for (auto& worker : m_workers) + worker.join(); + // Finished calls the executor has not run yet die here, unlock + // scopes included, without their callbacks. + m_results->Close(); + } + +private: + template + void Submit(std::function()> call, Callback cb, std::unique_ptr unlock = nullptr) + { + m_calls.Push(std::make_unique>(std::move(call), std::move(cb), std::move(unlock))); + } + + void WorkerLoop() + { + while (auto task{m_calls.Pop(/*wait=*/true)}) { + task->Run(); + m_results->Push(std::move(task)); + // Each posted closure delivers one finished call; it owns none. + m_executor([results = m_results] { + if (auto result{results->Pop(/*wait=*/false)}) result->Deliver(); + }); + } + } + + const rust::Box m_sdk; + const Executor m_executor; + TaskQueue m_calls; + const std::shared_ptr m_results{std::make_shared()}; + std::vector m_workers; +}; + +dash_sdk::Network SdkNetwork() +{ + const std::string network{Params().NetworkIDString()}; + if (network == CBaseChainParams::MAIN) return dash_sdk::Network::Mainnet; + if (network == CBaseChainParams::TESTNET) return dash_sdk::Network::Testnet; + if (network == CBaseChainParams::DEVNET) return dash_sdk::Network::Devnet; + return dash_sdk::Network::Regtest; +} + +} // namespace + +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config, interfaces::LLMQ& llmq, Executor executor) +{ + try { + dash_sdk::Config sdk_config; + sdk_config.network = SdkNetwork(); + sdk_config.platform_llmq_type = static_cast(Params().GetConsensus().llmqTypePlatform); + sdk_config.proxy = marshal::ToFfi(config.proxy); + for (const CService& endpoint : config.endpoints) { + sdk_config.endpoints.push_back("https://" + endpoint.ToStringAddrPort()); + } + return std::make_unique( + dash_sdk::new_platform_client(sdk_config, MakeCoreContext(llmq, sdk_config.platform_llmq_type)), + std::move(executor)); + } catch (const std::exception& e) { + LogPrintf("Platform client: the SDK refused its configuration: %s\n", e.what()); + return nullptr; + } +} + +bool IsValidUsername(const std::string& label) +{ + try { + return dash_sdk::is_valid_username(label); + } catch (const std::exception&) { + return false; + } +} + +bool IsContestedUsername(const std::string& label) +{ + try { + return dash_sdk::is_contested_username(label); + } catch (const std::exception&) { + return true; + } +} + +std::optional DecryptContactXpub(const std::array& shared_secret, + Span encrypted_public_key) +{ + try { + const auto xpub{ + dash_sdk::decrypt_contact_xpub(shared_secret, {encrypted_public_key.data(), encrypted_public_key.size()})}; + return wallet::FriendshipXpub{CPubKey{xpub.public_key}, ChainCode{xpub.chain_code}, xpub.parent_fingerprint}; + } catch (const std::exception&) { + return std::nullopt; + } +} + +} // namespace platform diff --git a/src/platform/client.h b/src/platform/client.h new file mode 100644 index 000000000000..2f06e6f89bfa --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,129 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_PLATFORM_CLIENT_H +#define BITCOIN_PLATFORM_CLIENT_H + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace interfaces { +class LLMQ; +class Wallet; +} // namespace interfaces + +namespace platform { + +//! Keeps the wallet unlocked while a write signs. The consumer implements it +//! (dash-qt wraps WalletModel::UnlockContext). The client destroys it on the +//! executor just before the write's callback runs, or in shutdown(). +class UnlockScope +{ +public: + virtual ~UnlockScope() = default; +}; + +//! The wallet a write signs with. Identity keys are +//! IdentityAuthKey{identity_index, key id}. The wallet must stay loaded +//! until the client has shut down. +struct Signing { + // cppcheck-suppress uninitMemberVarNoCtor + interfaces::Wallet& wallet; + uint32_t identity_index{0}; + std::unique_ptr unlock; +}; + +//! Runs a task later on the consumer's thread, never inline (dash-qt queues +//! it with QMetaObject::invokeMethod). The client calls it from several +//! worker threads at once until shutdown() returns. A task owns nothing: one +//! that runs after shutdown() does nothing. +using Executor = std::function)>; + +struct ClientConfig { + std::optional proxy; //!< nullopt: connect directly + //! Evonode Platform HTTPS endpoints, fixed for the client's lifetime. + std::vector endpoints; + + friend bool operator==(const ClientConfig&, const ClientConfig&) = default; +}; + +//! Asynchronous Dash Platform client. Each call runs on a worker thread and +//! its callback runs through the executor, never on a worker. Writes sign +//! through the wallet in Signing; private keys stay in the wallet. +class PlatformClient +{ +public: + template + using Callback = std::function)>; + + virtual ~PlatformClient() = default; + + virtual void getIdentity(const Identifier& id, Callback cb) = 0; + //! The identity whose key hashes to pubkey_hash (HASH160). + virtual void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback cb) = 0; + //! The identity a DPNS name resolves to; the SDK normalizes the label. + virtual void resolveName(const std::string& label, Callback cb) = 0; + //! The names of an identity, reading at most max_pages pages of page_limit. + virtual void namesOfIdentity(const Identifier& identity, uint32_t page_limit, uint32_t max_pages, + Callback cb) = 0; + //! Best effort: the SDK examines at most 100 contests. + virtual void pendingContests(const Identifier& identity, Callback> cb) = 0; + //! Contact requests sent by or to identity, created strictly after + //! since_ms (0: all). + virtual void contactRequests(const Identifier& identity, ContactDirection direction, uint64_t since_ms, + Callback> cb) = 0; + + //! Create an identity with keys, funded by an asset lock paying + //! RegistrationFundingKey{signing.identity_index}. + virtual void registerIdentity(std::vector keys, AssetLockProof proof, Signing signing, + Callback cb) = 0; + //! Add the asset lock paying funding to the identity's balance; the + //! result is the new balance. + virtual void topUpIdentity(const Identifier& identity, AssetLockProof proof, wallet::TopupFundingKey funding, + Signing signing, Callback cb) = 0; + //! Register a DPNS name, signed with identity key key_id. The result is + //! the domain document id. + virtual void registerName(const std::string& label, const Identifier& identity, uint32_t key_id, Signing signing, + Callback cb) = 0; + //! The result is the contact request document id. + virtual void sendContactRequest(const ContactRequestInput& input, Signing signing, Callback cb) = 0; + + //! Drop queued calls, cancel and wait for running ones, and destroy + //! every call not yet delivered, unlock scopes included. No callback runs + //! afterwards, and later calls are dropped: a dropped call never calls + //! back. Call it, or destroy the client, on the executor's thread (a + //! result callback may), never while holding cs_main or cs_wallet, which + //! the SDK's wallet and quorum callbacks take. + virtual void shutdown() = 0; +}; + +//! The client over dash-sdk-cxx, verifying proofs against the quorums llmq +//! retains; llmq must outlive the client. Network and Platform LLMQ type come +//! from Params(). Returns nullptr when the SDK refuses the configuration, such +//! as a proxy it cannot use: it never falls back to connecting directly. +std::unique_ptr MakeSdkPlatformClient(const ClientConfig& config, interfaces::LLMQ& llmq, + Executor executor); + +//! DPNS rules, as the SDK applies them. +bool IsValidUsername(const std::string& label); +//! Fails closed: true when the SDK cannot answer. +bool IsContestedUsername(const std::string& label); + +//! Decrypt a contact request's encryptedPublicKey with the ECDH secret. +//! The public key is not validated: check IsFullyValid() before use. +std::optional DecryptContactXpub(const std::array& shared_secret, + Span encrypted_public_key); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_CLIENT_H diff --git a/src/platform/marshal.cpp b/src/platform/marshal.cpp new file mode 100644 index 000000000000..a1cef4caf9d6 --- /dev/null +++ b/src/platform/marshal.cpp @@ -0,0 +1,209 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include +#include + +namespace platform::marshal { + +namespace { + +std::vector Bytes(const rust::Vec& bytes) { return {bytes.begin(), bytes.end()}; } + +template +rust::Vec ToVec(const T& bytes) +{ + rust::Vec out; + out.reserve(bytes.size()); + for (const auto byte : bytes) + out.push_back(static_cast(byte)); + return out; +} + +template +rust::Vec Serialized(const T& object) +{ + CDataStream stream{SER_NETWORK, PROTOCOL_VERSION}; + stream << object; + return ToVec(stream); +} + +Status FromFfi(const dash_sdk::Status& status) +{ + Status out; + switch (status.kind) { + case dash_sdk::StatusKind::Ok: + out.kind = StatusKind::OK; + break; + case dash_sdk::StatusKind::NotFound: + out.kind = StatusKind::NOT_FOUND; + break; + case dash_sdk::StatusKind::Unavailable: + out.kind = StatusKind::UNAVAILABLE; + break; + case dash_sdk::StatusKind::Rejected: + out.kind = StatusKind::REJECTED; + break; + case dash_sdk::StatusKind::Consensus: + out.kind = StatusKind::CONSENSUS; + break; + case dash_sdk::StatusKind::Internal: + out.kind = StatusKind::INTERNAL; + break; + } + out.consensus_code = status.consensus_code; + out.message = std::string{status.message}; + return out; +} + +IdentityPublicKey FromFfi(const dash_sdk::IdentityKey& key) +{ + IdentityPublicKey out; + out.id = key.id; + out.purpose = static_cast(key.purpose); + out.security_level = static_cast(key.security_level); + out.type = static_cast(key.key_type); + out.read_only = key.read_only; + out.data = Bytes(key.data); + out.disabled_at = key.disabled_at; + return out; +} + +DpnsName FromFfi(const dash_sdk::DpnsName& name) +{ + return {std::string{name.label}, std::string{name.normalized_label}, name.owner, name.identity}; +} + +ContactRequest FromFfi(const dash_sdk::ContactRequest& request) +{ + ContactRequest out; + out.document_id = request.document_id; + out.owner = request.owner; + out.to_user_id = request.to_user_id; + out.encrypted_public_key = Bytes(request.encrypted_public_key); + out.sender_key_index = request.sender_key_index; + out.recipient_key_index = request.recipient_key_index; + out.account_reference = request.account_reference; + out.encrypted_account_label = Bytes(request.encrypted_account_label); + out.auto_accept_proof = Bytes(request.auto_accept_proof); + out.created_at_ms = request.created_at_ms; + out.created_at_core_height = request.created_at_core_height; + return out; +} + +template +std::vector Each(const rust::Vec& items) +{ + std::vector out; + out.reserve(items.size()); + for (const In& item : items) + out.push_back(FromFfi(item)); + return out; +} + +} // namespace + +Result FromFfi(const dash_sdk::IdentityResult& result) +{ + const auto& identity{result.identity}; + return {FromFfi(result.status), + {identity.id, identity.balance, identity.revision, Each(identity.keys)}}; +} + +Result FromFfi(const dash_sdk::IdResult& result) { return {FromFfi(result.status), result.id}; } + +Result FromFfi(const dash_sdk::BalanceResult& result) { return {FromFfi(result.status), result.balance}; } + +Result FromFfi(const dash_sdk::NamesResult& result) +{ + return {FromFfi(result.status), {Each(result.names), result.complete}}; +} + +Result> FromFfi(const dash_sdk::ContestsResult& result) +{ + std::vector contests; + contests.reserve(result.contests.size()); + for (const auto& contest : result.contests) { + contests.push_back({std::string{contest.name}, contest.ends_at_ms}); + } + return {FromFfi(result.status), std::move(contests)}; +} + +Result> FromFfi(const dash_sdk::ContactRequestsResult& result) +{ + return {FromFfi(result.status), Each(result.requests)}; +} + +std::vector ToFfi(const std::vector& keys) +{ + std::vector out; + out.reserve(keys.size()); + for (const IdentityPublicKey& key : keys) { + dash_sdk::IdentityKey ffi; + ffi.id = key.id; + ffi.purpose = static_cast(key.purpose); + ffi.security_level = static_cast(key.security_level); + ffi.key_type = static_cast(key.type); + ffi.read_only = key.read_only; + ffi.data = ToVec(key.data); + ffi.disabled_at = key.disabled_at; + out.push_back(std::move(ffi)); + } + return out; +} + +dash_sdk::AssetLockProof ToFfi(const AssetLockProof& proof) +{ + dash_sdk::AssetLockProof out{}; + if (const auto* instant{std::get_if(&proof)}) { + out.kind = dash_sdk::AssetLockProofKind::Instant; + out.transaction = Serialized(*instant->transaction); + out.instant_lock = ToVec(instant->instant_lock); + out.output_index = instant->output_index; + } else { + const auto& chain{std::get(proof)}; + out.kind = dash_sdk::AssetLockProofKind::Chain; + out.core_chain_locked_height = chain.core_chain_locked_height; + const auto out_point{Serialized(chain.out_point)}; + std::copy(out_point.begin(), out_point.end(), out.out_point.begin()); + } + return out; +} + +dash_sdk::ContactRequestInput ToFfi(const ContactRequestInput& input) +{ + dash_sdk::ContactRequestInput out; + out.sender = input.sender; + out.recipient = input.recipient; + out.sender_key_index = input.sender_key_index; + out.recipient_key_index = input.recipient_key_index; + out.signing_key_id = input.signing_key_id; + out.account_reference = input.account_reference; + out.account_label = input.account_label; + return out; +} + +dash_sdk::Proxy ToFfi(const std::optional& proxy) +{ + dash_sdk::Proxy out; + out.kind = dash_sdk::ProxyKind::None; + if (!proxy) return out; + out.isolate = proxy->randomize_credentials; + if (const auto* service{std::get_if(&proxy->address)}) { + out.kind = dash_sdk::ProxyKind::Tcp; + out.address = service->ToStringAddrPort(); + } else { + out.kind = dash_sdk::ProxyKind::Unix; + out.address = std::get(proxy->address); + } + return out; +} + +} // namespace platform::marshal diff --git a/src/platform/marshal.h b/src/platform/marshal.h new file mode 100644 index 000000000000..64e414fba8e2 --- /dev/null +++ b/src/platform/marshal.h @@ -0,0 +1,33 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_PLATFORM_MARSHAL_H +#define BITCOIN_PLATFORM_MARSHAL_H + +#include + +#include + +#include +#include +#include + +//! Conversions between dash-sdk-cxx's bridge structs and platform:: types. +namespace platform::marshal { + +Result FromFfi(const dash_sdk::IdentityResult& result); +Result FromFfi(const dash_sdk::IdResult& result); +Result FromFfi(const dash_sdk::BalanceResult& result); +Result FromFfi(const dash_sdk::NamesResult& result); +Result> FromFfi(const dash_sdk::ContestsResult& result); +Result> FromFfi(const dash_sdk::ContactRequestsResult& result); + +std::vector ToFfi(const std::vector& keys); +dash_sdk::AssetLockProof ToFfi(const AssetLockProof& proof); +dash_sdk::ContactRequestInput ToFfi(const ContactRequestInput& input); +dash_sdk::Proxy ToFfi(const std::optional& proxy); + +} // namespace platform::marshal + +#endif // BITCOIN_PLATFORM_MARSHAL_H diff --git a/src/platform/signer.cpp b/src/platform/signer.cpp new file mode 100644 index 000000000000..5f5d97132bc9 --- /dev/null +++ b/src/platform/signer.cpp @@ -0,0 +1,93 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace platform { + +namespace { + +constexpr uint8_t KEY_TYPE_ECDSA_SECP256K1{static_cast(IdentityPublicKey::Type::ECDSA_SECP256K1)}; + +bool Sign(interfaces::Wallet& wallet, const wallet::PlatformKeyRequest& key, const uint256& digest, + std::array& sig_out) +{ + const auto signature{wallet.signPlatformDigest(key, digest)}; + if (!signature || signature.value.size() != sig_out.size()) return false; + std::copy(signature.value.begin(), signature.value.end(), sig_out.begin()); + return true; +} + +} // namespace + +std::unique_ptr MakeWalletSigner(interfaces::Wallet& wallet, uint32_t identity_index, + std::optional funding_key) +{ + return std::make_unique( + [&wallet, identity_index](uint32_t key_id, uint8_t key_type, std::span /*key_data*/, + std::span signable, std::array& sig_out) { + return key_type == KEY_TYPE_ECDSA_SECP256K1 && + Sign(wallet, wallet::IdentityAuthKey{identity_index, key_id}, Hash(signable), sig_out); + }, + [&wallet, funding_key](uint32_t /*key_ref*/, const std::array& sighash, + std::array& sig_out) { + if (!funding_key) return false; + const auto key{std::visit([](auto k) -> wallet::PlatformKeyRequest { return k; }, *funding_key)}; + return Sign(wallet, key, uint256{sighash}, sig_out); + }); +} + +std::unique_ptr MakeContactKeys(interfaces::Wallet& wallet, uint32_t identity_index, + const Identifier& sender) +{ + return std::make_unique( + [&wallet, identity_index](uint32_t sender_key_id, const std::array& recipient_key, + std::array& secret_out) { + const auto secret{wallet.platformECDHSecret(wallet::IdentityAuthKey{identity_index, sender_key_id}, + CPubKey{recipient_key})}; + if (!secret || secret.value.size() != secret_out.size()) return false; + std::copy(secret.value.begin(), secret.value.end(), secret_out.begin()); + return true; + }, + [&wallet, sender](const std::array& recipient, uint32_t /*account_reference*/, + std::array& xpub_out) { + // Account 0, as DashPay iOS sends it. No payment to this chain + // can predate its publication, so it is born now. + const auto keychain{wallet.ensureFriendshipReceivingKeychain( + {/*account=*/0, uint256{sender}, uint256{recipient}, + /*birth_time=*/TicksSinceEpoch(NodeClock::now())})}; + return keychain && wallet::CompactXpubBytes(keychain.value, xpub_out); + }); +} + +std::unique_ptr MakeCoreContext(interfaces::LLMQ& llmq, uint8_t platform_llmq_type) +{ + return std::make_unique( + [&llmq, platform_llmq_type](uint8_t llmq_type, const std::array& quorum_hash, + uint32_t /*core_chain_locked_height*/, std::vector& key_out) { + if (llmq_type != platform_llmq_type) return false; + const uint256 hash{quorum_hash}; + for (auto& quorum : llmq.getPlatformQuorums(llmq_type)) { + if (quorum.m_quorum_hash == hash) { + key_out = std::move(quorum.m_pubkey); + return true; + } + } + return false; + }, + dash_sdk::CoreContext::PlatformActivationHeightFn{}); +} + +} // namespace platform diff --git a/src/platform/signer.h b/src/platform/signer.h new file mode 100644 index 000000000000..7287158830fb --- /dev/null +++ b/src/platform/signer.h @@ -0,0 +1,46 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_PLATFORM_SIGNER_H +#define BITCOIN_PLATFORM_SIGNER_H + +#include +#include + +#include + +#include +#include +#include + +namespace interfaces { +class LLMQ; +class Wallet; +} // namespace interfaces + +//! The C++ objects dash-sdk-cxx calls back into. They run on SDK worker +//! threads and touch only thread-safe node and wallet interfaces. +namespace platform { + +//! Signs with the wallet's Platform keys: identity key `key_id` as +//! IdentityAuthKey{identity_index, key_id}, and the asset lock with +//! funding_key (none: refused). +std::unique_ptr MakeWalletSigner(interfaces::Wallet& wallet, uint32_t identity_index, + std::optional funding_key); + +//! The wallet's half of a contact request from sender: the ECDH secret of +//! IdentityAuthKey{identity_index, sender_key_id}, and the compact xpub of +//! our DIP-15 receiving chain towards the recipient, imported as it is +//! published. +std::unique_ptr MakeContactKeys(interfaces::Wallet& wallet, uint32_t identity_index, + const Identifier& sender); + +//! Answers quorum keys from the quorums the node retains, matching both +//! type and hash. There is no Platform activation height source: the SDK +//! needs it only for a query the binding does not expose. +std::unique_ptr MakeCoreContext(interfaces::LLMQ& llmq, uint8_t platform_llmq_type); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_SIGNER_H diff --git a/src/platform/types.h b/src/platform/types.h new file mode 100644 index 000000000000..acd60d95c15d --- /dev/null +++ b/src/platform/types.h @@ -0,0 +1,175 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_PLATFORM_TYPES_H +#define BITCOIN_PLATFORM_TYPES_H + +#include +#include + +#include +#include +#include +#include +#include + +//! The values PlatformClient reads and writes, as plain Core types. They +//! mirror dash-sdk-cxx's bridge structs, so nothing above src/platform +//! includes the binding. +namespace platform { + +using Identifier = std::array; + +//! How a call ended, as the binding classifies it. The message is for logs. +enum class StatusKind : uint8_t { + OK, + //! Proved absence: the identity or name does not exist. For resolveName + //! this means "does not resolve", not "available". + NOT_FOUND, + //! Transport, proxy, timeout, no endpoint, or the client is shut down. + UNAVAILABLE, + //! A proof or quorum check refused the response. + REJECTED, + //! Platform refused the state transition; consensus_code says why. + CONSENSUS, + //! Bad input, a refused callback, or an unclassified SDK error. + INTERNAL, +}; + +struct Status { + StatusKind kind{StatusKind::INTERNAL}; + uint32_t consensus_code{0}; + std::string message; +}; + +//! The value is meaningful only under OK. +template +struct Result { + Status status; + T value{}; + + bool ok() const { return status.kind == StatusKind::OK; } +}; + +struct IdentityPublicKey { + enum class Type : uint8_t { + ECDSA_SECP256K1 = 0, + BLS12_381 = 1, + ECDSA_HASH160 = 2, + BIP13_SCRIPT_HASH = 3, + EDDSA_25519_HASH160 = 4, + }; + enum class Purpose : uint8_t { + AUTHENTICATION = 0, + ENCRYPTION = 1, + DECRYPTION = 2, + TRANSFER = 3, + SYSTEM = 4, + VOTING = 5, + OWNER = 6, + }; + enum class SecurityLevel : uint8_t { + MASTER = 0, + CRITICAL = 1, + HIGH = 2, + MEDIUM = 3, + }; + + uint32_t id{0}; + Purpose purpose{Purpose::AUTHENTICATION}; + SecurityLevel security_level{SecurityLevel::MASTER}; + Type type{Type::ECDSA_SECP256K1}; + bool read_only{false}; + std::vector data; //!< the 33-byte public key for ECDSA_SECP256K1 + uint64_t disabled_at{0}; //!< 0: enabled +}; + +struct Identity { + Identifier id{}; + uint64_t balance{0}; //!< credits + uint64_t revision{0}; + std::vector public_keys; +}; + +struct DpnsName { + std::string label; + std::string normalized_label; + Identifier owner{}; + Identifier identity{}; //!< records.identity, all zero if none +}; + +struct Names { + std::vector names; + //! false: the page bound was reached, so the list is a lower bound. + bool complete{false}; +}; + +//! A contested name the identity is a contender for, still being voted on. +struct Contest { + std::string normalized_label; + uint64_t ends_at_ms{0}; +}; + +enum class ContactDirection : uint8_t { + SENT, + RECEIVED, +}; + +//! A DashPay contactRequest document. +struct ContactRequest { + Identifier document_id{}; + Identifier owner{}; + Identifier to_user_id{}; + std::vector encrypted_public_key; + uint32_t sender_key_index{0}; + uint32_t recipient_key_index{0}; + uint32_t account_reference{0}; + std::vector encrypted_account_label; //!< empty: absent + std::vector auto_accept_proof; //!< empty: absent + uint64_t created_at_ms{0}; + uint32_t created_at_core_height{0}; +}; + +//! An InstantSend-locked asset lock: the transaction, its serialized islock +//! and the index of the credit output. +struct InstantAssetLockProof { + CTransactionRef transaction; + std::vector instant_lock; + uint32_t output_index{0}; +}; + +//! A ChainLocked asset lock: its credit output and a ChainLocked height at +//! or above the block that mined it. +struct ChainAssetLockProof { + uint32_t core_chain_locked_height{0}; + COutPoint out_point; +}; + +using AssetLockProof = std::variant; + +//! A DIP-15 contact request to send. account_reference is the finished +//! value from interfaces::Wallet::platformAccountReference. +struct ContactRequestInput { + Identifier sender{}; + Identifier recipient{}; + uint32_t sender_key_index{0}; //!< our ENCRYPTION key + uint32_t recipient_key_index{0}; + uint32_t signing_key_id{0}; + uint32_t account_reference{0}; + std::string account_label; //!< empty: none +}; + +//! The SOCKS5 proxy every connection of a client goes through. +struct ProxyConfig { + //! A numeric address, or the path of a Unix socket (without "unix:"). + std::variant address; + //! -proxyrandomize: fresh credentials per connection. + bool randomize_credentials{true}; + + friend bool operator==(const ProxyConfig&, const ProxyConfig&) = default; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_TYPES_H diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 0ad815bd8bcf..bdf0137918e2 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -785,6 +785,14 @@ void BitcoinGUI::createToolBars() governanceButton->setStatusTip(tr("View Governance Proposals")); tabGroup->addButton(governanceButton); +#ifdef ENABLE_PLATFORM_GUI + platformButton = new QToolButton(this); + platformButton->setText(tr("&DashPay")); + platformButton->setStatusTip(tr("Usernames and contacts on Dash Platform")); + tabGroup->addButton(platformButton); + connect(platformButton, &QToolButton::clicked, this, &BitcoinGUI::gotoPlatformPage); +#endif + connect(overviewButton, &QToolButton::clicked, this, &BitcoinGUI::gotoOverviewPage); connect(sendCoinsButton, &QToolButton::clicked, [this]{ gotoSendCoinsPage(); }); connect(coinJoinCoinsButton, &QToolButton::clicked, [this]{ gotoCoinJoinCoinsPage(); }); @@ -1091,6 +1099,9 @@ void BitcoinGUI::setWalletActionsEnabled(bool enabled) coinJoinCoinsButton->setEnabled(enabled && clientModel->coinJoinOptions().isEnabled()); receiveCoinsButton->setEnabled(enabled); historyButton->setEnabled(enabled); +#ifdef ENABLE_PLATFORM_GUI + platformButton->setEnabled(enabled); +#endif } #endif // ENABLE_WALLET @@ -1341,6 +1352,14 @@ void BitcoinGUI::gotoMasternodePage() } } +#ifdef ENABLE_PLATFORM_GUI +void BitcoinGUI::gotoPlatformPage() +{ + platformButton->setChecked(true); + if (walletFrame) walletFrame->gotoPlatformPage(); +} +#endif + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsButton->setChecked(true); diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 6243619b05d5..e7197d0e8787 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -143,6 +143,9 @@ class BitcoinGUI : public QMainWindow QToolButton* historyButton = nullptr; QToolButton* masternodeButton = nullptr; QToolButton* governanceButton = nullptr; +#ifdef ENABLE_PLATFORM_GUI + QToolButton* platformButton = nullptr; +#endif QAction* appToolBarLogoAction = nullptr; QAction* quitAction = nullptr; QAction* sendCoinsAction = nullptr; @@ -356,6 +359,10 @@ public Q_SLOTS: void gotoHistoryPage(); /** Switch to masternode page */ void gotoMasternodePage(); +#ifdef ENABLE_PLATFORM_GUI + /** Switch to DashPay page */ + void gotoPlatformPage(); +#endif /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ diff --git a/src/qt/masternodemodel.h b/src/qt/masternodemodel.h index 3273b5a7f5f2..92f1a332de2a 100644 --- a/src/qt/masternodemodel.h +++ b/src/qt/masternodemodel.h @@ -99,6 +99,7 @@ class MasternodeEntry const CKeyID& keyIdOwnerRaw() const { return m_dmn->getKeyIdOwner(); } const CKeyID& keyIdVotingRaw() const { return m_dmn->getKeyIdVoting(); } + std::vector platformHTTPSAddrsRaw() const { return m_dmn->getPlatformHTTPSAddrs(); } const COutPoint& collateralOutpointRaw() const { return m_dmn->getCollateralOutpoint(); } const CScript& scriptOperatorPayoutRaw() const { return m_dmn->getScriptOperatorPayout(); } const CScript& scriptPayoutRaw() const { return m_dmn->getScriptPayout(); } diff --git a/src/qt/platform/identityflow.cpp b/src/qt/platform/identityflow.cpp new file mode 100644 index 000000000000..d06d9d01466d --- /dev/null +++ b/src/qt/platform/identityflow.cpp @@ -0,0 +1,392 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { +using platform::IdentityPublicKey; +using Funding = IdentityFlow::Funding; + +//! The asset lock's one credit output. +constexpr uint32_t CREDIT_OUTPUT{0}; +//! rs-dpp IdentityAssetLockTransactionOutPointAlreadyConsumedError. +constexpr uint32_t CODE_ASSET_LOCK_CONSUMED{10504}; +//! The funding key index of the next top-up. +constexpr const char* NEXT_TOP_UP_RECORD{"dashpay/topup-index"}; + +//! The keys an identity registers, by id as the mobile wallets register +//! them: the SDK's four base keys, then the ENCRYPTION and DECRYPTION pair of +//! contact requests. +struct KeySpec { + uint32_t id; + IdentityPublicKey::Purpose purpose; + IdentityPublicKey::SecurityLevel security_level; +}; +constexpr KeySpec REGISTRATION_KEYS[]{ + {0, IdentityPublicKey::Purpose::AUTHENTICATION, IdentityPublicKey::SecurityLevel::MASTER}, + {1, IdentityPublicKey::Purpose::AUTHENTICATION, IdentityPublicKey::SecurityLevel::CRITICAL}, + {2, IdentityPublicKey::Purpose::AUTHENTICATION, IdentityPublicKey::SecurityLevel::HIGH}, + {3, IdentityPublicKey::Purpose::TRANSFER, IdentityPublicKey::SecurityLevel::CRITICAL}, + {4, IdentityPublicKey::Purpose::ENCRYPTION, IdentityPublicKey::SecurityLevel::MEDIUM}, + {5, IdentityPublicKey::Purpose::DECRYPTION, IdentityPublicKey::SecurityLevel::MEDIUM}, +}; + +std::string RegistrationRecord() +{ + return strprintf("%sidentity/%u", IdentityFlow::FUNDING_PREFIX, IdentityFlow::IDENTITY_INDEX); +} +std::string TopUpRecord(uint32_t index) { return strprintf("%stopup/%u", IdentityFlow::FUNDING_PREFIX, index); } + +std::vector Encode(const Funding& funding) +{ + DataStream stream; + stream << funding.txid << funding.amount; + if (funding.top_up) stream << funding.identity; + return {UCharCast(stream.data()), UCharCast(stream.data() + stream.size())}; +} + +std::optional Decode(const std::string& record, const std::vector& value) +{ + Funding funding; + funding.record = record; + const std::string name{record.substr(std::strlen(IdentityFlow::FUNDING_PREFIX))}; + if (name.rfind("topup/", 0) == 0) { + const auto index{ToIntegral(name.substr(std::strlen("topup/")))}; + if (!index) return std::nullopt; + funding.top_up = wallet::TopupFundingKey{*index}; + } else if (record != RegistrationRecord()) { + return std::nullopt; + } + try { + DataStream stream{MakeUCharSpan(value)}; + stream >> funding.txid >> funding.amount; + if (funding.top_up) stream >> funding.identity; + } catch (const std::ios_base::failure&) { + return std::nullopt; + } + return funding; +} + +uint32_t NextTopUpIndex(interfaces::Wallet& wallet) +{ + const auto records{wallet.getPlatformData(NEXT_TOP_UP_RECORD)}; + const auto it{records.find(NEXT_TOP_UP_RECORD)}; + return it == records.end() || it->second.size() != 4 ? 0 : ReadLE32(it->second.data()); +} + +bool SaveFunding(interfaces::Wallet& wallet, const Funding& funding) +{ + if (funding.top_up) { + std::vector next(4); + WriteLE32(next.data(), funding.top_up->funding_index + 1); + if (!wallet.writePlatformData(NEXT_TOP_UP_RECORD, next)) return false; + } + return wallet.writePlatformData(funding.record, Encode(funding)); +} + +std::pair WalletTxOf(interfaces::Wallet& wallet, const uint256& txid) +{ + interfaces::WalletTxStatus status{}; + interfaces::WalletOrderForm order_form; + bool in_mempool{false}; + int num_blocks{0}; + const CTransactionRef tx{wallet.getWalletTxDetails(txid, status, order_form, in_mempool, num_blocks).tx}; + return {tx, status}; +} + +std::optional> MasterKeyHash(interfaces::Wallet& wallet) +{ + const auto key{ + wallet.getPlatformPubKey(wallet::IdentityAuthKey{IdentityFlow::IDENTITY_INDEX, REGISTRATION_KEYS[0].id})}; + if (!key) return std::nullopt; + const CKeyID hash{key.value.GetID()}; + std::array out; + std::copy(hash.begin(), hash.end(), out.begin()); + return out; +} + +std::optional> RegistrationKeys(interfaces::Wallet& wallet) +{ + std::vector keys; + for (const KeySpec& spec : REGISTRATION_KEYS) { + const auto pubkey{wallet.getPlatformPubKey(wallet::IdentityAuthKey{IdentityFlow::IDENTITY_INDEX, spec.id})}; + if (!pubkey) return std::nullopt; + IdentityPublicKey key; + key.id = spec.id; + key.purpose = spec.purpose; + key.security_level = spec.security_level; + key.data.assign(pubkey.value.begin(), pubkey.value.end()); + keys.push_back(std::move(key)); + } + return keys; +} + +std::optional NodeProof(interfaces::Node& node, interfaces::Wallet& wallet, + const CTransactionRef& tx) +{ + auto islock{node.llmq().getInstantSendLock(tx->GetHash())}; + if (!islock.empty()) return platform::InstantAssetLockProof{tx, std::move(islock), CREDIT_OUTPUT}; + // The node drops the InstantSend lock once the block is ChainLocked. + const auto status{WalletTxOf(wallet, tx->GetHash()).second}; + if (!status.is_chainlocked) return std::nullopt; + return platform::ChainAssetLockProof{static_cast(status.block_height), + COutPoint{tx->GetHash(), CREDIT_OUTPUT}}; +} + +} // namespace + +IdentityFlow::IdentityFlow(PlatformService& service, Confirm confirm, Proof proof) : + m_service(service), + m_confirm(std::move(confirm)), + m_proof(std::move(proof)) +{ + if (!m_proof) { + m_proof = [&service](const CTransactionRef& tx) { + return NodeProof(service.clientModel().node(), service.walletModel().wallet(), tx); + }; + } + connect(&m_service, &PlatformService::changed, this, &IdentityFlow::serviceChanged); + connect(&m_service.clientModel(), &ClientModel::instantSendChanged, this, &IdentityFlow::send); + connect(&m_service.clientModel(), &ClientModel::chainLockChanged, this, &IdentityFlow::send); + serviceChanged(); +} + +std::optional IdentityFlow::funding() const +{ + for (const auto& [record, value] : m_service.walletModel().wallet().getPlatformData(FUNDING_PREFIX)) { + if (auto funding{Decode(record, value)}) return funding; + } + return std::nullopt; +} + +void IdentityFlow::serviceChanged() +{ + // A call the client dropped never calls back; a funding record stays for Continue. + if ((m_step == Step::LOOKING_UP || m_step == Step::SENDING) && m_call.expired()) finish(); + if (m_service.gate() != PlatformService::Gate::NONE) { + m_looked_up = false; + m_identity.reset(); + if (m_step == Step::WAITING_FOR_LOCK) finish(); + } + if (m_step == Step::WAITING_FOR_LOCK) { + send(); + } else if (!m_looked_up) { + startLookUp(/*unlock=*/false); + } + Q_EMIT changed(); +} + +void IdentityFlow::finish(const QString& error) +{ + m_step = Step::IDLE; + m_funding.reset(); + m_tx.reset(); + m_unlock.reset(); + m_error = error; + Q_EMIT changed(); +} + +void IdentityFlow::lookUp() { startLookUp(/*unlock=*/true); } + +void IdentityFlow::startLookUp(bool unlock) +{ + if (m_step != Step::IDLE || !m_service.client()) return; + if (!m_key_hash) { + std::optional context; + if (unlock) context.emplace(m_service.walletModel().requestUnlock()); + m_key_hash = MasterKeyHash(m_service.walletModel().wallet()); + } + if (!m_key_hash) { + if (unlock) finish(tr("The wallet stayed locked, so DashPay could not look up your identity.")); + return; + } + // The passphrase dialog runs an event loop, which may have started a call. + platform::PlatformClient* client{m_service.client()}; + if (m_step != Step::IDLE || !client) return; + m_step = Step::LOOKING_UP; + m_error.clear(); + client->getIdentityByPublicKeyHash(*m_key_hash, PlatformUi::Tracked(m_call, [this](auto result) { + onLookedUp(std::move(result)); + })); + Q_EMIT changed(); +} + +void IdentityFlow::onLookedUp(platform::Result result) +{ + if (!result.ok() && result.status.kind != platform::StatusKind::NOT_FOUND) { + return finish(PlatformUi::Describe(result.status)); + } + m_looked_up = true; + m_identity.reset(); + if (result.ok()) { + m_identity = std::move(result.value); + // The record is erased once the identity is found. + if (const auto pending{funding()}; pending && !pending->top_up) { + m_service.walletModel().wallet().writePlatformData(pending->record, {}); + } + } + finish(); +} + +void IdentityFlow::create() +{ + if (!m_looked_up || m_identity) return; + Funding pending; + pending.record = RegistrationRecord(); + fund(wallet::RegistrationFundingKey{IDENTITY_INDEX}, REGISTRATION_AMOUNT, std::move(pending)); +} + +void IdentityFlow::topUp(CAmount amount) +{ + if (!m_identity) return; + const uint32_t index{NextTopUpIndex(m_service.walletModel().wallet())}; + Funding pending; + pending.record = TopUpRecord(index); + pending.top_up = wallet::TopupFundingKey{index}; + pending.identity = m_identity->id; + fund(*pending.top_up, amount, std::move(pending)); +} + +std::optional IdentityFlow::beginFunding() +{ + // Keeps other calls out while the passphrase and confirmation dialogs run their event loops. + m_step = Step::FUNDING; + m_error.clear(); + Q_EMIT changed(); + std::optional unlock{m_service.walletModel().requestUnlock()}; + if (!unlock->isValid()) { + finish(tr("The wallet stayed locked, so nothing was sent.")); + return std::nullopt; + } + if (!m_key_hash) m_key_hash = MasterKeyHash(m_service.walletModel().wallet()); + return unlock; +} + +void IdentityFlow::awaitLock(Funding pending, CTransactionRef tx, WalletModel::UnlockContext unlock) +{ + m_funding = std::move(pending); + m_tx = std::move(tx); + m_unlock.emplace(std::move(unlock)); + m_step = Step::WAITING_FOR_LOCK; + Q_EMIT changed(); + send(); +} + +void IdentityFlow::fund(const wallet::AssetLockFundingKey& key, CAmount amount, Funding pending) +{ + if (m_step != Step::IDLE || !m_service.client() || m_service.writing() || funding()) return; + auto unlock{beginFunding()}; + if (!unlock) return; + interfaces::Wallet& wallet{m_service.walletModel().wallet()}; + CAmount fee{0}; + const auto tx{wallet.createAssetLockTransaction(amount, key, wallet::CCoinControl{}, fee)}; + if (!tx) return finish(QString::fromStdString(util::ErrorString(tx).translated)); + if (!m_confirm(amount, fee)) return finish(); + + pending.txid = (*tx)->GetHash(); + pending.amount = amount; + // Before the asset lock is sent: the record is all that resumes it. + if (!SaveFunding(wallet, pending)) { + return finish(tr("The wallet could not save the funding payment, so nothing was sent.")); + } + if (const auto res{wallet.commitTransaction(*tx, {}, {})}; !res) { + // Abandoning releases its coins; one it can't abandon may still confirm. + if (wallet.abandonTransaction(pending.txid)) wallet.writePlatformData(pending.record, {}); + return finish(tr("The network did not accept the funding payment: %1") + .arg(QString::fromStdString(util::ErrorString(res).translated))); + } + awaitLock(std::move(pending), *tx, std::move(*unlock)); +} + +void IdentityFlow::resume() +{ + auto pending{funding()}; + if (m_step != Step::IDLE || !m_service.client() || m_service.writing() || !pending) return; + interfaces::Wallet& wallet{m_service.walletModel().wallet()}; + const auto [tx, status]{WalletTxOf(wallet, pending->txid)}; + if (!tx || status.is_abandoned || status.depth_in_main_chain < 0) { + // Never sent, or its coins went to another payment: Platform can't get it. + wallet.writePlatformData(pending->record, {}); + return finish(tr("The funding payment was not sent or was replaced, so it bought no credits. Its coins are " + "in your wallet.")); + } + auto unlock{beginFunding()}; + if (!unlock) return; + awaitLock(std::move(*pending), tx, std::move(*unlock)); +} + +void IdentityFlow::send() +{ + if (m_step != Step::WAITING_FOR_LOCK) return; + const auto proof{m_proof(m_tx)}; + platform::PlatformClient* client{m_service.client()}; + if (!proof || !client) return; + + interfaces::Wallet& wallet{m_service.walletModel().wallet()}; + std::optional> keys; + if (!m_funding->top_up) { + keys = RegistrationKeys(wallet); + if (!keys) return finish(tr("Dash Core could not derive your identity's keys from this wallet.")); + } + platform::Signing signing{wallet, IDENTITY_INDEX, PlatformUi::KeepUnlocked(std::move(*m_unlock))}; + m_unlock.reset(); + m_step = Step::SENDING; + if (m_funding->top_up) { + client->topUpIdentity(m_funding->identity, *proof, *m_funding->top_up, std::move(signing), + m_service.trackWrite(PlatformUi::Tracked( + m_call, [this, funding = *m_funding](platform::Result result) { + if (result.ok() && m_identity) m_identity->balance = result.value; + sent(funding, result.status); + }))); + } else { + client->registerIdentity(std::move(*keys), *proof, std::move(signing), + m_service.trackWrite(PlatformUi::Tracked( + m_call, [this, funding = *m_funding](platform::Result result) { + if (result.ok()) { + m_identity = std::move(result.value); + m_looked_up = true; + } + sent(funding, result.status); + }))); + } + Q_EMIT changed(); +} + +void IdentityFlow::sent(const Funding& funding, const platform::Status& status) +{ + interfaces::Wallet& wallet{m_service.walletModel().wallet()}; + if (status.kind == platform::StatusKind::OK) { + wallet.writePlatformData(funding.record, {}); + return finish(); + } + if (status.kind == platform::StatusKind::CONSENSUS && status.consensus_code == CODE_ASSET_LOCK_CONSUMED) { + // An earlier attempt was applied. The lookup finds the identity it + // created, or the balance a top-up raised. + if (funding.top_up) wallet.writePlatformData(funding.record, {}); + finish(); + m_looked_up = false; + return startLookUp(/*unlock=*/false); + } + LogPrintf("DashPay: %s failed: %s\n", funding.top_up ? "identity top-up" : "identity registration", status.message); + finish(PlatformUi::Describe(status)); +} diff --git a/src/qt/platform/identityflow.h b/src/qt/platform/identityflow.h new file mode 100644 index 000000000000..70f6c404f0f2 --- /dev/null +++ b/src/qt/platform/identityflow.h @@ -0,0 +1,142 @@ +// 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_QT_PLATFORM_IDENTITYFLOW_H +#define BITCOIN_QT_PLATFORM_IDENTITYFLOW_H + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +class PlatformService; + +/** + * Creates and tops up the wallet's DashPay identity (identity index 0), as + * the mobile wallets do: the wallet builds an asset lock paying a key derived + * from its seed, the user confirms its amount and exact fee before it is + * sent, and once the node has the payment's InstantSend lock or ChainLock one + * SDK call creates the identity or adds the credits. + * + * The asset lock's wallet record is written before it is sent and erased + * once Platform has consumed it, so funding interrupted by a restart, a + * dropped call or a refusal resumes from the record (Continue). An asset + * lock Platform reports as already consumed was consumed by an earlier + * attempt: the identity is then looked up by its MASTER key. + * + * The passphrase given to fund keeps the wallet unlocked until the client + * has signed: while the node waits for the payment's lock, which may take + * until its ChainLock, and until a closed gate ends the wait. + */ +class IdentityFlow : public QObject +{ + Q_OBJECT + +public: + //! Every identity is index 0 until the UI offers more than one. + static constexpr uint32_t IDENTITY_INDEX{0}; + //! What a new identity is funded with, as the mobile wallets: enough to + //! register a username. + static constexpr CAmount REGISTRATION_AMOUNT{3'000'000}; + //! The smallest top-up the mobile wallets offer. + static constexpr CAmount MIN_TOP_UP{1'000'000}; + //! The wallet records of asset locks Platform has not consumed yet. + static constexpr const char* FUNDING_PREFIX{"dashpay/funding/"}; + + //! Whether the user sends an asset lock buying `amount` duffs of credits + //! for a network fee of `fee`. + using Confirm = std::function; + //! The proof Platform takes for a committed asset lock; nullopt until + //! the node has a lock for it. + using Proof = std::function(const CTransactionRef& tx)>; + + //! An asset lock this wallet sent that Platform has not consumed. + struct Funding { + std::string record; + //! nullopt: it registers the identity. + std::optional top_up; + uint256 txid; + CAmount amount{0}; + //! The identity a top-up adds the credits to. + platform::Identifier identity{}; + }; + + enum class Step { + IDLE, + LOOKING_UP, + FUNDING, //!< building the asset lock, asking for the passphrase or the confirmation + WAITING_FOR_LOCK, //!< committed; the node has no lock for it yet + SENDING, + }; + + //! An empty proof uses the node: the payment's InstantSend lock, else its + //! ChainLock. + IdentityFlow(PlatformService& service, Confirm confirm, Proof proof = {}); + + Step step() const { return m_step; } + //! Whether the identity was looked up since the client started. + bool lookedUp() const { return m_looked_up; } + const std::optional& identity() const { return m_identity; } + //! The asset lock waiting for Platform, if any. + std::optional funding() const; + //! Why the last step failed; empty when it did not. + const QString& error() const { return m_error; } + + //! Look up the identity registered under the wallet's MASTER key, + //! asking for the passphrase if the key must be derived. + void lookUp(); + //! Fund and register the identity. + void create(); + //! Fund `amount` duffs of credits for the identity. + void topUp(CAmount amount); + //! Send funding() to Platform again. + void resume(); + +Q_SIGNALS: + void changed(); + +private: + void serviceChanged(); + void startLookUp(bool unlock); + void onLookedUp(platform::Result result); + void fund(const wallet::AssetLockFundingKey& key, CAmount amount, Funding pending); + //! Ask for the passphrase; nullopt (and IDLE again) if the wallet stays locked. + std::optional beginFunding(); + void awaitLock(Funding pending, CTransactionRef tx, WalletModel::UnlockContext unlock); + //! Send the funding to Platform once the node has a lock for it and a client. + void send(); + void sent(const Funding& funding, const platform::Status& status); + void finish(const QString& error = {}); + + PlatformService& m_service; + const Confirm m_confirm; + Proof m_proof; + Step m_step{Step::IDLE}; + bool m_looked_up{false}; + std::optional m_identity; + QString m_error; + //! Hash160 of the MASTER key, kept once derived: deriving needs an + //! unlocked wallet. + std::optional> m_key_hash; + //! The one call in flight, until it calls back or is dropped. + std::weak_ptr m_call; + //! The funding being waited for or sent, and the unlock it signs with. + std::optional m_funding; + CTransactionRef m_tx; + std::optional m_unlock; +}; + +#endif // BITCOIN_QT_PLATFORM_IDENTITYFLOW_H diff --git a/src/qt/platform/platformpage.cpp b/src/qt/platform/platformpage.cpp new file mode 100644 index 000000000000..19762ac64d9c --- /dev/null +++ b/src/qt/platform/platformpage.cpp @@ -0,0 +1,277 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +PlatformPage::PlatformPage(PlatformService::Sources sources, QWidget* parent) : + QWidget(parent), + m_sources(std::move(sources)) +{ + auto* layout{new QVBoxLayout(this)}; + auto* title{new QLabel(tr("DashPay"), this)}; + GUIUtil::setFont({title}, GUIUtil::FontWeight::Bold, 18); + m_username = new QLabel(this); + m_username->setObjectName("dashPayUsername"); + m_username->setTextFormat(Qt::PlainText); + m_username->setTextInteractionFlags(Qt::TextSelectableByMouse); + GUIUtil::setFont({m_username}, GUIUtil::FontWeight::Bold); + m_identity = new QLabel(this); + m_identity->setObjectName("dashPayIdentity"); + m_identity->setTextInteractionFlags(Qt::TextSelectableByMouse); + auto* notice{new QLabel(tr("DashPay connects to evonodes, which see your IP address unless you use a proxy."), this)}; + notice->setObjectName("dashPayNotice"); + notice->setWordWrap(true); + m_status = new QLabel(this); + m_status->setWordWrap(true); + m_identity_status = new QLabel(this); + m_identity_status->setObjectName("dashPayIdentityStatus"); + m_identity_status->setWordWrap(true); + + m_look_up = new QPushButton(tr("Look up identity"), this); + m_look_up->setObjectName("dashPayLookUp"); + m_create = new QPushButton(tr("Create identity"), this); + m_create->setObjectName("dashPayCreateIdentity"); + m_continue = new QPushButton(tr("Continue"), this); + m_continue->setObjectName("dashPayContinue"); + m_top_up_amount = new BitcoinAmountField(this); + m_top_up_amount->setObjectName("dashPayTopUpAmount"); + m_top_up_amount->SetMinValue(IdentityFlow::MIN_TOP_UP); + m_top_up_amount->setValue(IdentityFlow::MIN_TOP_UP); + m_top_up = new QPushButton(tr("Top up"), this); + m_top_up->setObjectName("dashPayTopUp"); + auto* actions{new QHBoxLayout}; + actions->addWidget(m_look_up); + actions->addWidget(m_create); + actions->addWidget(m_continue); + actions->addWidget(m_top_up_amount); + actions->addWidget(m_top_up); + actions->addStretch(); + + m_username_input = new QLineEdit(this); + m_username_input->setObjectName("dashPayUsernameInput"); + m_username_input->setPlaceholderText(tr("Username")); + m_register = new QPushButton(tr("Register username"), this); + m_register->setObjectName("dashPayRegisterUsername"); + m_username_status = new QLabel(this); + m_username_status->setObjectName("dashPayUsernameStatus"); + m_username_status->setWordWrap(true); + auto* username_row{new QHBoxLayout}; + username_row->addWidget(m_username_input); + username_row->addWidget(m_register); + username_row->addStretch(); + + auto* header{new QHBoxLayout}; + header->addWidget(m_username); + header->addWidget(m_identity); + header->addStretch(); + + m_start = new QPushButton(tr("Start DashPay"), this); + m_start->setObjectName("dashPayStart"); + m_stop = new QPushButton(tr("Turn off DashPay for this wallet"), this); + m_stop->setObjectName("dashPayStop"); + layout->addWidget(title); + layout->addLayout(header); + layout->addWidget(notice); + layout->addWidget(m_status); + layout->addWidget(m_identity_status); + layout->addLayout(actions); + layout->addWidget(m_username_status); + layout->addLayout(username_row); + layout->addWidget(m_start, 0, Qt::AlignLeft); + layout->addWidget(m_stop, 0, Qt::AlignLeft); + layout->addStretch(); + GUIUtil::updateFonts(); + + connect(m_start, &QPushButton::clicked, this, [this] { + if (!m_service->start()) { + QMessageBox::warning(this, tr("DashPay"), tr("The wallet could not save that DashPay is started.")); + } + }); + connect(m_stop, &QPushButton::clicked, this, [this] { + if (!m_service->stop()) { + QMessageBox::warning(this, tr("DashPay"), tr("The wallet could not save that DashPay is turned off.")); + } + }); + connect(m_look_up, &QPushButton::clicked, this, [this] { m_identity_flow->lookUp(); }); + connect(m_create, &QPushButton::clicked, this, [this] { m_identity_flow->create(); }); + connect(m_continue, &QPushButton::clicked, this, [this] { m_identity_flow->resume(); }); + const auto register_name = [this] { + m_username_flow->registerName(m_username_input->text().trimmed().toStdString()); + }; + connect(m_register, &QPushButton::clicked, this, register_name); + connect(m_username_input, &QLineEdit::returnPressed, this, register_name); + connect(m_top_up, &QPushButton::clicked, this, [this] { + bool valid{false}; + const CAmount amount{m_top_up_amount->value(&valid)}; + if (valid && amount >= IdentityFlow::MIN_TOP_UP) m_identity_flow->topUp(amount); + }); + refresh(); +} + +PlatformPage::~PlatformPage() +{ + // Before the child widgets go, which later layers connect to the service. + if (m_service) Q_EMIT serviceChanged(nullptr); +} + +void PlatformPage::setWalletModel(WalletModel* wallet_model) +{ + m_wallet_model = wallet_model; + if (m_wallet_model) m_top_up_amount->setDisplayUnit(m_wallet_model->getOptionsModel()->getDisplayUnit()); + resetService(); +} + +void PlatformPage::setClientModel(ClientModel* client_model) +{ + m_client_model = client_model; + resetService(); +} + +void PlatformPage::resetService() +{ + if (m_service) { + Q_EMIT serviceChanged(nullptr); + m_username_flow.reset(); + m_identity_flow.reset(); + m_service.reset(); + } + if (m_wallet_model && m_client_model) { + m_service = std::make_unique(*m_wallet_model, *m_client_model, m_sources); + connect(m_service.get(), &PlatformService::changed, this, &PlatformPage::refresh); + m_identity_flow = std::make_unique(*m_service, [this](CAmount amount, CAmount fee) { + return confirmFunding(amount, fee); + }); + connect(m_identity_flow.get(), &IdentityFlow::changed, this, &PlatformPage::refresh); + m_username_flow = std::make_unique(*m_service, *m_identity_flow); + connect(m_username_flow.get(), &UsernameFlow::changed, this, &PlatformPage::refresh); + Q_EMIT serviceChanged(m_service.get()); + } + refresh(); +} + +bool PlatformPage::confirmFunding(CAmount amount, CAmount fee) +{ + const BitcoinUnit unit{m_wallet_model->getOptionsModel()->getDisplayUnit()}; + return QMessageBox::question(this, tr("Confirm DashPay payment"), + tr("Send %1 to Dash Platform as identity credits?\n\nNetwork fee: %2\nTotal: %3") + .arg(BitcoinUnits::formatWithUnit(unit, amount), + BitcoinUnits::formatWithUnit(unit, fee), + BitcoinUnits::formatWithUnit(unit, amount + fee)), + QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) == QMessageBox::Yes; +} + +void PlatformPage::refresh() +{ + using Gate = PlatformService::Gate; + const Gate gate{m_service ? m_service->gate() : Gate::NO_CONSENT}; + QString status{m_service ? m_service->reason() : QString{}}; + if (gate == Gate::NONE) { + status = m_service->client() ? tr("DashPay is on for this wallet.") + : tr("DashPay is switching to the current evonodes…"); + } + m_status->setText(status); + m_status->setVisible(!status.isEmpty()); + m_start->setVisible(m_service && gate == Gate::NO_CONSENT); + m_stop->setVisible(m_service && m_service->consented()); + + const IdentityFlow* flow{m_identity_flow.get()}; + const bool running{flow && m_service->client()}; + const BitcoinUnit unit{m_wallet_model ? m_wallet_model->getOptionsModel()->getDisplayUnit() : BitcoinUnit::DASH}; + const platform::Identity* identity{flow && flow->identity() ? &*flow->identity() : nullptr}; + if (identity) { + m_identity->setText(tr("Identity %1 · Balance %2") + .arg(QString::fromStdString(EncodeBase58(identity->id)), + PlatformUi::FormatCredits(unit, identity->balance))); + } + m_identity->setVisible(identity != nullptr); + const UsernameFlow* names{m_username_flow.get()}; + const auto username{names && identity ? names->username() : std::nullopt}; + m_username->setText(QString::fromStdString(username.value_or(std::string{}))); + m_username->setVisible(username.has_value()); + + using Step = IdentityFlow::Step; + const Step step{flow ? flow->step() : Step::IDLE}; + const auto funding{running ? flow->funding() : std::nullopt}; + using NameStep = UsernameFlow::Step; + const NameStep name_step{names ? names->step() : NameStep::IDLE}; + const bool idle{running && step == Step::IDLE && name_step == NameStep::IDLE}; + //! Nothing in flight and no funding waiting for Platform. + const bool ready{idle && !funding}; + QString identity_status; + switch (step) { + case Step::IDLE: + if (!running) break; + if (!flow->error().isEmpty()) { + identity_status = flow->error(); + } else if (funding) { + identity_status = tr("A funding payment of %1 has not reached Dash Platform yet.") + .arg(BitcoinUnits::formatWithUnit(unit, funding->amount)); + } else if (!flow->lookedUp()) { + identity_status = tr("Unlock the wallet so DashPay can find your identity."); + } else if (!identity) { + identity_status = tr("This wallet has no DashPay identity yet. Creating one buys %1 of credits.") + .arg(BitcoinUnits::formatWithUnit(unit, IdentityFlow::REGISTRATION_AMOUNT)); + } + break; + case Step::LOOKING_UP: + identity_status = tr("Looking up your DashPay identity…"); + break; + case Step::FUNDING: + identity_status = tr("Preparing the funding payment…"); + break; + case Step::WAITING_FOR_LOCK: + identity_status = tr("Waiting for the network to lock the funding payment…"); + break; + case Step::SENDING: + identity_status = funding && funding->top_up ? tr("Adding the credits to your identity…") + : tr("Creating your DashPay identity…"); + break; + } + m_identity_status->setText(identity_status); + m_identity_status->setVisible(!identity_status.isEmpty()); + m_look_up->setVisible(ready && !flow->lookedUp()); + m_create->setVisible(ready && flow->lookedUp() && !identity); + m_continue->setVisible(idle && funding.has_value()); + m_top_up_amount->setVisible(ready && identity); + m_top_up->setVisible(ready && identity); + + QString username_status; + switch (name_step) { + case NameStep::IDLE: + if (running) username_status = names->error(); + break; + case NameStep::CHECKING: + username_status = tr("Checking that the username is free…"); + break; + case NameStep::PREPARING: + // Until the identity exists its own status says what runs. + if (identity) username_status = tr("Registering your username…"); + break; + case NameStep::REGISTERING: + username_status = tr("Registering your username…"); + break; + } + m_username_status->setText(username_status); + m_username_status->setVisible(!username_status.isEmpty()); + const bool can_register{ready && flow->lookedUp() && (!identity || (names->namesRead() && !username))}; + m_username_input->setVisible(can_register); + m_register->setVisible(can_register); +} diff --git a/src/qt/platform/platformpage.h b/src/qt/platform/platformpage.h new file mode 100644 index 000000000000..738aba4584e8 --- /dev/null +++ b/src/qt/platform/platformpage.h @@ -0,0 +1,81 @@ +// 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_QT_PLATFORM_PLATFORMPAGE_H +#define BITCOIN_QT_PLATFORM_PLATFORMPAGE_H + +#include +#include + +#include + +#include + +class BitcoinAmountField; +class IdentityFlow; +class UsernameFlow; + +QT_BEGIN_NAMESPACE +class QLabel; +class QLineEdit; +class QPushButton; +QT_END_NAMESPACE + +/** The DashPay tab of one wallet. Only built with --enable-platform-gui. + * + * It owns the wallet's PlatformService while both models are set, offers + * the first DashPay click with a one-line privacy notice, and says why + * DashPay can't run when a gate is closed. Once DashPay runs it shows the + * wallet's identity, its username and its balance; creates, continues or + * tops up the identity; and registers its username. */ +class PlatformPage : public QWidget +{ + Q_OBJECT + +public: + explicit PlatformPage(PlatformService::Sources sources = {}, QWidget* parent = nullptr); + ~PlatformPage() override; + + void setWalletModel(WalletModel* wallet_model); + void setClientModel(ClientModel* client_model); + PlatformService* service() const { return m_service.get(); } + IdentityFlow* identityFlow() const { return m_identity_flow.get(); } + UsernameFlow* usernameFlow() const { return m_username_flow.get(); } + +Q_SIGNALS: + //! A service was created, or is about to be destroyed (nullptr) while + //! the models it holds and the page's widgets still exist. + void serviceChanged(PlatformService* service); + +private: + void resetService(); + void refresh(); + bool confirmFunding(CAmount amount, CAmount fee); + + const PlatformService::Sources m_sources; + WalletModel* m_wallet_model{nullptr}; + ClientModel* m_client_model{nullptr}; + std::unique_ptr m_service; + //! Destroyed before the service it calls through. + std::unique_ptr m_identity_flow; + //! Destroyed before the identity flow it builds on. + std::unique_ptr m_username_flow; + + QLabel* m_username{nullptr}; + QLabel* m_identity{nullptr}; + QLabel* m_status{nullptr}; + QLabel* m_identity_status{nullptr}; + QPushButton* m_look_up{nullptr}; + QPushButton* m_create{nullptr}; + QPushButton* m_continue{nullptr}; + BitcoinAmountField* m_top_up_amount{nullptr}; + QPushButton* m_top_up{nullptr}; + QLineEdit* m_username_input{nullptr}; + QPushButton* m_register{nullptr}; + QLabel* m_username_status{nullptr}; + QPushButton* m_start{nullptr}; + QPushButton* m_stop{nullptr}; +}; + +#endif // BITCOIN_QT_PLATFORM_PLATFORMPAGE_H diff --git a/src/qt/platform/platformservice.cpp b/src/qt/platform/platformservice.cpp new file mode 100644 index 000000000000..1eb6d18e1e04 --- /dev/null +++ b/src/qt/platform/platformservice.cpp @@ -0,0 +1,234 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { +std::optional ReadProxy(interfaces::Node& node, Network net) +{ + Proxy proxy; + if (!node.getProxy(net, proxy)) return std::nullopt; + if (proxy.m_is_unix_socket) { + return platform::ProxyConfig{proxy.m_unix_socket_path.substr(ADDR_PREFIX_UNIX.size()), + proxy.m_randomize_credentials}; + } + return platform::ProxyConfig{proxy.proxy, proxy.m_randomize_credentials}; +} +} // namespace + +PlatformNetworkSettings PlatformNetworkSettings::Read(interfaces::Node& node) +{ + PlatformNetworkSettings settings; + settings.ipv4 = node.isReachable(NET_IPV4); + settings.ipv6 = node.isReachable(NET_IPV6); + settings.onion = node.isReachable(NET_ONION); + // -proxy sets IPv4 and IPv6 together. + settings.clearnet_proxy = ReadProxy(node, NET_IPV4); + settings.onion_proxy = ReadProxy(node, NET_ONION); + return settings; +} + +bool PlatformRoute::allows(const CService& endpoint) const +{ + if (endpoint.IsIPv4()) return ipv4; + if (endpoint.IsIPv6()) return ipv6; + if (endpoint.IsTor()) return onion; + return false; +} + +std::optional PlatformRoute::Choose(const PlatformNetworkSettings& settings) +{ + if (settings.ipv4 || settings.ipv6) { + return PlatformRoute{settings.clearnet_proxy, settings.ipv4, settings.ipv6, + settings.onion && settings.onion_proxy && settings.onion_proxy == settings.clearnet_proxy}; + } + if (settings.onion && settings.onion_proxy) { + return PlatformRoute{settings.onion_proxy, /*ipv4=*/false, /*ipv6=*/false, /*onion=*/true}; + } + return std::nullopt; +} + +PlatformService::PlatformService(WalletModel& wallet_model, ClientModel& client_model, Sources sources) : + m_wallet_model(wallet_model), + m_client_model(client_model), + m_sources(std::move(sources)) +{ + interfaces::Node& node{m_client_model.node()}; + if (!m_sources.make_client) { + m_sources.make_client = [&node](const platform::ClientConfig& config, platform::Executor executor) { + return platform::MakeSdkPlatformClient(config, node.llmq(), std::move(executor)); + }; + } + if (!m_sources.evonodes) { + m_sources.evonodes = [feed = m_client_model.feedMasternode()] { + std::vector endpoints; + if (const auto data{feed->data()}) { + for (const auto& entry : data->m_entries) { + if (entry->isBanned()) continue; + for (const CService& endpoint : entry->platformHTTPSAddrsRaw()) { + endpoints.push_back(endpoint); + } + } + } + return endpoints; + }; + } + if (!m_sources.network_settings) { + m_sources.network_settings = [&node] { return PlatformNetworkSettings::Read(node); }; + } + + interfaces::Wallet& wallet{m_wallet_model.wallet()}; + m_eligible = wallet.platformKeySourceStatus() == wallet::PlatformKeyStatus::SUCCESS; + m_consent = wallet.getPlatformData(CONSENT_RECORD).contains(CONSENT_RECORD); + + // Queued: the wallet notifies with cs_wallet held, and update() may + // destroy a client, which must not happen under it. + connect( + &m_wallet_model, &WalletModel::canGetAddressesChanged, this, + [this] { + m_eligible = m_wallet_model.wallet().platformKeySourceStatus() == wallet::PlatformKeyStatus::SUCCESS; + update(); + }, + Qt::QueuedConnection); + connect(&m_client_model, &ClientModel::networkActiveChanged, this, &PlatformService::update); + // The masternode feed reads the list again on ClientModel::masternodeListChanged + // and on every block, off the GUI thread. + connect(m_client_model.feedMasternode(), &FeedBase::dataReady, this, &PlatformService::update); + update(); +} + +QString PlatformService::reason() const +{ + switch (m_gate) { + case Gate::NONE: + case Gate::NO_CONSENT: + return {}; + case Gate::WALLET: + return tr("This wallet can't use DashPay: DashPay needs a descriptor wallet that holds its own keys, from " + "one recovery phrase."); + case Gate::OUTBOUND_DISABLED: + return tr("DashPay is off because Dash Core makes no automatic outbound connections (-connect or " + "-maxconnections=0). Remove that setting to use DashPay."); + case Gate::BROADCAST_DISABLED: + return tr("DashPay is off because this wallet does not broadcast its transactions (-walletbroadcast=0). " + "Remove that setting to use DashPay."); + case Gate::NO_ROUTE: + return tr("Your network settings leave DashPay no network to use. DashPay needs IPv4 or IPv6, directly or " + "through your proxy, or Tor with an onion proxy."); + case Gate::NETWORK_OFF: + return tr("DashPay is paused while network activity is off."); + case Gate::SYNCING: + return tr("DashPay starts once Dash Core has finished syncing."); + case Gate::NO_EVONODES: + return tr("DashPay is waiting for an evonode it can reach with your network settings."); + case Gate::CLIENT_FAILED: + return tr("DashPay couldn't start with your network settings. The debug log has details."); + } + return {}; +} + +bool PlatformService::start() +{ + if (!m_wallet_model.wallet().writePlatformData(CONSENT_RECORD, {1})) return false; + m_consent = true; + refresh(); + Q_EMIT changed(); + return true; +} + +bool PlatformService::stop() +{ + if (!m_wallet_model.wallet().writePlatformData(CONSENT_RECORD, {})) return false; + m_consent = false; + refresh(); + Q_EMIT changed(); + return true; +} + +PlatformService::Gate PlatformService::evaluate(platform::ClientConfig& config) const +{ + if (!m_eligible) return Gate::WALLET; + // As init.cpp's parameter interaction: no automatic outbound connections. + if (gArgs.IsArgSet("-connect") || gArgs.GetIntArg("-maxconnections", DEFAULT_MAX_PEER_CONNECTIONS) <= 0) { + return Gate::OUTBOUND_DISABLED; + } + if (!gArgs.GetBoolArg("-walletbroadcast", wallet::DEFAULT_WALLETBROADCAST)) return Gate::BROADCAST_DISABLED; + const auto route{PlatformRoute::Choose(m_sources.network_settings())}; + if (!route) return Gate::NO_ROUTE; + if (!m_consent) return Gate::NO_CONSENT; + interfaces::Node& node{m_client_model.node()}; + if (!node.getNetworkActive()) return Gate::NETWORK_OFF; + if (node.isInitialBlockDownload()) return Gate::SYNCING; + + config.proxy = route->proxy; + for (const CService& endpoint : m_sources.evonodes()) { + if (route->allows(endpoint)) config.endpoints.push_back(endpoint); + } + std::sort(config.endpoints.begin(), config.endpoints.end()); + config.endpoints.erase(std::unique(config.endpoints.begin(), config.endpoints.end()), config.endpoints.end()); + if (config.endpoints.empty()) return Gate::NO_EVONODES; + if (config == m_refused) return Gate::CLIENT_FAILED; + return Gate::NONE; +} + +void PlatformService::update() +{ + if (refresh()) Q_EMIT changed(); +} + +bool PlatformService::refresh() +{ + platform::ClientConfig config; + const Gate gate{evaluate(config)}; + if (gate == Gate::NONE && m_client && config == m_config) { + // Back to the set the client has: whatever paused it is moot. + return std::exchange(m_paused, false); + } + if (m_writes > 0 && (gate == Gate::NONE || gate == Gate::NO_EVONODES || gate == Gate::CLIENT_FAILED)) { + // A changed evonode set: no new call starts, and the write in flight + // finishes on the old client (writeFinished() comes back here). + return !std::exchange(m_paused, true); + } + const bool had_client{m_client != nullptr}; + const Gate old_gate{std::exchange(m_gate, gate)}; + m_client.reset(); + m_writes = 0; + m_paused = false; + if (gate == Gate::NONE) { + m_client = m_sources.make_client(config, [this](std::function task) { + QMetaObject::invokeMethod(this, std::move(task), Qt::QueuedConnection); + }); + if (m_client) { + m_config = std::move(config); + } else { + m_refused = std::move(config); + m_gate = Gate::CLIENT_FAILED; + } + } + return had_client || m_client || m_gate != old_gate; +} + +void PlatformService::writeFinished() +{ + Assume(m_writes > 0); + // Queued: the client delivering this callback is the one a rebuild destroys. + if (--m_writes == 0 && m_paused) QMetaObject::invokeMethod(this, &PlatformService::update, Qt::QueuedConnection); +} diff --git a/src/qt/platform/platformservice.h b/src/qt/platform/platformservice.h new file mode 100644 index 000000000000..8c42e3981a1c --- /dev/null +++ b/src/qt/platform/platformservice.h @@ -0,0 +1,163 @@ +// 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_QT_PLATFORM_PLATFORMSERVICE_H +#define BITCOIN_QT_PLATFORM_PLATFORMSERVICE_H + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +class ClientModel; +class WalletModel; + +namespace interfaces { +class Node; +} // namespace interfaces + +//! The node's settings DashPay chooses its route from: the networks its +//! outbound connections may use and the proxies it uses for them. +struct PlatformNetworkSettings { + bool ipv4{true}; + bool ipv6{true}; + bool onion{false}; + std::optional clearnet_proxy; //!< -proxy (IPv4 and IPv6) + std::optional onion_proxy; //!< -onion, -proxy or the Tor controller's + + static PlatformNetworkSettings Read(interfaces::Node& node); +}; + +//! How DashPay connects to evonodes, as Core connects to its peers: through +//! one proxy or directly, to the networks that proxy reaches. +struct PlatformRoute { + std::optional proxy; //!< nullopt: connect directly + bool ipv4{true}; + bool ipv6{true}; + bool onion{false}; + + //! Whether an evonode at this address is reached on this route. I2P + //! (not SOCKS5) and CJDNS evonodes never are. + bool allows(const CService& endpoint) const; + //! With IPv4 or IPv6 reachable, -proxy (or none) to those networks, and + //! to onion evonodes only when the onion proxy is the same one; with + //! only onion reachable, the onion proxy to onion evonodes. nullopt: no + //! network DashPay can use under these settings. + static std::optional Choose(const PlatformNetworkSettings& settings); +}; + +/** + * One wallet's connection to Dash Platform. It holds the PlatformClient, + * which exists only while the wallet has started DashPay and every gate is + * open; nothing contacts Platform for the wallet before that. + * + * The client is built for the valid evonodes in Core's masternode list that + * the route reaches. When that set or the route changes, new calls pause and + * the client is rebuilt once no write is in flight. Network activity turned + * off destroys the client, calls in flight included. + */ +class PlatformService : public QObject +{ + Q_OBJECT + +public: + //! Why DashPay can't run for the wallet now, in the order checked. + enum class Gate { + NONE, + WALLET, //!< no single HD seed to derive Platform keys from + OUTBOUND_DISABLED, //!< -connect or -maxconnections=0 + BROADCAST_DISABLED, //!< -walletbroadcast=0 + NO_ROUTE, //!< the network settings leave no network to use + NO_CONSENT, //!< DashPay was not started for this wallet + NETWORK_OFF, + SYNCING, + NO_EVONODES, //!< no valid evonode on the route + CLIENT_FAILED, + }; + + //! What the service reads from the node. An empty member uses the node. + struct Sources { + //! The SDK client, verifying proofs against the node's quorums. + std::function(const platform::ClientConfig&, platform::Executor)> make_client; + //! The Platform HTTPS endpoints of the valid evonodes in Core's + //! masternode list. + std::function()> evonodes; + //! PlatformNetworkSettings::Read. + std::function network_settings; + }; + + //! The wallet record the first DashPay click writes. + static constexpr const char* CONSENT_RECORD{"dashpay/consent"}; + + PlatformService(WalletModel& wallet_model, ClientModel& client_model, Sources sources = {}); + + WalletModel& walletModel() const { return m_wallet_model; } + ClientModel& clientModel() const { return m_client_model; } + Gate gate() const { return m_gate; } + //! Why DashPay can't run, naming the setting that disables it; empty for + //! NONE and NO_CONSENT. + QString reason() const; + bool consented() const { return m_consent; } + //! The first DashPay click: record the wallet's consent and start. + bool start(); + //! Erase the consent and stop contacting Platform for the wallet. + bool stop(); + + //! The client to start a call on; nullptr while a gate is closed or a + //! rebuild waits for a write. A call whose client is destroyed never + //! calls back. + platform::PlatformClient* client() const { return m_paused ? nullptr : m_client.get(); } + //! Whether a write is in flight. The wallet's flows start one only when + //! none is, which serializes the wallet's unlocks and its identity's + //! state transitions. + bool writing() const { return m_writes > 0; } + + //! Wrap the callback of a write, in the call that starts it: a rebuild + //! waits until it has run. + template + platform::PlatformClient::Callback trackWrite(platform::PlatformClient::Callback cb) + { + ++m_writes; + return [this, cb = std::move(cb)](platform::Result result) { + // First: the callback may destroy the client, which resets the count. + writeFinished(); + cb(std::move(result)); + }; + } + +Q_SIGNALS: + //! gate(), consented() or client() changed. + void changed(); + +private: + void update(); + //! Re-read the gates and the evonode set; create, rebuild, pause or + //! destroy the client to match. Returns whether gate() or client() changed. + bool refresh(); + Gate evaluate(platform::ClientConfig& config) const; + void writeFinished(); + + WalletModel& m_wallet_model; + ClientModel& m_client_model; + Sources m_sources; + bool m_eligible{false}; + bool m_consent{false}; + Gate m_gate{Gate::NO_CONSENT}; + std::unique_ptr m_client; + platform::ClientConfig m_config; + //! A configuration the SDK refused, not tried again. + std::optional m_refused; + int m_writes{0}; + //! The evonode set changed while a write was in flight. + bool m_paused{false}; +}; + +#endif // BITCOIN_QT_PLATFORM_PLATFORMSERVICE_H diff --git a/src/qt/platform/platformui.cpp b/src/qt/platform/platformui.cpp new file mode 100644 index 000000000000..41b44c1c47cc --- /dev/null +++ b/src/qt/platform/platformui.cpp @@ -0,0 +1,63 @@ +// 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 + +namespace PlatformUi { +namespace { +//! Credits in one duff, as Dash Platform converts an asset lock. +constexpr uint64_t CREDITS_PER_DUFF{1000}; + +class WalletUnlock final : public platform::UnlockScope +{ +public: + explicit WalletUnlock(WalletModel::UnlockContext unlock) : + m_unlock(std::move(unlock)) + { + } + +private: + WalletModel::UnlockContext m_unlock; +}; +} // namespace + +QString Describe(const platform::Status& status) +{ + using platform::StatusKind; + switch (status.kind) { + case StatusKind::OK: + case StatusKind::NOT_FOUND: + return {}; + case StatusKind::UNAVAILABLE: + return QCoreApplication::translate("PlatformUi", + "Dash Platform can't be reached right now. Try again in a moment."); + case StatusKind::REJECTED: + return QCoreApplication::translate("PlatformUi", "Dash Core could not verify Dash Platform's answer, so it was " + "not used. Try again in a minute."); + case StatusKind::CONSENSUS: + return QCoreApplication::translate("PlatformUi", "Dash Platform did not accept this (consensus error %1).") + .arg(status.consensus_code); + case StatusKind::INTERNAL: + return QCoreApplication::translate("PlatformUi", "Something went wrong inside Dash Core: %1") + .arg(QString::fromStdString(status.message)); + } + return {}; +} + +QString FormatCredits(BitcoinUnit unit, uint64_t credits) +{ + return BitcoinUnits::formatWithUnit(unit, static_cast(credits / CREDITS_PER_DUFF)); +} + +std::unique_ptr KeepUnlocked(WalletModel::UnlockContext unlock) +{ + return std::make_unique(std::move(unlock)); +} +} // namespace PlatformUi diff --git a/src/qt/platform/platformui.h b/src/qt/platform/platformui.h new file mode 100644 index 000000000000..98f415f7f74b --- /dev/null +++ b/src/qt/platform/platformui.h @@ -0,0 +1,38 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_PLATFORMUI_H +#define BITCOIN_QT_PLATFORM_PLATFORMUI_H + +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace PlatformUi { +//! A failed Platform call as the user reads it; empty for OK and NOT_FOUND, +//! which are answers. +QString Describe(const platform::Status& status); +//! A Platform balance in credits as a Dash amount, rounded down to a duff. +QString FormatCredits(BitcoinUnit unit, uint64_t credits); +//! Keeps the wallet unlocked until the client has signed the write. +std::unique_ptr KeepUnlocked(WalletModel::UnlockContext unlock); +//! The callback of a call, owning a token `call` points to: it expires +//! once the client drops the call, which then never calls back. +template +platform::PlatformClient::Callback Tracked(std::weak_ptr& call, std::function)> fn) +{ + auto owner{std::make_shared(0)}; + call = owner; + return [owner, fn = std::move(fn)](platform::Result result) { fn(std::move(result)); }; +} +} // namespace PlatformUi + +#endif // BITCOIN_QT_PLATFORM_PLATFORMUI_H diff --git a/src/qt/platform/usernameflow.cpp b/src/qt/platform/usernameflow.cpp new file mode 100644 index 000000000000..c7ef962b0767 --- /dev/null +++ b/src/qt/platform/usernameflow.cpp @@ -0,0 +1,178 @@ +// 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 { +using platform::IdentityPublicKey; + +//! The header shows the first of the identity's names. +constexpr uint32_t NAMES_PAGE_LIMIT{10}; + +//! The key a DPNS document is signed with: the identity's enabled HIGH +//! authentication key, whatever its id. +std::optional SigningKey(const platform::Identity& identity) +{ + for (const IdentityPublicKey& key : identity.public_keys) { + if (key.purpose == IdentityPublicKey::Purpose::AUTHENTICATION && + key.security_level == IdentityPublicKey::SecurityLevel::HIGH && + key.type == IdentityPublicKey::Type::ECDSA_SECP256K1 && key.disabled_at == 0) { + return key.id; + } + } + return std::nullopt; +} +} // namespace + +UsernameFlow::UsernameFlow(PlatformService& service, IdentityFlow& identity_flow) : + m_service(service), + m_identity_flow(identity_flow) +{ + connect(&m_service, &PlatformService::changed, this, &UsernameFlow::update); + connect(&m_identity_flow, &IdentityFlow::changed, this, &UsernameFlow::update); + update(); +} + +void UsernameFlow::update() +{ + // The identity flow forgets the identity when a gate closes, which is + // also what drops a write: once the identity is found again, its names + // say whether a dropped registration landed. + if (!m_identity_flow.identity()) { + m_names_read = false; + m_username.reset(); + } + // A call the client dropped never calls back. + if (m_step == Step::CHECKING && m_call.expired()) { + resolve(); + } else if (m_step == Step::REGISTERING && m_call.expired()) { + finish(); + } else if (m_step == Step::PREPARING && m_identity_flow.step() == IdentityFlow::Step::IDLE) { + if (m_identity_flow.identity()) { + send(*m_identity_flow.identity()); + } else { + // Creating it failed or was declined; the identity flow says why. + finish(); + } + } + readNames(); + Q_EMIT changed(); +} + +void UsernameFlow::finish(const QString& error) +{ + m_step = Step::IDLE; + m_unlock.reset(); + m_error = error; + readNames(); + Q_EMIT changed(); +} + +void UsernameFlow::readNames() +{ + const auto& identity{m_identity_flow.identity()}; + platform::PlatformClient* client{m_service.client()}; + if (m_step != Step::IDLE || m_names_read || !identity || !m_names_call.expired() || !client) return; + client->namesOfIdentity(identity->id, NAMES_PAGE_LIMIT, /*max_pages=*/1, + PlatformUi::Tracked(m_names_call, [this](auto result) { + if (!result.ok()) { + LogPrintf("DashPay: reading the identity's usernames failed: %s\n", + result.status.message); + } + m_names_read = true; + m_username.reset(); + if (result.ok() && !result.value.names.empty()) { + m_username = result.value.names.front().label; + } + Q_EMIT changed(); + })); +} + +void UsernameFlow::registerName(const std::string& label) +{ + if (m_step != Step::IDLE || !m_service.client()) return; + if (!platform::IsValidUsername(label)) { + return finish(tr("A username has 3 to 63 letters, digits and hyphens, starts and ends with a letter or " + "digit, and has no two hyphens in a row.")); + } + // Contested names need a masternode vote; registering them comes later. + if (platform::IsContestedUsername(label)) { + return finish(tr("This is a premium username, which a later version of Dash Core will register. Add a digit " + "from 2 to 9, or use 20 characters or more.")); + } + m_label = label; + m_step = Step::CHECKING; + m_error.clear(); + resolve(); + Q_EMIT changed(); +} + +void UsernameFlow::resolve() +{ + platform::PlatformClient* client{m_service.client()}; + if (!client) return finish(); + client->resolveName(m_label, PlatformUi::Tracked(m_call, [this](auto result) { + onResolved(std::move(result)); + })); +} + +void UsernameFlow::onResolved(platform::Result result) +{ + const auto& identity{m_identity_flow.identity()}; + if (result.ok()) { + if (identity && result.value == identity->id) { + m_username = m_label; + return finish(); + } + return finish(tr("“%1” is taken.").arg(QString::fromStdString(m_label))); + } + if (result.status.kind != platform::StatusKind::NOT_FOUND) return finish(PlatformUi::Describe(result.status)); + + m_unlock.emplace(m_service.walletModel().requestUnlock()); + if (!m_unlock->isValid()) return finish(tr("The wallet stayed locked, so no username was registered.")); + // Only alone: the unlock of another write in flight would relock the + // wallet under this one. The passphrase dialog may have started one. + if (m_identity_flow.step() != IdentityFlow::Step::IDLE || m_service.writing()) { + return finish(tr("Another DashPay payment is on its way. Try again once it is done.")); + } + m_step = Step::PREPARING; + if (!m_identity_flow.identity()) m_identity_flow.create(); + update(); +} + +void UsernameFlow::send(const platform::Identity& identity) +{ + platform::PlatformClient* client{m_service.client()}; + // Paused for a rebuild: changed() comes back here. + if (!client) return; + const auto key{SigningKey(identity)}; + if (!key) return finish(tr("Your identity has no key DashPay can register a username with.")); + platform::Signing signing{m_service.walletModel().wallet(), IdentityFlow::IDENTITY_INDEX, + PlatformUi::KeepUnlocked(std::move(*m_unlock))}; + m_unlock.reset(); + m_step = Step::REGISTERING; + client->registerName(m_label, identity.id, *key, std::move(signing), + m_service.trackWrite(PlatformUi::Tracked( + m_call, [this](auto result) { onRegistered(std::move(result)); }))); +} + +void UsernameFlow::onRegistered(platform::Result result) +{ + if (!result.ok()) { + LogPrintf("DashPay: username registration failed: %s\n", result.status.message); + // It may have landed all the same: the names, read again, say. + m_names_read = false; + return finish(PlatformUi::Describe(result.status)); + } + m_names_read = true; + m_username = m_label; + finish(); +} diff --git a/src/qt/platform/usernameflow.h b/src/qt/platform/usernameflow.h new file mode 100644 index 000000000000..d49f13133616 --- /dev/null +++ b/src/qt/platform/usernameflow.h @@ -0,0 +1,85 @@ +// 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_QT_PLATFORM_USERNAMEFLOW_H +#define BITCOIN_QT_PLATFORM_USERNAMEFLOW_H + +#include +#include + +#include +#include + +#include +#include +#include + +class IdentityFlow; +class PlatformService; + +/** + * Registers the DashPay username of the wallet's identity, as the mobile + * wallets do: a label DPNS accepts and that resolves to nobody is registered + * with one SDK call, signed with the identity's HIGH authentication key. A + * wallet without an identity creates it first through IdentityFlow, under + * the same unlock. + * + * Nothing is persisted: the identity's names, read from Platform whenever + * the identity is found, say whether a registration whose answer was lost + * landed. + */ +class UsernameFlow : public QObject +{ + Q_OBJECT + +public: + enum class Step { + IDLE, + CHECKING, //!< resolving the label + PREPARING, //!< unlocked; waiting for the identity being created, or for a client + REGISTERING, + }; + + UsernameFlow(PlatformService& service, IdentityFlow& identity_flow); + + Step step() const { return m_step; } + //! Whether the identity's names were read since it was found. + bool namesRead() const { return m_names_read; } + //! The identity's username; nullopt when it has none or they weren't read. + const std::optional& username() const { return m_username; } + //! Why the last registration failed; empty when it did not. + const QString& error() const { return m_error; } + + //! Register `label` for the wallet's identity, creating the identity + //! first if the wallet has none. + void registerName(const std::string& label); + +Q_SIGNALS: + void changed(); + +private: + void update(); + void readNames(); + void onResolved(platform::Result result); + void send(const platform::Identity& identity); + void onRegistered(platform::Result result); + void resolve(); + void finish(const QString& error = {}); + + PlatformService& m_service; + IdentityFlow& m_identity_flow; + Step m_step{Step::IDLE}; + std::string m_label; + //! Taken once the label is free, and held until the name is sent: a new + //! identity is funded and the name signed under it. + std::optional m_unlock; + //! The step's call and the names read, until they call back or are dropped. + std::weak_ptr m_call; + std::weak_ptr m_names_call; + bool m_names_read{false}; + std::optional m_username; + QString m_error; +}; + +#endif // BITCOIN_QT_PLATFORM_USERNAMEFLOW_H diff --git a/src/qt/test/platformtests.cpp b/src/qt/test/platformtests.cpp new file mode 100644 index 000000000000..85db5b13f7fe --- /dev/null +++ b/src/qt/test/platformtests.cpp @@ -0,0 +1,1107 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include