diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index e79c5fa4248e..4d941b9fc666 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -15,6 +15,11 @@ on: description: "Short hash of the CI base image manifest for cache busting" required: true type: string + rust-vendor-artifact: + description: "Artifact holding freshly generated Rust vendor archives" + required: false + type: string + default: "" runs-on: description: "Runner label to use (e.g., ubuntu-24.04 or ubuntu-24.04-arm)" required: true @@ -144,6 +149,28 @@ jobs: key: depends-sources-${{ hashFiles('depends/packages/*') }} restore-keys: depends-sources- + - name: Restore Rust vendor sources + id: rust-vendor-cache + if: inputs.build-target == 'linux64_platform_gui' + uses: actions/cache/restore@v5 + with: + path: depends/sources/platform-cxx-*-vendored.tar.gz + key: depends-rust-vendor-sources-${{ hashFiles('depends/Makefile', 'depends/funcs.mk', 'depends/packages/native_rust.mk', 'depends/packages/platform_cxx.mk', 'depends/patches/platform_cxx/cargo-config.toml') }} + + - name: Download Rust vendor sources + if: inputs.build-target == 'linux64_platform_gui' && steps.rust-vendor-cache.outputs.cache-hit != 'true' && inputs.rust-vendor-artifact != '' + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.rust-vendor-artifact }} + path: depends/sources + + - name: Check Rust vendor sources are present + if: inputs.build-target == 'linux64_platform_gui' && steps.rust-vendor-cache.outputs.cache-hit != 'true' && inputs.rust-vendor-artifact == '' + run: | + echo "::error::Rust vendor source cache missed and no same-run artifact was provided" + exit 1 + shell: bash + - name: Restore SDKs cache id: sdk-cache uses: actions/cache/restore@v5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce8191dc99ea..74de8337be81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,6 +165,18 @@ jobs: base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + depends-linux64_platform_gui: + name: x86_64-pc-linux-gnu_platform_gui + uses: ./.github/workflows/build-depends.yml + needs: [check-skip, container, cache-sources] + if: ${{ vars.SKIP_LINUX64_PLATFORM_GUI == '' }} + with: + build-target: linux64_platform_gui + container-path: ${{ needs.container.outputs.path }} + base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} + rust-vendor-artifact: ${{ needs.cache-sources.outputs.rust-vendor-artifact }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + depends-mac: name: x86_64-apple-darwin uses: ./.github/workflows/build-depends.yml @@ -277,6 +289,20 @@ jobs: depends-artifact: ${{ needs.depends-linux64_nowallet.outputs.built-artifact }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + src-linux64_platform_gui: + name: linux64_platform_gui-build + uses: ./.github/workflows/build-src.yml + needs: [check-skip, container, depends-linux64_platform_gui] + if: ${{ vars.SKIP_LINUX64_PLATFORM_GUI == '' }} + with: + build-target: linux64_platform_gui + container-path: ${{ needs.container.outputs.path }} + depends-key: ${{ needs.depends-linux64_platform_gui.outputs.key }} + depends-host: ${{ needs.depends-linux64_platform_gui.outputs.host }} + depends-dep-opts: ${{ needs.depends-linux64_platform_gui.outputs.dep-opts }} + depends-artifact: ${{ needs.depends-linux64_platform_gui.outputs.built-artifact }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + src-linux64_sqlite: name: linux64_sqlite-build uses: ./.github/workflows/build-src.yml @@ -372,6 +398,17 @@ jobs: container-path: ${{ needs.container-slim.outputs.path }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + test-linux64_platform_gui: + name: linux64_platform_gui-test + uses: ./.github/workflows/test-src.yml + needs: [check-skip, container-slim, src-linux64_platform_gui, lint] + if: ${{ vars.SKIP_LINUX64_PLATFORM_GUI == '' }} + with: + bundle-key: ${{ needs.src-linux64_platform_gui.outputs.key }} + build-target: linux64_platform_gui + container-path: ${{ needs.container-slim.outputs.path }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + test-linux64_sqlite: name: linux64_sqlite-test uses: ./.github/workflows/test-src.yml diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index 122851d8cd72..5e163b94c776 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -8,6 +8,10 @@ on: required: false type: string default: ubuntu-24.04-arm + outputs: + rust-vendor-artifact: + description: "Artifact holding freshly generated Rust vendor archives" + value: ${{ jobs.cache-sources.outputs.rust-vendor-artifact }} schedule: # Run daily at 6 AM UTC on the default branch to keep cache warm - cron: '0 6 * * *' @@ -18,6 +22,8 @@ jobs: # Intentionally keep scheduled cache warming on GitHub-hosted ARM runners. # Blacksmith caches are expected to persist long enough without a warmup cron. runs-on: ${{ inputs.runs-on || 'ubuntu-24.04-arm' }} + outputs: + rust-vendor-artifact: ${{ steps.vendor-artifact.outputs.name }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -35,6 +41,36 @@ jobs: restore-keys: depends-sources- lookup-only: true + - name: Cache Rust vendor sources + id: rust-vendor-cache + uses: actions/cache@v5 + with: + path: depends/sources/platform-cxx-*-vendored.tar.gz + key: depends-rust-vendor-sources-${{ hashFiles('depends/Makefile', 'depends/funcs.mk', 'depends/packages/native_rust.mk', 'depends/packages/platform_cxx.mk', 'depends/patches/platform_cxx/cargo-config.toml') }} + - name: Download sources - if: steps.cache-check.outputs.cache-hit != 'true' - run: make -C depends download + if: | + steps.cache-check.outputs.cache-hit != 'true' || + steps.rust-vendor-cache.outputs.cache-hit != 'true' + run: | + make -C depends PLATFORM_GUI=1 download + make -C depends PLATFORM_GUI=1 vendor-platform_cxx-crates + # The cache producer normally runs on ARM, while the platform_gui + # consumer runs on x86_64. Native Rust is selected from the build + # architecture, so fetch the x86_64 compiler archive explicitly too. + make -C depends BUILD=x86_64-pc-linux-gnu PLATFORM_GUI=1 download-one + + - name: Select Rust vendor artifact + id: vendor-artifact + if: github.event_name != 'schedule' && steps.rust-vendor-cache.outputs.cache-hit != 'true' + run: echo "name=depends-rust-vendor-sources-${{ github.run_id }}" >> "$GITHUB_OUTPUT" + + - name: Upload Rust vendor sources + if: steps.vendor-artifact.outputs.name != '' + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.vendor-artifact.outputs.name }} + path: depends/sources/platform-cxx-*-vendored.tar.gz + compression-level: 0 + retention-days: 1 + overwrite: true diff --git a/ci/dash/matrix.sh b/ci/dash/matrix.sh index e54e279422c4..e76379120b7c 100755 --- a/ci/dash/matrix.sh +++ b/ci/dash/matrix.sh @@ -28,6 +28,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..212594528d04 --- /dev/null +++ b/ci/test/00_setup_env_native_platform_gui.sh @@ -0,0 +1,23 @@ +#!/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 + +# Builds dash-qt with the optional Dash Platform GUI (--enable-platform-gui) +# turned on and runs the platform_* unit-test suites. PLATFORM_GUI=1 makes +# depends build mbedtls and the Platform-owned CXX binding archive; proof +# verification, DPP decoding and state-transition construction come from that +# archive. Functional tests are skipped: the feature is exercised by the gated +# C++ unit tests and there is no dashd-only surface to drive. +export CONTAINER_NAME=ci_native_platform_gui +export HOST=x86_64-pc-linux-gnu +export PACKAGES="python3-zmq qtbase5-dev qttools5-dev-tools libdbus-1-dev libharfbuzz-dev" +export DEP_OPTS="PLATFORM_GUI=1" +export RUN_UNIT_TESTS="true" +export RUN_UNIT_TESTS_SEQUENTIAL="false" +export RUN_FUNCTIONAL_TESTS="false" +export GOAL="install" +export BITCOIN_CONFIG="--enable-platform-gui --with-gui=qt5 --enable-zmq --with-libs=no --enable-reduce-exports LDFLAGS=-static-libstdc++" diff --git a/configure.ac b/configure.ac index daf733fb2224..482aa3244cb5 100644 --- a/configure.ac +++ b/configure.ac @@ -301,6 +301,18 @@ if test "$enable_miner" = "yes"; then AC_DEFINE(ENABLE_MINER, 1, [Define this symbol if in-wallet miner should be enabled]) fi +dnl Enable Dash Platform support (usernames / DashPay contacts) in the GUI. +dnl This only affects dash-qt; dashd and the other binaries never link any of it. +AC_ARG_ENABLE([platform-gui], + [AS_HELP_STRING([--enable-platform-gui], + [enable Dash Platform (usernames/DashPay) support in the GUI (default is no)])], + [enable_platform_gui=$enableval], + [enable_platform_gui=no]) +AC_ARG_VAR([MBEDTLS_CFLAGS], [C compiler flags for mbedtls, bypasses autodetection]) +AC_ARG_VAR([MBEDTLS_LIBS], [Linker flags for mbedtls, bypasses autodetection]) +AC_ARG_VAR([PLATFORM_CXX_CFLAGS], [C++ compiler flags for the Dash Platform CXX bindings]) +AC_ARG_VAR([PLATFORM_CXX_LIBS], [Linker flags for the Dash Platform CXX bindings]) + dnl Enable different -fsanitize options AC_ARG_WITH([sanitizers], [AS_HELP_STRING([--with-sanitizers], @@ -881,6 +893,16 @@ case $host in export PKG_CONFIG_PATH="$($BREW --prefix qt@5 2>/dev/null)/lib/pkgconfig:$PKG_CONFIG_PATH" fi + if test "$enable_platform_gui" = "yes" && $BREW list --versions mbedtls >/dev/null && test "$MBEDTLS_CFLAGS" = "" && test "$MBEDTLS_LIBS" = ""; then + mbedtls_prefix=$($BREW --prefix mbedtls 2>/dev/null) + if test "$suppress_external_warnings" != "no"; then + MBEDTLS_CFLAGS="-isystem $mbedtls_prefix/include" + else + MBEDTLS_CFLAGS="-I$mbedtls_prefix/include" + fi + MBEDTLS_LIBS="-L$mbedtls_prefix/lib -lmbedtls -lmbedx509 -lmbedcrypto" + fi + gmp_prefix=$($BREW --prefix gmp 2>/dev/null) if test "$gmp_prefix" != ""; then if test "$suppress_external_warnings" != "no"; then @@ -1936,6 +1958,57 @@ 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 Platform GUI support needs the GUI and the wallet, and mbedtls for the +dnl DAPI TLS client. The platform client library and the Platform-owned CXX +dnl archive are linked into dash-qt (and test binaries) only. +if test "$enable_platform_gui" = "yes"; then + if test "$bitcoin_enable_qt" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires the GUI (--with-gui)]) + fi + if test "$enable_wallet" != "yes"; then + AC_MSG_ERROR([--enable-platform-gui requires wallet support (--enable-wallet)]) + fi + if test "$MBEDTLS_CFLAGS$MBEDTLS_LIBS" = ""; then + AC_CHECK_HEADER([mbedtls/ssl.h], [], [AC_MSG_ERROR([mbedtls headers not found (required by --enable-platform-gui)])]) + AC_CHECK_LIB([mbedcrypto], [main], [MBEDTLS_LIBS="-lmbedcrypto"], [AC_MSG_ERROR([libmbedcrypto not found (required by --enable-platform-gui)])]) + AC_CHECK_LIB([mbedx509], [main], [MBEDTLS_LIBS="-lmbedx509 $MBEDTLS_LIBS"], [AC_MSG_ERROR([libmbedx509 not found (required by --enable-platform-gui)])], [$MBEDTLS_LIBS]) + AC_CHECK_LIB([mbedtls], [mbedtls_ssl_init], [MBEDTLS_LIBS="-lmbedtls $MBEDTLS_LIBS"], [AC_MSG_ERROR([libmbedtls not found (required by --enable-platform-gui)])], [$MBEDTLS_LIBS]) + fi + if test -z "$PLATFORM_CXX_LIBS"; then + PLATFORM_CXX_LIBS="-ldash_platform_cxx" + fi + PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -lpthread -lm" + case $host in + *darwin*) PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -framework CoreFoundation" ;; + *linux*) PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -ldl" ;; + *mingw*) PLATFORM_CXX_LIBS="$PLATFORM_CXX_LIBS -luserenv -lntdll" ;; + esac + TEMP_CPPFLAGS="$CPPFLAGS" + TEMP_LIBS="$LIBS" + CPPFLAGS="$CPPFLAGS $PLATFORM_CXX_CFLAGS" + LIBS="$PLATFORM_CXX_LIBS $LIBS" + AC_LANG_PUSH([C++]) + AC_MSG_CHECKING([for Dash Platform CXX bindings]) + AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + #include + ]], [[ + platform_ffi::set_context("test", std::uint32_t{0}); + ]])], [AC_MSG_RESULT([yes])], [ + AC_MSG_RESULT([no]) + AC_MSG_ERROR([Dash Platform CXX bindings not found (required by --enable-platform-gui)]) + ]) + AC_LANG_POP + 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"]) +AC_SUBST(MBEDTLS_CFLAGS) +AC_SUBST(MBEDTLS_LIBS) +AC_SUBST(PLATFORM_CXX_CFLAGS) +AC_SUBST(PLATFORM_CXX_LIBS) + AM_CONDITIONAL([TARGET_DARWIN], [test "$TARGET_OS" = "darwin"]) AM_CONDITIONAL([BUILD_DARWIN], [test "$BUILD_OS" = "darwin"]) AM_CONDITIONAL([TARGET_LINUX], [test "$TARGET_OS" = "linux"]) @@ -2133,6 +2206,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/update-rust-hashes.py b/contrib/devtools/update-rust-hashes.py new file mode 100755 index 000000000000..a2cee608f33e --- /dev/null +++ b/contrib/devtools/update-rust-hashes.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2021-2022 The Zcash developers +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import hashlib +import re +import sys +import urllib.request +from pathlib import Path + +# Rust standard libraries provisioned in rust_stdlib.mk. Confined to the +# hosts we validate (the Guix release set); see rust_stdlib.mk. +CROSS_TARGETS = [ + # Linux + "aarch64-unknown-linux-musl", + "riscv64gc-unknown-linux-musl", + "x86_64-unknown-linux-musl", + # Windows + "x86_64-pc-windows-gnu", + # macOS + "aarch64-apple-darwin", + "x86_64-apple-darwin", +] + +# Native compilers provisioned in native_rust.mk (build hosts for depends) +NATIVE_TARGETS = [ + # Linux + ("aarch64-unknown-linux-gnu", "aarch64_linux"), + ("x86_64-unknown-linux-gnu", "x86_64_linux"), + # macOS + ("aarch64-apple-darwin", "aarch64_darwin"), + ("x86_64-apple-darwin", "x86_64_darwin"), +] + + +def get_rust_version(makefile_path: Path) -> str: + content = makefile_path.read_text() + match = re.search(r"\$\(package\)_version:=(.+)", content) + if not match: + raise RuntimeError("Could not find Rust version in makefile") + return match.group(1).strip() + + +def compute_sha256(url: str) -> str: + hasher = hashlib.sha256() + with urllib.request.urlopen(url) as response: + while chunk := response.read(8192): + hasher.update(chunk) + return hasher.hexdigest() + + +def update_hash_in_file(makefile_path: Path, pattern: str, new_hash: str) -> None: + content = makefile_path.read_text() + regex = re.compile(rf"^(\$\(package\)_{pattern}:=).*$", re.MULTILINE) + if not regex.search(content): + raise RuntimeError(f"Could not find pattern {pattern} in makefile") + new_content = regex.sub(rf"\g<1>{new_hash}", content) + makefile_path.write_text(new_content) + + +def update_version_in_file(path: Path, pattern: str, version: str) -> None: + content = path.read_text() + regex = re.compile(pattern, re.MULTILINE) + new_content, replacements = regex.subn( + lambda match: f"{match.group(1)}{version}{match.group(2) if match.lastindex == 2 else ''}", content + ) + if replacements != 1: + raise RuntimeError(f"Expected one version pin in {path}, found {replacements}") + path.write_text(new_content) + + +def compute_rust_hash(rust_version: str, rust_target: str) -> str: + url = f"https://static.rust-lang.org/dist/rust-{rust_version}-{rust_target}.tar.gz" + return compute_sha256(url) + + +def compute_stdlib_hash(rust_version: str, rust_target: str) -> str: + url = f"https://static.rust-lang.org/dist/rust-std-{rust_version}-{rust_target}.tar.gz" + return compute_sha256(url) + + +def main() -> int: + script_dir = Path(__file__).resolve().parent + native_rust_path = script_dir / "../../depends/packages/native_rust.mk" + native_rust_path = native_rust_path.resolve() + rust_stdlib_path = script_dir / "../../depends/packages/rust_stdlib.mk" + rust_stdlib_path = rust_stdlib_path.resolve() + toolchain_path = (script_dir / "../../rust-toolchain.toml").resolve() + configure_path = (script_dir / "../../configure.ac").resolve() + + for path in (native_rust_path, rust_stdlib_path, toolchain_path, configure_path): + if not path.exists(): + print(f"Error: {path} not found", file=sys.stderr) + return 1 + + rust_version = get_rust_version(native_rust_path) + + print(f"Rust version: {rust_version}\n") + print("Downloading native compiler hashes:") + + native_hashes = {} + for rust_target, makefile_id in NATIVE_TARGETS: + native_hashes[makefile_id] = compute_rust_hash(rust_version, rust_target) + print(f" Downloaded sha256_hash_{makefile_id}") + + print("\nDownloading stdlib hashes:") + stdlib_hashes = {} + for rust_target in CROSS_TARGETS: + stdlib_hashes[rust_target] = compute_stdlib_hash(rust_version, rust_target) + print(f" Downloaded sha256_hash_{rust_target}") + + for makefile_id, hash_value in native_hashes.items(): + update_hash_in_file(native_rust_path, f"sha256_hash_{makefile_id}", hash_value) + for rust_target, hash_value in stdlib_hashes.items(): + update_hash_in_file(rust_stdlib_path, f"sha256_hash_{rust_target}", hash_value) + + update_version_in_file(rust_stdlib_path, r"^(\$\(package\)_version:=).*$", rust_version) + update_version_in_file(toolchain_path, r'^(channel = ")[^"]*(")$', rust_version) + update_version_in_file(configure_path, r'^(RUSTC_REQUIRED_VERSION=")[^"]*(")$', rust_version) + print("\nSynchronized rust_stdlib.mk, rust-toolchain.toml, and configure.ac") + + print("\nDone!") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/depends/Makefile b/depends/Makefile index c8510f4cc010..654ea0e03c7c 100644 --- a/depends/Makefile +++ b/depends/Makefile @@ -44,6 +44,7 @@ NO_UPNP ?= NO_USDT ?= NO_NATPMP ?= MULTIPROCESS ?= +PLATFORM_GUI ?= LTO ?= NO_HARDEN ?= FALLBACK_DOWNLOAD_PATH ?= http://dash-depends-sources.s3-website-us-west-2.amazonaws.com @@ -175,6 +176,7 @@ natpmp_packages_$(NO_NATPMP) = $(natpmp_packages) zmq_packages_$(NO_ZMQ) = $(zmq_packages) multiprocess_packages_$(MULTIPROCESS) = $(multiprocess_packages) +platform_packages_$(PLATFORM_GUI) = $(platform_packages) usdt_packages_$(NO_USDT) = $(usdt_$(host_os)_packages) packages += $($(host_arch)_$(host_os)_packages) $($(host_os)_packages) $(boost_packages_) $(libevent_packages_) $(qt_packages_) $(wallet_packages_) $(upnp_packages_) $(natpmp_packages_) $(usdt_packages_) @@ -189,6 +191,14 @@ packages += $(multiprocess_packages) native_packages += $(multiprocess_native_packages) endif +ifeq ($(platform_packages_),) +packages += $(platform_packages) +native_packages += $(platform_native_packages) +rust_download_targets = download-rust-std +endif + +cargo_packages = $(filter platform_cxx,$(packages)) + 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 @@ -202,9 +212,13 @@ $(host_prefix)/.stamp_$(final_build_id): $(native_packages) $(packages) mkdir -p $(@D) echo copying packages: $^ echo to: $(@D) - cd $(@D); $(foreach package,$^, $(build_TAR) xf $($(package)_cached); ) + cd $(@D); $(foreach package,$^, $(build_TAR) --no-same-owner -xf $($(package)_cached); ) touch $@ +# Vendors crate dependencies for cargo-built packages +vendor-dep-crates: $(foreach package,$(cargo_packages),vendor-$(package)-crates) +.PHONY: vendor-dep-crates + # $PATH is not preserved between ./configure and make by convention. Its # modification and overriding at ./configure time is (as I understand it) # supposed to be captured by the AC_{PROG_{,OBJ}CXX,PATH_{PROG,TOOL}} macros, @@ -257,6 +271,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)|' \ @@ -299,15 +314,19 @@ install: check-packages $(host_prefix)/share/config.site download-one: check-sources $(all_sources) download-osx: - @$(MAKE) -s HOST=x86_64-apple-darwin download-one + @$(MAKE) -s PLATFORM_GUI=$(PLATFORM_GUI) HOST=x86_64-apple-darwin download-one download-linux: - @$(MAKE) -s HOST=x86_64-unknown-linux-gnu download-one + @$(MAKE) -s PLATFORM_GUI=$(PLATFORM_GUI) HOST=x86_64-unknown-linux-gnu download-one download-win: - @$(MAKE) -s HOST=x86_64-w64-mingw32 download-one -download: download-osx download-linux download-win + @$(MAKE) -s PLATFORM_GUI=$(PLATFORM_GUI) HOST=x86_64-w64-mingw32 download-one +download-rust-std: + @mkdir -p $(SOURCES_PATH) + @mkdir -p $(SOURCES_PATH)/download-stamps + @$(foreach target,$(rust_stdlib_targets),$(call download_rust_std_target,$(target)) && ) true +download: download-osx download-linux download-win $(rust_download_targets) $(foreach package,$(all_packages),$(eval $(call ext_add_stages,$(package)))) -.PHONY: install cached clean clean-all download-one download-osx download-linux download-win download check-packages check-sources +.PHONY: install cached clean clean-all download-one download-osx download-linux download-win download download-rust-std check-packages check-sources .PHONY: FORCE $(V).SILENT: diff --git a/depends/README.md b/depends/README.md index f504627729f0..f4e5f744b5bc 100644 --- a/depends/README.md +++ b/depends/README.md @@ -92,6 +92,8 @@ 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`: Download/build/cache the Rust toolchain, target standard library and + the Dash Platform CXX bindings needed for `--enable-platform-gui` - `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..ef4af20ff1cd 100644 --- a/depends/config.site.in +++ b/depends/config.site.in @@ -50,6 +50,17 @@ 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 "$PLATFORM_CXX_CFLAGS" && test -f "${depends_prefix}/include/dash/platform/ffi.h"; then + PLATFORM_CXX_CFLAGS="-I${depends_prefix}/include" +fi +if test -z "$PLATFORM_CXX_LIBS" && test -f "${depends_prefix}/lib/libdash_platform_cxx.a"; then + PLATFORM_CXX_LIBS="${depends_prefix}/lib/libdash_platform_cxx.a" +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..0fc9aee733df 100644 --- a/depends/funcs.mk +++ b/depends/funcs.mk @@ -197,6 +197,22 @@ $(1)_cmake += -DCMAKE_C_COMPILER_TARGET=$(host) $(1)_cmake += -DCMAKE_CXX_COMPILER_TARGET=$(host) endif endif + +$(1)_cargo=env CC="$$($(1)_cc)" \ + CXX="$$($(1)_cxx)" \ + AR="$$($(1)_ar)" \ + CFLAGS="$$($(1)_cppflags) $$($(1)_cflags)" \ + CXXFLAGS="$$($(1)_cppflags) $$($(1)_cxxflags)" \ + LDFLAGS="$$($(1)_ldflags)" \ + RUSTFLAGS="-C linker=$$(firstword $($(1)_cc))" \ + LD_LIBRARY_PATH="$$($($(1)_type)_prefix)/lib" +ifeq ($(host_os),darwin) +$(1)_cargo += MACOSX_DEPLOYMENT_TARGET="$(OSX_MIN_VERSION)" +ifneq ($(host),$(build)) +$(1)_cargo += SDKROOT="$(OSX_SDK)" +endif +endif +$(1)_cargo += cargo endef define int_add_cmds @@ -269,6 +285,39 @@ $(foreach stage,$(stages), .PHONY: $(1)_$(stage)) endef +# Template for vendoring a package's Rust crate dependencies +# Packages opt-in by defining $(package)_vendored_file_name and $(package)_cargo_manifest +define int_vendor_crates +ifneq ($($(1)_vendored_file_name),) +$(1)_vendored_archive = $(SOURCES_PATH)/$($(1)_vendored_file_name) + +vendor-$(1)-crates: $(native_rust_cached) $($(1)_fetched) + @rm -rf $(WORK_PATH)/vendor-$(1) + @mkdir -p $(WORK_PATH)/vendor-$(1) + @$(build_TAR) --no-same-owner -xf $(native_rust_cached) -C $(WORK_PATH)/vendor-$(1) + @echo "Vendoring $(1) crates..." + @mkdir -p $(WORK_PATH)/vendor-$(1)/src + @cd $(WORK_PATH)/vendor-$(1)/src && $(build_TAR) --no-same-owner --strip-components=1 -xf $(SOURCES_PATH)/$($(1)_file_name) + @if test -f $(PATCHES_PATH)/$(1)/Cargo.lock; then \ + cp $(PATCHES_PATH)/$(1)/Cargo.lock $(WORK_PATH)/vendor-$(1)/src/$($(1)_cargo_lock_path); \ + fi + @$(WORK_PATH)/vendor-$(1)/native/bin/cargo vendor --locked --manifest-path $(WORK_PATH)/vendor-$(1)/src/$($(1)_cargo_manifest) $(WORK_PATH)/vendor-$(1)/src/vendored + @cd $(WORK_PATH)/vendor-$(1)/src; find vendored | sort | $(build_TAR) --no-recursion -czf $$($(1)_vendored_archive) -T - + @rm -rf $(WORK_PATH)/vendor-$(1) + @echo "Created $$($(1)_vendored_archive)" +.PHONY: vendor-$(1)-crates +endif +endef + +define download_rust_std_target +([ -f "$(SOURCES_PATH)/rust-std-$(rust_stdlib_version)-$(1).tar.gz" ] && \ + echo "Already have rust-std-$(rust_stdlib_version)-$(1).tar.gz" || \ + (echo "Downloading rust-std-$(rust_stdlib_version)-$(1).tar.gz..." && \ + $(build_DOWNLOAD) "$(SOURCES_PATH)/rust-std-$(rust_stdlib_version)-$(1).tar.gz" "$(rust_stdlib_download_path)/rust-std-$(rust_stdlib_version)-$(1).tar.gz")) && \ +echo "$(rust_stdlib_sha256_hash_$(1)) $(SOURCES_PATH)/rust-std-$(rust_stdlib_version)-$(1).tar.gz" | $(build_SHA256SUM) -c - && \ +echo "$(rust_stdlib_sha256_hash_$(1)) rust-std-$(rust_stdlib_version)-$(1).tar.gz" > "$(SOURCES_PATH)/download-stamps/.stamp_fetched-rust_stdlib-$(rust_stdlib_version)-$(rust_stdlib_sha256_hash_$(1)).hash" +endef + # These functions create the build targets for each package. They must be # broken down into small steps so that each part is done for all packages # before moving on to the next step. Otherwise, a package's info @@ -286,6 +335,18 @@ $(foreach package,$(all_packages),$(eval $(call int_vars,$(package)))) $(foreach native_package,$(native_packages),$(eval include packages/$(native_package).mk)) $(foreach package,$(packages),$(eval include packages/$(package).mk)) +# Extend preprocess_cmds for cargo packages to extract vendored crates +define int_cargo_preprocess_ext +$(1)_preprocess_cmds += && \ + if test -f $(SOURCES_PATH)/$($(1)_vendored_file_name); then \ + echo "Extracting vendored crates for $(1)..." && \ + $(build_TAR) --no-same-owner -xf $(SOURCES_PATH)/$($(1)_vendored_file_name) && \ + mkdir -p .cargo && \ + cp $(PATCHES_PATH)/$(1)/cargo-config.toml .cargo/config.toml; \ + fi +endef +$(foreach cargo_package,$(cargo_packages),$(eval $(call int_cargo_preprocess_ext,$(cargo_package)))) + #compute a hash of all files that comprise this package's build recipe $(foreach package,$(all_packages),$(eval $(call int_get_build_recipe_hash,$(package)))) @@ -297,3 +358,6 @@ $(foreach package,$(all_packages),$(eval $(call int_config_attach_build_config,$ #create build targets $(foreach package,$(all_packages),$(eval $(call int_add_cmds,$(package)))) + +#create vendor targets for cargo packages +$(foreach cargo_package,$(cargo_packages),$(eval $(call int_vendor_crates,$(cargo_package)))) diff --git a/depends/packages/mbedtls.mk b/depends/packages/mbedtls.mk new file mode 100644 index 000000000000..cea4466c167d --- /dev/null +++ b/depends/packages/mbedtls.mk @@ -0,0 +1,27 @@ +package=mbedtls +$(package)_version=3.6.3.1 +$(package)_download_path=https://github.com/Mbed-TLS/mbedtls/releases/download/v$($(package)_version)/ +$(package)_file_name=$(package)-$($(package)_version).tar.bz2 +$(package)_sha256_hash=243ed496d5f88a5b3791021be2800aac821b9a4cc16e7134aa413c58b4c20e0c + +define $(package)_set_vars +$(package)_config_opts := -DENABLE_PROGRAMS=OFF -DENABLE_TESTING=OFF +$(package)_config_opts += -DUSE_SHARED_MBEDTLS_LIBRARY=OFF -DUSE_STATIC_MBEDTLS_LIBRARY=ON +$(package)_config_opts += -DMBEDTLS_FATAL_WARNINGS=OFF -DGEN_FILES=OFF +endef + +define $(package)_config_cmds + $($(package)_cmake) -S . -B . +endef + +define $(package)_build_cmds + $(MAKE) +endef + +define $(package)_stage_cmds + $(MAKE) DESTDIR=$($(package)_staging_dir) install +endef + +define $(package)_postprocess_cmds + rm -rf lib/cmake +endef 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..b474a0971ae3 --- /dev/null +++ b/depends/packages/native_rust.mk @@ -0,0 +1,55 @@ +# 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.92.0 +$(package)_download_path:=https://static.rust-lang.org/dist +$(package)_patches:=fix-elf-interpreter.sh + +# Linux (ARMv8) +$(package)_file_name_aarch64_linux:=rust-$($(package)_version)-aarch64-unknown-linux-gnu.tar.gz +$(package)_sha256_hash_aarch64_linux:=c812028423c3d7dd7ba99f66101e9e1aa3f66eab44a1285f41c363825d49dca4 + +# Linux (x86_64) +$(package)_file_name_x86_64_linux:=rust-$($(package)_version)-x86_64-unknown-linux-gnu.tar.gz +$(package)_sha256_hash_x86_64_linux:=6e5efd6c25953b2732d4e6b1842512536650c68cf72a8b99a0fc566012dd6ca5 + +# macOS (ARMv8) +$(package)_file_name_aarch64_darwin:=rust-$($(package)_version)-aarch64-apple-darwin.tar.gz +$(package)_sha256_hash_aarch64_darwin:=235a6cca2dd4881130a9ae61ad1149bbf28bba184dd4621700f0c98c97457716 + +# macOS (x86_64) +$(package)_file_name_x86_64_darwin:=rust-$($(package)_version)-x86_64-apple-darwin.tar.gz +$(package)_sha256_hash_x86_64_darwin:=fc6868991e61e9262272effbb8956b23428430f5f4300c1b48eaae3969f8af2a + +$(package)_file_name=$($(package)_file_name_$(build_arch)_$(build_os)) +$(package)_sha256_hash=$($(package)_sha256_hash_$(build_arch)_$(build_os)) + +define $(package)_set_vars +$(package)_stage_opts=--disable-ldconfig +$(package)_stage_build_opts=--without=rust-docs-json-preview,rust-docs +endef + +define $(package)_fetch_cmds +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_file_name),$($(package)_file_name),$($(package)_sha256_hash)) +endef + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_dir)/$(host_prefix)/native/bin && \ + mkdir -p $($(package)_staging_dir)/$(host_prefix)/native/lib/rustlib && \ + cp cargo/bin/cargo $($(package)_staging_dir)/$(host_prefix)/native/bin/ && \ + cp rustc/bin/rustc $($(package)_staging_dir)/$(host_prefix)/native/bin/ && \ + cp rustc/bin/rustdoc $($(package)_staging_dir)/$(host_prefix)/native/bin/ && \ + cp -r rustc/lib/* $($(package)_staging_dir)/$(host_prefix)/native/lib/ && \ + cp -r rust-std-*/lib/rustlib/* $($(package)_staging_dir)/$(host_prefix)/native/lib/rustlib/ && \ + bash $($(package)_patch_dir)/fix-elf-interpreter.sh \ + $($(package)_staging_dir)/$(host_prefix)/native/lib \ + $($(package)_staging_dir)/$(host_prefix)/native/bin/cargo \ + $($(package)_staging_dir)/$(host_prefix)/native/bin/rustc \ + $($(package)_staging_dir)/$(host_prefix)/native/bin/rustdoc +endef diff --git a/depends/packages/packages.mk b/depends/packages/packages.mk index 7e0bb2633219..38f0d38a8373 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -26,4 +26,7 @@ natpmp_packages=libnatpmp multiprocess_packages = libmultiprocess capnp multiprocess_native_packages = native_libmultiprocess native_capnp +platform_packages = mbedtls rust_stdlib tenderdash_sources platform_cxx +platform_native_packages = native_protobuf native_rust + usdt_linux_packages=systemtap diff --git a/depends/packages/platform_cxx.mk b/depends/packages/platform_cxx.mk new file mode 100644 index 000000000000..5a959fe2e350 --- /dev/null +++ b/depends/packages/platform_cxx.mk @@ -0,0 +1,37 @@ +# 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=platform_cxx +$(package)_version=df4fdb68559ef57d50624b7f0841594aef8647e5 +$(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=935b64a4f3acf48840706d573acc4c43ce4ac44272265ea96af45e35c47d829a +$(package)_build_subdir=packages/rs-platform-cxx/standalone +$(package)_dependencies=native_rust rust_stdlib native_protobuf tenderdash_sources +$(package)_patches=cargo-config.toml +$(package)_vendored_file_name=platform-cxx-$($(package)_version)-vendored.tar.gz +$(package)_cargo_manifest=packages/rs-platform-cxx/standalone/Cargo.toml +$(package)_cargo_lock_path=packages/rs-platform-cxx/standalone/Cargo.lock + +define $(package)_preprocess_cmds + true +endef + +define $(package)_build_cmds + mkdir -p target && \ + cp $(host_prefix)/tenderdash-sources/tenderdash-*.zip target/ && \ + CARGO_BUILD_TARGET=$(rust_stdlib_target) \ + CARGO_TARGET_DIR=$($(package)_build_dir)/target \ + PROTOC=$(build_prefix)/bin/protoc \ + PROTOC_INCLUDE=$(build_prefix)/include \ + $($(package)_cargo) build --locked --offline --release --target $(rust_stdlib_target) +endef + +define $(package)_stage_cmds + CARGO_BUILD_TARGET=$(rust_stdlib_target) \ + CARGO_PROFILE=release \ + CARGO_TARGET_DIR=$($(package)_build_dir)/target \ + bash ../install.sh $($(package)_staging_prefix_dir) +endef diff --git a/depends/packages/rust_stdlib.mk b/depends/packages/rust_stdlib.mk new file mode 100644 index 000000000000..99395326ebad --- /dev/null +++ b/depends/packages/rust_stdlib.mk @@ -0,0 +1,70 @@ +# 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. + +# This version is synchronized from native_rust.mk by update-rust-hashes.py. + +package:=rust_stdlib +$(package)_version:=1.92.0 +$(package)_download_path:=https://static.rust-lang.org/dist +$(package)_dependencies:=native_rust + +# Rust support is deliberately confined to the hosts we actually validate +# (the Guix release set plus native development hosts). RUST=1 on any other +# host fails explicitly below rather than fetching a stdlib we never test. + +# Linux (ARMv8) +$(package)_targets += aarch64-unknown-linux-musl +$(package)_target_aarch64-unknown-linux-gnu:=aarch64-unknown-linux-musl +$(package)_sha256_hash_aarch64-unknown-linux-musl:=715fbcfd8712c723947a020d0371c8a1a21f7531f2b696aeaed50ac23ba675c9 + +# Linux (RISCV64GC) +$(package)_targets += riscv64gc-unknown-linux-musl +$(package)_target_riscv64-unknown-linux-gnu:=riscv64gc-unknown-linux-musl +$(package)_target_riscv64gc-unknown-linux-gnu:=riscv64gc-unknown-linux-musl +$(package)_sha256_hash_riscv64gc-unknown-linux-musl:=34f5722ff2a0940bcd7ff6603a7748d2b963de72f6f713579c39c74ead06a7a0 + +# Linux (x86_64) +$(package)_targets += x86_64-unknown-linux-musl +$(package)_target_x86_64-unknown-linux-gnu:=x86_64-unknown-linux-musl +$(package)_sha256_hash_x86_64-unknown-linux-musl:=8bfd9a42c8295949d556587201acdb35d2bfb8b7ce55223845f337aa5614f9a3 + +# macOS (ARMv8) +$(package)_targets += aarch64-apple-darwin +$(package)_target_aarch64-apple-darwin:=aarch64-apple-darwin +$(package)_target_arm64-apple-darwin:=aarch64-apple-darwin +$(package)_sha256_hash_aarch64-apple-darwin:=b1f55aac4bc982ea67b68b262b711263005e470d31cab5d09d534bc1866d455a + +# macOS (x86_64) +$(package)_targets += x86_64-apple-darwin +$(package)_target_x86_64-apple-darwin:=x86_64-apple-darwin +$(package)_sha256_hash_x86_64-apple-darwin:=1e5a8fee4e038ea2d35d82a680e2b9bf44ffccb3746aaf9dbdc56cb14152dcb8 + +# Windows (x86_64) +$(package)_targets += x86_64-pc-windows-gnu +$(package)_target_x86_64-w64-mingw32:=x86_64-pc-windows-gnu +$(package)_sha256_hash_x86_64-pc-windows-gnu:=6256f3497e3b14b6650511e84fdfb51fc632db1908ae5a173dffcdc96c80b7ce + +$(package)_target:=$(or \ + $($(package)_target_$(canonical_host)),\ + $($(package)_target_$(subst -pc-,-unknown-,$(canonical_host))),\ + $($(package)_target_$(subst -unknown-,-pc-,$(canonical_host))),\ + $($(package)_target_$(subst -linux-,-unknown-linux-,$(canonical_host))),\ + $(if $(findstring -apple-darwin,$(canonical_host)),$(host_arch)-apple-darwin)) + +ifeq ($($(package)_target),) +$(error Unsupported Rust standard library target: $(canonical_host)) +endif + +$(package)_file_name=rust-std-$($(package)_version)-$($(package)_target).tar.gz +$(package)_sha256_hash=$($(package)_sha256_hash_$($(package)_target)) + +define $(package)_fetch_cmds + $(call fetch_file,$(package),$($(package)_download_path),$($(package)_file_name),$($(package)_file_name),$($(package)_sha256_hash)) +endef + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_dir)/$(host_prefix)/native/lib/rustlib && \ + cp -r rust-std-$($(package)_target)/lib/rustlib/$($(package)_target) $($(package)_staging_dir)/$(host_prefix)/native/lib/rustlib/ +endef diff --git a/depends/packages/tenderdash_sources.mk b/depends/packages/tenderdash_sources.mk new file mode 100644 index 000000000000..057abbf6048b --- /dev/null +++ b/depends/packages/tenderdash_sources.mk @@ -0,0 +1,36 @@ +# 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. + +# Source-only package: the tenderdash source archive that tenderdash-proto's +# build.rs (rs-tenderdash-abci, a dependency of the Platform CXX package) +# compiles its protobuf definitions from. +# Online builds download this zip themselves at cargo build time; depends +# builds must not touch the network, so the sha256-pinned archive is staged +# verbatim into the prefix and platform_cxx copies it into the Cargo target +# directory, where build.rs treats it as a pre-populated download +# cache (cache file name: tenderdash-$(TENDERDASH_COMMITISH).zip). +# +# The version must match TENDERDASH_COMMITISH default of the pinned +# tenderdash-proto crate (rs-tenderdash-abci proto/build.rs). + +package=tenderdash_sources +$(package)_version=1.5.1 +$(package)_download_path=https://github.com/dashpay/tenderdash/archive +$(package)_download_file=v$($(package)_version).zip +$(package)_file_name=tenderdash-v$($(package)_version).zip +$(package)_sha256_hash=7a8844899a4635a6c2f55057e0c0f7cec357907d0cfeb2900e035760cf187f9a + +# Keep the archive as-is: the consumer (tenderdash-proto build.rs) unzips it +# from its own cache directory, so extraction here would only be discarded. +define $(package)_extract_cmds + mkdir -p $($(package)_extract_dir) && \ + echo "$($(package)_sha256_hash) $($(package)_source)" > $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + $(build_SHA256SUM) -c $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + cp $($(package)_source) $($(package)_file_name) +endef + +define $(package)_stage_cmds + mkdir -p $($(package)_staging_dir)/$(host_prefix)/tenderdash-sources && \ + cp $($(package)_file_name) $($(package)_staging_dir)/$(host_prefix)/tenderdash-sources/ +endef diff --git a/depends/patches/native_rust/fix-elf-interpreter.sh b/depends/patches/native_rust/fix-elf-interpreter.sh new file mode 100755 index 000000000000..ccf05f8ecc05 --- /dev/null +++ b/depends/patches/native_rust/fix-elf-interpreter.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +export LC_ALL=C + +# 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. + +LIBDIR="$1" +shift + +if ! command -v patchelf >/dev/null 2>&1; then + # Inside a Guix environment the prebuilt binaries cannot run without + # having their interpreter patched, so a missing patchelf is fatal there. + case "$(command -v ls)" in + /gnu/store/*) + echo "ERROR: patchelf is required inside the Guix environment but was not found" >&2 + exit 1 + ;; + esac + echo "patchelf not found, skipping ELF fix" + exit 0 +fi + +# Get the interpreter from a known working binary (ls) +LS_PATH=$(command -v ls) +GUIX_INTERP=$(patchelf --print-interpreter "$LS_PATH" 2>/dev/null) + +if [ -z "$GUIX_INTERP" ]; then + echo "Could not detect interpreter, skipping" + exit 0 +fi + +echo "Detected interpreter: $GUIX_INTERP" + +# Find and copy runtime libraries the prebuilt binaries need into our lib +# directory so the $ORIGIN-based RPATH can resolve them. +for libname in libgcc_s.so.1 libz.so.1; do + LIB_SRC="" + + # Method 1: Use gcc to find it + if command -v gcc >/dev/null 2>&1; then + CANDIDATE=$(gcc -print-file-name="$libname" 2>/dev/null) + if [ -f "$CANDIDATE" ]; then + LIB_SRC="$CANDIDATE" + else + GCC_PATH=$(command -v gcc) + GCC_PREFIX=$(dirname "$(dirname "$GCC_PATH")") + if [ -f "$GCC_PREFIX/lib/$libname" ]; then + LIB_SRC="$GCC_PREFIX/lib/$libname" + fi + fi + fi + + # Method 2: Search LIBRARY_PATH + if [ -z "$LIB_SRC" ] && [ -n "$LIBRARY_PATH" ]; then + IFS=':' read -ra LIB_PATHS <<< "$LIBRARY_PATH" + for libpath in "${LIB_PATHS[@]}"; do + if [ -f "$libpath/$libname" ]; then + LIB_SRC="$libpath/$libname" + break + fi + done + fi + + if [ -n "$LIB_SRC" ]; then + # Resolve symlinks and copy the actual file + LIB_REAL=$(readlink -f "$LIB_SRC") + echo "Copying $libname from: $LIB_REAL" + cp "$LIB_REAL" "$LIBDIR/$libname" + else + echo "WARNING: Could not find $libname to copy" + fi +done + +# RPATH just needs $ORIGIN/../lib - everything is self-contained +GUIX_RPATH="\$ORIGIN/../lib" +echo "Using RPATH: $GUIX_RPATH" + +for binary in "$@"; do + if [ -f "$binary" ]; then + echo "Patching: $binary" + patchelf --set-interpreter "$GUIX_INTERP" "$binary" + patchelf --set-rpath "$GUIX_RPATH" "$binary" + fi +done + +if [ -n "$1" ]; then + echo "Verifying first binary:" + patchelf --print-interpreter "$1" + patchelf --print-rpath "$1" +fi diff --git a/depends/patches/platform_cxx/cargo-config.toml b/depends/patches/platform_cxx/cargo-config.toml new file mode 100644 index 000000000000..1f46305c8c3f --- /dev/null +++ b/depends/patches/platform_cxx/cargo-config.toml @@ -0,0 +1,30 @@ +[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = "vendored" + +[source."git+https://github.com/dashpay/agora-blsful?rev=0c34a7a488a0bd1c9a9a2196e793b303ad35c900"] +git = "https://github.com/dashpay/agora-blsful" +rev = "0c34a7a488a0bd1c9a9a2196e793b303ad35c900" +replace-with = "vendored-sources" + +[source."git+https://github.com/dashpay/grovedb?rev=a2791bbdca756d6a6113024aec48f09f7a33faa9"] +git = "https://github.com/dashpay/grovedb" +rev = "a2791bbdca756d6a6113024aec48f09f7a33faa9" +replace-with = "vendored-sources" + +[source."git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.1"] +git = "https://github.com/dashpay/rs-tenderdash-abci" +tag = "v1.5.1" +replace-with = "vendored-sources" + +[source."git+https://github.com/dashpay/rust-dashcore?rev=173ffac0fdc0c73dda0626cf385bbcfcf2437aeb"] +git = "https://github.com/dashpay/rust-dashcore" +rev = "173ffac0fdc0c73dda0626cf385bbcfcf2437aeb" +replace-with = "vendored-sources" + +[source."git+https://github.com/dashpay/vsss-rs?branch=main"] +git = "https://github.com/dashpay/vsss-rs" +branch = "main" +replace-with = "vendored-sources" diff --git a/doc/design/platform-rust-scope.md b/doc/design/platform-rust-scope.md new file mode 100644 index 000000000000..749a8290fc67 --- /dev/null +++ b/doc/design/platform-rust-scope.md @@ -0,0 +1,216 @@ +# Platform-GUI on Real Rust Crates — Scope Investigation + +Decision document for the `platform-gui-rust` track: re-implement the +internals of PR PastaPastaPasta/dash#49's `--enable-platform-gui` feature on +top of the real Dash Platform Rust crates, built and linked only when the flag +is enabled. The investigation below predates the final ownership decision: +the Rust implementation and CXX schema now live in dashpay/platform, while +Dash Core consumes the installed headers and static archive through depends. + +Three candidate scopes were spiked and measured. Spike workspaces (with +committed lockfiles, test harnesses, and raw logs) live outside the tree in +`~/workspace/platform-rust-spikes/`; every number below states the command +that produced it. + +## The three options + +| | Rust replaces | C++ keeps | +|---|---|---| +| **Option 1 — crypto core** | GroveDB proof verification, drive query verification, DPP (identity/document decode, state-transition build+sign, bincode) | DAPI transport (gRPC-Web/TLS), protobuf codecs, quorum BLS sig check, GUI, wallet | +| **Option 2 — middle** | Option 1 + typed response verification (`drive-proof-verifier` + `dapi-grpc`) | transport, quorum sig check, GUI, wallet | +| **Option 3 — full client** | Everything incl. transport (`dash-sdk` / `rs-dapi-client`, tokio runtime in dash-qt) | GUI, wallet seams | + +Pins used (current latest release): platform `v4.1.0` (MSRV **1.92**), +grovedb `v5.0.1` and rust-dashcore `70d4bf8e` (platform's own pins). + +## Measurements + +Machine: Apple Silicon (aarch64-apple-darwin), rustc 1.92.0. Release profile +mirrors the rust_build branch: `lto = true`, `codegen-units = 1`, +`panic = "abort"`. + +| Metric | Option 1 | Option 2 | Option 3 | Command | +|---|---|---|---|---| +| Lockfile crates | **284** | 408 | 471 | `grep -c '^name = ' Cargo.lock` | +| Build-graph crates (no dev-deps) | ~225 | 344 | 402 | `cargo tree -e no-dev … \| sort -u \| wc -l` | +| Git sources (each needs a vendor config stanza) | **5** | 6 | 7 | `grep -c '^\[source."git' ` | +| Vendor dir / tarball | **205 MB / 33 MB** | 364 MB / 53 MB | 411 MB / 58 MB | `cargo vendor --locked` + `tar czf` | +| Cold release build | **68 s** | 77 s | 196 s | `/usr/bin/time cargo build --release --locked --offline` | +| Warm rebuild (touch lib.rs) | 8.6 s | 8.6 s | 43 s | same | +| Staticlib size | 8.8 MB | 9.4 MB | 17.1 MB | `ls -la target/release/*.a` | +| Stripped C++-binary delta | **~2.5 MB** | ~2.4 MB¹ | **~7.5 MB** | clang++ main + cxx glue vs empty main, stripped | +| `panic=unwind` cost (opt 1) | +3.6 % `.a`, ≈same time | — | — | `release-unwind` profile | +| tokio / hyper / rustls / ring / tonic | **absent** | compiled in² | active | lockfile presence | +| rocksdb / storage | absent | absent | absent | lockfile presence | +| Extra link inputs | none | none | macOS: `Security`, `CoreFoundation`, `SystemConfiguration` frameworks | link test | + +¹ Slight underestimate relative to option 1: the spike bridge references +less of dpp. Both round to "≈2.5 MB". +² Present in the dependency graph and compiled, but fat LTO strips the +unreferenced transport out of the final binary; the cost is vendor surface, +build time, and audit scope — not shipped bytes. + +### Functional proofs (the important part) + +All harnesses live in the spike workspaces and run in CI-able form +(`cargo test`): + +- **GroveDB layer is a drop-in replacement.** All 16 of PR 49's + Rust-generated grovedb proof vectors (`src/test/data/platform/ + grovedb_proof_vectors.json` — keys, ranges, subqueries, conditional + branches, limits, absence, references, sum trees) verify **bit-identically** + (root hash + every result element) through `grovedb v5.0.1` + (`verify` feature, storage-free). `tests/vectors.rs`. +- **Error paths don't panic.** One corrupted byte mid-proof across all 16 + vectors: 7 return `Err`, 9 replay to a wrong root (which the C++ quorum-sig + check rejects), 0 panics/aborts — safe even under `panic=abort`; with + `panic=unwind` (+3.6 % size) cxx additionally converts any residual panic + into a catchable C++ exception. A junk proof fed from C++ through the cxx + bridge produces a clean `std::exception`. +- **Drive layer verified, cross-version stable.** PR 49's drive query vectors + were generated by platform **v4.0.0**; `drive v4.1.0` + (`Drive::verify_identity_balance_for_identity_id`, + `verify_identity_id_by_unique_public_key_hash`) verifies them with matching + roots and values. `tests/drive_vectors.rs`. +- **Signing seam works without exposing wallet keys.** dpp v4.1.0's `Signer` + trait is **async**; driven with `futures::executor::block_on` (no tokio) and + implemented over an external callback (the future cxx call into + `interfaces::Wallet::signPlatformDigest`), both of PR 49's hardest state + transitions build and sign: a DPNS-preorder batch transition (266 bytes) and + an **IdentityCreate** (249 bytes) via + `try_from_identity_with_signer_and_private_key` — HD identity keys stay + behind the callback; only the throwaway one-time asset-lock key crosses as + bytes, matching PR 49's trust model. (The no-raw-key two-signer variant + exists but is coupled to rust-dashcore's `key-wallet`; not worth it.) + `tests/signing.rs`. +- **Option 3 transport works but changes the process model.** A + C++-instantiated tokio runtime fetched an identity from live testnet DAPI + over TLS and proceeded to proof verification, stopping exactly at the + stub `ContextProvider` (the SDK's quorum-key lookup — same shape as PR 49's + `QuorumKeyLookup`, would be backed by local LLMQ data). Requires the three + macOS frameworks at link time and moves quorum-sig verification from + dashbls into Rust (`blsful`). +- **Cross-compile smoke passes** (option 1): `cargo check --target + x86_64-pc-windows-gnu` (needs a mingw C compiler for `blst`) and + `--target x86_64-unknown-linux-musl` both clean on 1.92. + +### Build-infrastructure findings (apply to every option) + +These verify and extend the known dashpay/dash#7109 gaps: + +1. **Git-dependency vendoring works and the fix is mechanical.** + crates.io is unusable (`drive` 0.0.1 is a 2015 name-squat; `dpp`/`grovedb` + are stale), so git deps are unavoidable. `cargo vendor` handles them but + emits per-git-source `[source."git+…"] replace-with` stanzas that 7109's + offline config generation (a single `directory =` line) must learn to + carry. Captured verbatim in the spike results; an offline rebuild from the + vendor dir with a **fresh `CARGO_HOME`** succeeds. +2. **Source-unification patch required.** `grovedb-version` wants + `versioned-feature-core` from crates.io while `platform-version` wants the + same version from git; `cargo vendor` hard-fails on the duplicate. Fixed + with a two-line `[patch."https://github.com/dashpay/versioned-feature-core"]` + redirect. Upstream-worthy. +3. **Floating branch pin upstream.** `vsss-rs` (via `agora-blsful`) is pinned + `branch = "main"`. `--locked` freezes the rev in Cargo.lock, but any + re-lock silently moves it. Should be reported/fixed upstream. +4. **7109's flat `ar -x` aggregation is unusable at this scale — measured.** + 373 of 374 archive member basenames collide between two crate staticlibs + sharing a dependency tree; the flat-extract merge silently drops objects. + The umbrella-crate rework (one workspace staticlib depending on bridge + crates as rlibs, cargo features for conditionality) is mandatory, not + optional. +5. **MSRV.** Nine platform crates declare `rust-version = 1.92`; rustc 1.85.1 + (7109's pin) refuses them by name. Toolchain bump required, as planned. +6. **License / advisory audit** (`cargo deny`, `cargo audit`): everything in + the permissive set except `hex_lit` 0.1.1 (MITNFA — MIT with a + no-false-attribution clause; acceptable), `dpp` has **no license field** + in its Cargo.toml (upstream metadata bug; the repo is MIT), and bincode + 2.0.1 carries RUSTSEC-2025-0141 "unmaintained" (informational — bincode is + platform's wire format either way). +7. **Duplicate crypto implementations enter dash-qt** with any option: + rust-dashcore vendors its own secp256k1 (C), and `blsful`/`blst` sit + beside dashbls. Client-side only, never consensus-facing, but it is review + surface to acknowledge. +8. **dapi-grpc cannot shed tonic's transport by feature** — it is a + target-scoped (`cfg(not(wasm32))`) unconditional dependency with + `transport`+TLS features. Making option 2 lean would require an upstream + "types-only" feature split in dashpay/platform. + +## Comparison + +**Maintenance:** every option pins platform+grovedb+rust-dashcore and re-pins +each platform release (MSRV churn included). Option 1's tree (284 crates) is +~70 % of it shared with options 2/3; the marginal maintenance of 2/3 is the +async/network stack (tokio/hyper/rustls advisories arrive on their own +schedule and would demand pin bumps unrelated to any Dash feature). + +**Security/review:** option 1 compiles zero network-facing Rust; its entire +attack surface is parsers/verifiers fed by C++-fetched bytes — precisely the +code we *want* replaced by the canonical implementation, since the hand-rolled +C++ copies (proof verifier, bincode, DPP layouts, drive tree layout) are where +a silent divergence from mainnet Platform behavior would hurt. Options 2/3 add +~130–190 crates of async runtime + TLS whose main effect is enlarging the +Guix-vendored audit payload (+20–25 MB compressed). Option 3 additionally +moves quorum-sig verification from dashbls (consensus-vetted) into blsful. + +**What C++ gets deleted:** option 1 removes +`src/platform/{proof,dpp}/**`, `drive/{verify,queries}.*` (~3,000 LoC of the +riskiest code) plus the vendored blake3 (~1,780 LoC). Option 2 additionally +deletes `transport/protobuf.*` + response parsing (~400 LoC). Option 3 +additionally deletes `transport/**` (~1,400 LoC of reviewed, working I/O +code) — at the price of an embedded runtime and the biggest tree. + +**Guix/determinism risk:** identical mechanism for all options (vendored +tarball), scaled by size. Option 3 also adds per-platform system-library +coupling (frameworks on macOS; DNS/`getaddrinfo` behavior under the +musl-Rust/glibc-C++ mix on Linux is untested territory). + +## Recommendation: Option 1 (crypto core), leave option 2 as an upgrade path + +Option 1 captures essentially all of the correctness value — canonical +proof verification, canonical DPP encode/sign, canonical drive semantics, +bit-for-bit proven against PR 49's vectors — at the smallest cost in vendor +surface, build time, binary size, and review burden, with **no async runtime +and no network-facing Rust** in dash-qt. The quorum-sig check stays on +dashbls against locally synced LLMQ keys, preserving PR 49's trust +architecture unchanged. + +Option 2's only real prize (deleting the hand-rolled protobuf response +decoding) is blocked from being lean by dapi-grpc's unconditional tonic +transport; if upstream later ships a types-only feature, option 2 becomes a +small incremental PR on top of option 1 — nothing in option 1's design +forecloses it. + +Option 3 is rejected for this track: it discards working, reviewed C++ +transport, triples the binary delta and cold-build time, embeds tokio in a +GUI process, moves BLS verification off dashbls, and maximizes the +Guix/symbol-check/system-library surface — all for functionality PR 49 +already has in C++. + +## Consequences for the implementation + +- The Platform-owned `dash-platform-cxx` package exposes per-query verify functions + (proof bytes + params in → root hash + typed results out), state-transition + build/sign via a cxx-bridged wallet signer (async trait driven by a local + executor; one-time asset-lock key passed as bytes), and identity/document + decoders. Quorum-sig verification, transport, retry/freshness, GUI, and + wallet seams are untouched PR 49 C++. +- Dash Core's depends system pins the Platform source revision and its + standalone lockfile, vendors that dependency closure independently, and + installs `dash/platform/ffi.h`, `dash/platform/signer.h`, generated CXX + headers, and `libdash_platform_cxx.a` into the depends prefix. +- Core owns no Platform Rust crate or generated Platform bridge sources. + Conditional linking keeps the Platform archive out of dashd, dash-cli, + dash-tx, and wallet libraries. +- Core's independent Rust smoke component remains available through + `--enable-rust`, but cannot be linked into the same binaries as the Platform + static archive because both archives contain a Rust and CXX runtime. +- dpp features to pin: `state-transitions`, `state-transition-signing`, + `identity-serialization`, `identity-hashing`, `bls-signatures`, + `dpns-contract`, `dashpay-contract` (the last two also replace PR 49's + hard-coded contract IDs with the canonical definitions). +- PR 49's JSON vectors are kept as cross-implementation regression pins; the + spike proved they pass through the real crates unchanged. +- Report upstream while landing: dpp missing license metadata, vsss-rs + `branch = "main"` pin, dapi-grpc types-only feature request, + versioned-feature-core dual-source. diff --git a/src/Makefile.am b/src/Makefile.am index c09876eca3fc..ac526fa93022 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -62,6 +62,9 @@ LIBSECP256K1=secp256k1/libsecp256k1.la if ENABLE_ZMQ LIBBITCOIN_ZMQ=libbitcoin_zmq.a endif +if ENABLE_PLATFORM_GUI +LIBDASH_PLATFORM=libdash_platform.a +endif if BUILD_BITCOIN_LIBS LIBBITCOINCONSENSUS=libdashconsensus.la endif @@ -127,7 +130,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBDASH_PLATFORM) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -691,6 +695,40 @@ libbitcoin_zmq_a_SOURCES = \ endif # +# platform (Dash Platform client, linked into dash-qt and test_dash only) # +if ENABLE_PLATFORM_GUI +libdash_platform_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(MBEDTLS_CFLAGS) $(PLATFORM_CXX_CFLAGS) +libdash_platform_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +libdash_platform_a_CFLAGS = $(AM_CFLAGS) $(PIE_FLAGS) +libdash_platform_a_SOURCES = \ + platform/client.h \ + platform/dpp/document.cpp \ + platform/dpp/document.h \ + platform/dpp/identity.cpp \ + platform/dpp/identity.h \ + platform/dpp/statetransitions.cpp \ + platform/drive/queries.cpp \ + platform/drive/queries.h \ + platform/params.cpp \ + platform/params.h \ + platform/statetransitions.h \ + platform/transport/cbor.h \ + platform/transport/client.cpp \ + platform/transport/endpoint_retry.h \ + platform/transport/freshness.h \ + platform/transport/grpcweb.cpp \ + platform/transport/grpcweb.h \ + platform/transport/protobuf.cpp \ + platform/transport/protobuf.h \ + platform/transport/tls.cpp \ + platform/transport/tls.h \ + platform/types.h \ + platform/walletrecords.cpp \ + platform/walletrecords.h + +endif +# + # wallet # libbitcoin_wallet_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(BDB_CPPFLAGS) $(SQLITE_CFLAGS) libbitcoin_wallet_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/Makefile.test.include b/src/Makefile.test.include index a3c8cb3aead3..13d0c002d4eb 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -62,6 +62,10 @@ FUZZ_SUITE_LD_COMMON = \ $(GMP_LIBS) \ $(BACKTRACE_LIBS) +if ENABLE_PLATFORM_GUI +FUZZ_SUITE_LD_COMMON += $(LIBDASH_PLATFORM) $(PLATFORM_CXX_LIBS) $(MBEDTLS_LIBS) +endif + if USE_UPNP FUZZ_SUITE_LD_COMMON += $(MINIUPNPC_LIBS) endif @@ -220,6 +224,18 @@ BITCOIN_TESTS =\ test/versionbits_tests.cpp \ test/xoroshiro128plusplus_tests.cpp +if ENABLE_PLATFORM_GUI +BITCOIN_TESTS += \ + test/platform_client_tests.cpp \ + test/platform_dpp_tests.cpp \ + test/platform_drive_tests.cpp +JSON_TEST_FILES += \ + test/data/platform/dpp_identity_vectors.json \ + test/data/platform/dpp_st_vectors.json \ + test/data/platform/drive_query_vectors.json \ + test/data/platform/quorum_sig_vectors.json +endif + if ENABLE_WALLET BITCOIN_TESTS += \ wallet/test/bip39_tests.cpp \ @@ -273,6 +289,9 @@ if ENABLE_WALLET test_test_dash_LDADD += $(LIBBITCOIN_WALLET) test_test_dash_CPPFLAGS += $(BDB_CPPFLAGS) endif +if ENABLE_PLATFORM_GUI +test_test_dash_LDADD += $(LIBDASH_PLATFORM) $(PLATFORM_CXX_LIBS) $(MBEDTLS_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) @@ -399,6 +418,10 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/utxo_total_supply.cpp \ test/fuzz/validation_load_mempool.cpp \ test/fuzz/versionbits.cpp + +if ENABLE_PLATFORM_GUI +test_fuzz_fuzz_SOURCES += test/fuzz/platform_bridge.cpp +endif endif # ENABLE_FUZZ_BINARY nodist_test_test_dash_SOURCES = $(GENERATED_TEST_FILES) diff --git a/src/platform/README.md b/src/platform/README.md new file mode 100644 index 000000000000..24764d248510 --- /dev/null +++ b/src/platform/README.md @@ -0,0 +1,31 @@ +# Dash Platform client library (GUI-only) + +This directory contains a Qt-free C++ client for Dash Platform (Evolution), +used exclusively by the dash-qt GUI when configured with +`--enable-platform-gui`. It provides: + +- per-network parameters and the well-known system data contract IDs + (`params.*`); +- a protobuf wire-format subset for the DAPI gRPC messages; +- thin adapters (`dpp/`, `drive/queries.*`) over the Platform-owned CXX + bindings (namespace `platform_ffi`) for proved-response + verification, DPP object decoding and state-transition construction — + backed by the real dashpay/platform crates. Verification hands the exact + protobuf (request, response) pair to drive-proof-verifier's `FromProof`, + which replays the GroveDB proof and checks the Tenderdash quorum + threshold signature against locally synced LLMQ keys; +- a DAPI client speaking gRPC-Web over HTTP/1.1 + TLS (mbedtls) to evonodes. + +## Isolation rules + +- Nothing in this directory may be linked into `dashd`, `dash-cli`, + `dash-tx`, `dash-wallet` or any consensus/wallet library. It is linked into + `dash-qt` and `test_dash` only, and only under `--enable-platform-gui`. +- Consensus, wallet and node code must not include headers from here. The GUI + (`src/qt/platform/`) is the only consumer. +- Code here may depend on `src/crypto`, `src/util`, `src/bls` (dashbls), + `src/secp256k1` and the standard library. It must not depend on Qt. + +Upstream references are pinned in code comments (dashpay/platform, grovedb). +All protocol logic is pinned to a single Platform protocol version and must be +re-validated against Rust-generated test vectors when Platform upgrades. diff --git a/src/platform/client.h b/src/platform/client.h new file mode 100644 index 000000000000..0f2cbef07dd6 --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,130 @@ +// 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 platform { + +//! A quorum public key the client may verify proofs against, fed from the +//! node's locally synced LLMQ data (interfaces::Node::LLMQ). +struct QuorumKey { + uint256 quorum_hash; + std::vector pubkey; //!< serialized BLS public key (basic scheme) + int32_t height{0}; + + //! DAPI encodes Proof.quorum_hash in display (big-endian) byte order, + //! while uint256::begin() exposes Dash Core's internal little-endian + //! representation. Keep that representation boundary explicit here so a + //! valid locally synced quorum can be selected without weakening proof + //! verification. + bool matchesProofHash(const std::array& proof_hash) const + { + for (size_t i = 0; i < proof_hash.size(); ++i) { + if (proof_hash[i] != quorum_hash.begin()[proof_hash.size() - 1 - i]) return false; + } + return true; + } +}; + +//! An evonode DAPI endpoint, fed from the deterministic masternode list. +struct Endpoint { + CService service; //!< platform HTTPS (gRPC-Web gateway) addr:port + uint256 pro_tx_hash{}; + + friend bool operator==(const Endpoint& lhs, const Endpoint& rhs) + { + return lhs.service == rhs.service && lhs.pro_tx_hash == rhs.pro_tx_hash; + } +}; + +template +struct Result { + std::optional value; + std::string error; + ResponseMetadata metadata; + + bool ok() const { return value.has_value(); } +}; + +//! Abstract asynchronous Dash Platform (DAPI) client. +//! +//! Implementations own their I/O threads; callbacks fire on client threads +//! and consumers must marshal to their own thread (the Qt layer uses +//! QMetaObject::invokeMethod). Every query is issued with prove=true and the +//! response is verified (GroveDB proof replay to the root hash, then the +//! Tenderdash quorum signature against the quorum keys supplied via +//! updateQuorumKeys) before the callback sees it; unverifiable responses +//! surface as errors and the offending endpoint is rotated out. +class PlatformClient +{ +public: + template + using Callback = std::function)>; + + virtual ~PlatformClient() = default; + + //! Resolve an exact normalized label under a parent domain ("dash"). + //! Absence (name available) yields ok() with std::nullopt-like empty + //! optional inside the vector-free overloads below; here: value with + //! std::optional empty means proven absent. + virtual void resolveName(const std::string& normalized_label, Callback> cb) = 0; + + //! Prefix search over normalizedLabel (startsWith), limited. + virtual void searchNames(const std::string& prefix, uint32_t limit, Callback> cb) = 0; + + //! Reverse lookup: all names whose records.identity == identity. + virtual void namesOfIdentity(const Identifier& identity, Callback> cb) = 0; + + virtual void getIdentity(const Identifier& id, Callback> cb) = 0; + virtual void getIdentityByPublicKeyHash(const std::array& pubkey_hash, Callback> cb) = 0; + virtual void getIdentityNonce(const Identifier& id, Callback cb) = 0; + virtual void getIdentityContractNonce(const Identifier& id, const Identifier& contract_id, Callback cb) = 0; + + virtual void getProfile(const Identifier& owner_id, Callback> cb) = 0; + //! Contact requests sent to (to_me=true) or by (to_me=false) the given + //! identity, created at or after since_ms (0 = all). + virtual void getContactRequests(const Identifier& identity, bool to_me, uint64_t since_ms, Callback> cb) = 0; + + virtual void getContestedNameState(const std::string& normalized_label, Callback cb) = 0; + + //! Broadcast a serialized state transition. The result's error channel is + //! unverified; confirm success with a proved re-query of the created + //! object (waitForStateTransitionResult is used internally to surface + //! consensus errors quickly). + virtual void broadcastStateTransition(const std::vector& state_transition, Callback cb) = 0; + + //! Node-local trust/context injection. + virtual void updateEndpoints(std::vector endpoints) = 0; + virtual void updateQuorumKeys(uint8_t llmq_type, std::vector keys) = 0; + //! The node's best locally verified core ChainLock height. Used as a + //! coarse staleness floor so an on-path attacker cannot replay a much + //! older (but validly signed) platform state. 0 means "unknown" and + //! disables the floor. + virtual void updateCoreChainLockedHeight(int32_t height) = 0; + + //! Stop all I/O and drop pending callbacks (must be called before the + //! consumer is destroyed). + virtual void shutdown() = 0; +}; + +//! Create the production gRPC-Web/TLS client (implemented in +//! platform/transport/, Phase 7). +std::unique_ptr MakeGrpcWebPlatformClient(const Params& params); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_CLIENT_H diff --git a/src/platform/dpp/document.cpp b/src/platform/dpp/document.cpp new file mode 100644 index 000000000000..6116013fdd6c --- /dev/null +++ b/src/platform/dpp/document.cpp @@ -0,0 +1,98 @@ +// 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 + +namespace platform::dpp { + +namespace { + +rust::Slice ToSlice(Span data) +{ + return {data.data(), data.size()}; +} + +bool CopyId(const rust::Vec& bytes, Identifier& out) +{ + if (bytes.size() != out.size()) return false; + std::copy(bytes.begin(), bytes.end(), out.begin()); + return true; +} + +} // namespace + +bool DecodeDpnsDomain(Span doc, DpnsName& out) +{ + try { + const platform_ffi::FfiDpnsName decoded{platform_ffi::decode_dpns_domain(ToSlice(doc))}; + DpnsName name; + name.label = std::string{decoded.label}; + name.normalized_label = std::string{decoded.normalized_label}; + name.parent_domain = std::string{decoded.parent_domain}; + if (!CopyId(decoded.identity, name.identity) || !CopyId(decoded.document_id, name.document_id)) { + return false; + } + out = std::move(name); + return true; + } catch (const rust::Error&) { + return false; + } +} + +bool DecodeDashPayProfile(Span doc, Profile& out) +{ + try { + const platform_ffi::FfiProfile decoded{platform_ffi::decode_dashpay_profile(ToSlice(doc))}; + Profile profile; + if (!CopyId(decoded.document_id, profile.document_id) || !CopyId(decoded.owner_id, profile.owner_id)) { + return false; + } + profile.display_name = std::string{decoded.display_name}; + profile.public_message = std::string{decoded.public_message}; + profile.avatar_url = std::string{decoded.avatar_url}; + profile.avatar_hash.assign(decoded.avatar_hash.begin(), decoded.avatar_hash.end()); + profile.avatar_fingerprint.assign(decoded.avatar_fingerprint.begin(), + decoded.avatar_fingerprint.end()); + profile.created_at = decoded.created_at; + profile.updated_at = decoded.updated_at; + profile.revision = decoded.revision; + out = std::move(profile); + return true; + } catch (const rust::Error&) { + return false; + } +} + +bool DecodeDashPayContactRequest(Span doc, ContactRequest& out) +{ + try { + const platform_ffi::FfiContactRequest decoded{ + platform_ffi::decode_contact_request(ToSlice(doc))}; + ContactRequest contact; + if (!CopyId(decoded.owner_id, contact.owner_id) || + !CopyId(decoded.to_user_id, contact.to_user_id) || + !CopyId(decoded.document_id, contact.document_id)) { + return false; + } + contact.encrypted_public_key.assign(decoded.encrypted_public_key.begin(), + decoded.encrypted_public_key.end()); + contact.sender_key_index = decoded.sender_key_index; + contact.recipient_key_index = decoded.recipient_key_index; + contact.account_reference = decoded.account_reference; + contact.encrypted_account_label.assign(decoded.encrypted_account_label.begin(), + decoded.encrypted_account_label.end()); + contact.core_height_created_at = decoded.core_height_created_at; + contact.created_at = decoded.created_at; + out = std::move(contact); + return true; + } catch (const rust::Error&) { + return false; + } +} + +} // namespace platform::dpp diff --git a/src/platform/dpp/document.h b/src/platform/dpp/document.h new file mode 100644 index 000000000000..703eab8adcba --- /dev/null +++ b/src/platform/dpp/document.h @@ -0,0 +1,32 @@ +// 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_DPP_DOCUMENT_H +#define BITCOIN_PLATFORM_DPP_DOCUMENT_H + +#include +#include + +#include + +/** + * Decoding of stored (platform-serialized) DPNS and DashPay documents into + * the GUI types. Thin adapters over the Platform-owned CXX bindings, which + * deserialize with the real rs-dpp against the pinned system data contracts. + */ +namespace platform::dpp { + +//! Decode the GUI-relevant fields of a DPNS `domain` document. Returns false +//! on malformed input. +bool DecodeDpnsDomain(Span doc, DpnsName& out); + +//! Decode the GUI-relevant fields of a DashPay `profile` document. +bool DecodeDashPayProfile(Span doc, Profile& out); + +//! Decode a DashPay `contactRequest` document. +bool DecodeDashPayContactRequest(Span doc, ContactRequest& out); + +} // namespace platform::dpp + +#endif // BITCOIN_PLATFORM_DPP_DOCUMENT_H diff --git a/src/platform/dpp/identity.cpp b/src/platform/dpp/identity.cpp new file mode 100644 index 000000000000..c47d7be6725f --- /dev/null +++ b/src/platform/dpp/identity.cpp @@ -0,0 +1,66 @@ +// 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 + +namespace platform::dpp { + +namespace { + +platform::IdentityPublicKey KeyFromFfi(const platform_ffi::FfiIdentityKey& key) +{ + platform::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.assign(key.data.begin(), key.data.end()); + out.disabled_at = key.has_disabled_at ? std::optional{key.disabled_at} : std::nullopt; + return out; +} + +} // namespace + +std::optional DecodeIdentity(Span bytes, std::string& error) +{ + try { + const platform_ffi::FfiIdentity decoded{ + platform_ffi::decode_identity(rust::Slice{bytes.data(), bytes.size()})}; + if (decoded.id.size() != std::tuple_size_v) { + error = "bridge returned a malformed identity id"; + return std::nullopt; + } + platform::Identity identity; + std::copy(decoded.id.begin(), decoded.id.end(), identity.id.begin()); + identity.balance = decoded.balance; + identity.revision = decoded.revision; + identity.public_keys.reserve(decoded.keys.size()); + for (const platform_ffi::FfiIdentityKey& key : decoded.keys) { + identity.public_keys.push_back(KeyFromFfi(key)); + } + return identity; + } catch (const rust::Error& e) { + error = e.what(); + return std::nullopt; + } +} + +std::optional DecodeIdentityPublicKey(Span bytes, + std::string& error) +{ + try { + return KeyFromFfi(platform_ffi::decode_identity_public_key( + rust::Slice{bytes.data(), bytes.size()})); + } catch (const rust::Error& e) { + error = e.what(); + return std::nullopt; + } +} + +} // namespace platform::dpp diff --git a/src/platform/dpp/identity.h b/src/platform/dpp/identity.h new file mode 100644 index 000000000000..794c91ea848c --- /dev/null +++ b/src/platform/dpp/identity.h @@ -0,0 +1,32 @@ +// 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_DPP_IDENTITY_H +#define BITCOIN_PLATFORM_DPP_IDENTITY_H + +#include +#include + +#include +#include + +/** + * Decoding of platform-serialized Identity / IdentityPublicKey objects into + * the platform::Identity / platform::IdentityPublicKey GUI types. Thin + * adapters over the Platform-owned CXX bindings, which deserialize with the + * real rs-dpp. + */ +namespace platform::dpp { + +//! Decodes a platform-serialized Identity. Returns std::nullopt and sets +//! error on malformed input. +std::optional DecodeIdentity(Span bytes, std::string& error); + +//! Decodes a standalone platform-serialized IdentityPublicKey. +std::optional DecodeIdentityPublicKey(Span bytes, + std::string& error); + +} // namespace platform::dpp + +#endif // BITCOIN_PLATFORM_DPP_IDENTITY_H diff --git a/src/platform/dpp/statetransitions.cpp b/src/platform/dpp/statetransitions.cpp new file mode 100644 index 000000000000..aabafc8bab73 --- /dev/null +++ b/src/platform/dpp/statetransitions.cpp @@ -0,0 +1,335 @@ +// 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 + +/** + * DPP state transition construction and signing, delegated to the + * Platform-owned CXX bindings, which build and serialize with the real + * rs-dpp. Signing crosses the FFI as a digest + * callback (platform_ffi::WalletSigner) so private keys never leave the + * wallet: Rust hands back the key id being signed plus the double-SHA256 + * digest of the transition's signable bytes and expects a 65-byte compact + * recoverable ECDSA signature. + * + * The DPNS helpers (label normalization, salted domain hashes, the contested + * rule) and the deterministic document entropy remain implemented here; the + * builders validate/reproduce them against rs-dpp on the Rust side. + */ +namespace platform::st { + +namespace { + +uint256 DoubleSha(Span data) +{ + uint256 out; + CHash256().Write(data).Finalize(out); + return out; +} + +std::array ToArray(const uint256& hash) +{ + std::array out; + std::copy(hash.begin(), hash.end(), out.begin()); + return out; +} + +//! Deterministic document entropy for DashPay documents: +//! DSHA256(owner_id || document_type_name || identity_contract_nonce LE64). +//! rs-dpp leaves entropy to the caller (the Rust SDK draws it at random); +//! deriving it from the (identity, contract nonce) pair keeps rebuilds of +//! the same transition byte-identical, so a GUI retry cannot register a +//! second document. DPNS documents use flow-specific entropy instead +//! (preorder: salted domain hash; domain: preorder salt). +std::array DeriveEntropy(const Identifier& owner, + const std::string& document_type_name, + uint64_t identity_contract_nonce) +{ + CHash256 hasher; + hasher.Write(owner); + hasher.Write(MakeUCharSpan(document_type_name)); + uint8_t nonce_le[8]; + WriteLE64(nonce_le, identity_contract_nonce); + hasher.Write(nonce_le); + uint256 hash; + hasher.Finalize(hash); + return ToArray(hash); +} + +rust::Slice ToSlice(Span data) +{ + return {data.data(), data.size()}; +} + +//! Wraps a single st::Signer as the bridge's keyed signer; the key id is +//! already pinned on the C++ side (signature_public_key_id), so it is +//! ignored here. +platform_ffi::WalletSigner SingleKeySigner(const Signer& signer) +{ + return platform_ffi::WalletSigner{ + [&signer](uint32_t /*key_id*/, const std::array& digest, + std::vector& sig_out) { + return signer(uint256{digest}, sig_out); + }}; +} + +//! The identity key the GUI signs document transitions with: its HIGH-level +//! ECDSA authentication key. Only the metadata reaches the bridge (rs-dpp +//! checks purpose and security level and records the key id in the +//! transition); the public key itself stays in the wallet with its private +//! counterpart. +platform_ffi::FfiIdentityKey HighAuthKey(uint32_t signature_public_key_id) +{ + platform_ffi::FfiIdentityKey key{}; + key.id = signature_public_key_id; + key.purpose = static_cast(IdentityPublicKey::Purpose::AUTHENTICATION); + key.security_level = static_cast(IdentityPublicKey::SecurityLevel::HIGH); + key.key_type = static_cast(IdentityPublicKey::Type::ECDSA_SECP256K1); + key.read_only = false; + key.has_disabled_at = false; + key.disabled_at = 0; + return key; +} + +Result FromFfi(const platform_ffi::FfiBuiltTransition& built) +{ + BuiltTransition out; + out.bytes.assign(built.bytes.begin(), built.bytes.end()); + if (built.hash.size() != out.hash.size()) { + return {std::nullopt, "bridge returned a malformed transition hash"}; + } + std::copy(built.hash.begin(), built.hash.end(), out.hash.begin()); + return {std::move(out), ""}; +} + +} // namespace + +Identifier IdentityIdFromOutpoint(const std::array& out_point) +{ + return ToArray(DoubleSha(out_point)); +} + +Result BuildIdentityCreate( + const std::variant& proof, + const std::vector& keys, + const Signer& asset_lock_signer) +{ + if (keys.empty()) return {std::nullopt, "no identity keys provided"}; + if (!asset_lock_signer) return {std::nullopt, "no asset lock signer provided"}; + + rust::Vec ffi_keys; + ffi_keys.reserve(keys.size()); + for (const NewIdentityKey& key : keys) { + if (!key.signer) return {std::nullopt, strprintf("identity key %d: no signer", key.id)}; + platform_ffi::FfiNewIdentityKey ffi_key{}; + ffi_key.id = key.id; + ffi_key.purpose = static_cast(key.purpose); + ffi_key.security_level = static_cast(key.security_level); + ffi_key.pubkey.reserve(key.pubkey.size()); + for (const uint8_t byte : key.pubkey) ffi_key.pubkey.push_back(byte); + ffi_keys.push_back(std::move(ffi_key)); + } + + // Route each per-key signature request to the matching registered key's + // signer; the sentinel ASSET_LOCK_KEY_ID selects the one-time asset-lock + // key. Every key signs the same signable-bytes digest. + const platform_ffi::WalletSigner signer{ + [&keys, &asset_lock_signer](uint32_t key_id, const std::array& digest, + std::vector& sig_out) { + const uint256 digest256{digest}; + if (key_id == platform_ffi::WalletSigner::ASSET_LOCK_KEY_ID) { + return asset_lock_signer(digest256, sig_out); + } + for (const NewIdentityKey& key : keys) { + if (key.id == key_id) return key.signer(digest256, sig_out); + } + return false; + }}; + + const bool is_instant{std::holds_alternative(proof)}; + Span transaction, instant_lock; + uint32_t output_index{0}, core_chain_locked_height{0}; + Span out_point; + if (is_instant) { + const auto& instant{std::get(proof)}; + transaction = instant.transaction; + instant_lock = instant.instant_lock; + output_index = instant.output_index; + } else { + const auto& chain{std::get(proof)}; + core_chain_locked_height = chain.core_chain_locked_height; + out_point = chain.out_point; + } + + try { + return FromFfi(platform_ffi::st_build_identity_create( + is_instant, ToSlice(transaction), ToSlice(instant_lock), output_index, + core_chain_locked_height, ToSlice(out_point), std::move(ffi_keys), signer)); + } catch (const rust::Error& e) { + return {std::nullopt, e.what()}; + } +} + +Result BuildDpnsPreorder( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::string& label, + const std::array& preorder_salt, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (!signer) return {std::nullopt, "no signer provided"}; + const platform_ffi::WalletSigner ffi_signer{SingleKeySigner(signer)}; + try { + // The preorder document has no natural id source; the bridge derives + // the salted domain hash — already blinded and unique per + // (name, salt) — and doubles it as the document entropy. + return FromFfi(platform_ffi::st_build_dpns_preorder( + ToSlice(identity), identity_contract_nonce, label, ToSlice(preorder_salt), + signature_public_key_id, HighAuthKey(signature_public_key_id), ffi_signer)); + } catch (const rust::Error& e) { + return {std::nullopt, e.what()}; + } +} + +Result BuildDpnsDomain( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::string& label, + const std::string& normalized_label, + const std::string& parent_domain, + const std::array& preorder_salt, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (!signer) return {std::nullopt, "no signer provided"}; + const platform_ffi::WalletSigner ffi_signer{SingleKeySigner(signer)}; + try { + // The preorder salt is drawn fresh per registration attempt; the + // bridge doubles it as the deterministic document entropy for the + // paired domain create. + return FromFfi(platform_ffi::st_build_dpns_domain( + ToSlice(identity), identity_contract_nonce, label, normalized_label, parent_domain, + ToSlice(preorder_salt), signature_public_key_id, + HighAuthKey(signature_public_key_id), ffi_signer)); + } catch (const rust::Error& e) { + return {std::nullopt, e.what()}; + } +} + +Result BuildProfile( + const Identifier& identity, + uint64_t identity_contract_nonce, + const Profile& profile, + uint64_t revision, + const std::optional& existing_document_id, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (!signer) return {std::nullopt, "no signer provided"}; + const platform_ffi::WalletSigner ffi_signer{SingleKeySigner(signer)}; + const std::array entropy{DeriveEntropy(identity, "profile", identity_contract_nonce)}; + const Identifier existing{existing_document_id.value_or(Identifier{})}; + try { + return FromFfi(platform_ffi::st_build_profile( + ToSlice(identity), identity_contract_nonce, profile.display_name, + profile.public_message, profile.avatar_url, ToSlice(profile.avatar_hash), + ToSlice(profile.avatar_fingerprint), revision, existing_document_id.has_value(), + ToSlice(existing), ToSlice(entropy), signature_public_key_id, + HighAuthKey(signature_public_key_id), ffi_signer)); + } catch (const rust::Error& e) { + return {std::nullopt, e.what()}; + } +} + +Result BuildContactRequest( + const Identifier& identity, + uint64_t identity_contract_nonce, + const ContactRequest& request, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (!signer) return {std::nullopt, "no signer provided"}; + const platform_ffi::WalletSigner ffi_signer{SingleKeySigner(signer)}; + const std::array entropy{ + DeriveEntropy(identity, "contactRequest", identity_contract_nonce)}; + try { + // $createdAt and $createdAtCoreBlockHeight are chain-assigned system + // fields, so ContactRequest::created_at and ::core_height_created_at + // do not enter the transition. + return FromFfi(platform_ffi::st_build_contact_request( + ToSlice(identity), identity_contract_nonce, ToSlice(request.to_user_id), + ToSlice(request.encrypted_public_key), request.sender_key_index, + request.recipient_key_index, request.account_reference, + ToSlice(request.encrypted_account_label), ToSlice(entropy), signature_public_key_id, + HighAuthKey(signature_public_key_id), ffi_signer)); + } catch (const rust::Error& e) { + return {std::nullopt, e.what()}; + } +} + +std::array SaltedDomainHash( + const std::array& salt, + const std::string& normalized_label, + const std::string& parent_domain) +{ + // DSHA256(salt || normalized_label || "." || parent_domain), per the + // Rust SDK DPNS registration flow + // (packages/rs-sdk/src/platform/dpns_usernames/mod.rs register_dpns_name). + const std::string full_name{normalized_label + "." + parent_domain}; + CHash256 hasher; + hasher.Write(salt); + hasher.Write(MakeUCharSpan(full_name)); + uint256 hash; + hasher.Finalize(hash); + return ToArray(hash); +} + +std::string NormalizeLabel(const std::string& label) +{ + // Port of rs-dpp/src/util/strings.rs convert_to_homograph_safe_chars: + // lower-case, then o->0 and l/i->1. DPNS labels are ASCII-only by + // contract pattern, so ASCII lower-casing matches Rust's to_lowercase(). + std::string normalized; + normalized.reserve(label.size()); + for (char c : label) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + switch (c) { + case 'o': normalized.push_back('0'); break; + case 'l': + case 'i': normalized.push_back('1'); break; + default: normalized.push_back(c); + } + } + return normalized; +} + +bool IsContestedLabel(const std::string& normalized_label) +{ + // DPNS domain contested index rule: the parentNameAndLabel index is + // contested when normalizedLabel matches ^[a-zA-Z01-]{3,19}$ + // (packages/dpns-contract/schema/v1/dpns-contract-documents.json, + // indices[0].contested.fieldMatches). Note digits 2-9 make a name + // non-contested and normalized labels are already lower-case. + if (normalized_label.size() < 3 || normalized_label.size() > 19) return false; + for (char c : normalized_label) { + const bool allowed{(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + c == '0' || c == '1' || c == '-'}; + if (!allowed) return false; + } + return true; +} + +} // namespace platform::st diff --git a/src/platform/drive/queries.cpp b/src/platform/drive/queries.cpp new file mode 100644 index 000000000000..358d63608baf --- /dev/null +++ b/src/platform/drive/queries.cpp @@ -0,0 +1,216 @@ +// 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 + +namespace platform::drive { +namespace { + +rust::Slice ToSlice(Span data) +{ + return {data.data(), data.size()}; +} + +//! Copies the authenticated metadata fields out of the bridge result. +ResponseMetadata MetaFromFfi(const platform_ffi::FfiMeta& meta) +{ + ResponseMetadata out; + out.height = meta.height; + out.core_chain_locked_height = meta.core_chain_locked_height; + out.time_ms = meta.time_ms; + out.protocol_version = meta.protocol_version; + out.chain_id = std::string{meta.chain_id}; + return out; +} + +bool IdFromFfi(const rust::Vec& bytes, Identifier& id_out, std::string& error) +{ + if (bytes.size() != id_out.size()) { + error = "bridge returned a malformed identifier"; + return false; + } + std::copy(bytes.begin(), bytes.end(), id_out.begin()); + return true; +} + +IdentityPublicKey KeyFromFfi(const platform_ffi::FfiIdentityKey& 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.assign(key.data.begin(), key.data.end()); + out.disabled_at = key.has_disabled_at ? std::optional{key.disabled_at} : std::nullopt; + return out; +} + +//! Runs one bridge call, converting the rust::Error it throws on a failed or +//! malformed proof into the bool + error-string convention used here. +template +bool Bridged(std::string& error, const Fn& fn) +{ + try { + return fn(); + } catch (const std::exception& e) { + // rust::Error from the bridge, but also cxx marshalling throws such + // as rust::String rejecting invalid UTF-8. + error = e.what(); + return false; + } +} + +bool IdentityFromFfi(const platform_ffi::FfiVerifiedIdentity& result, + std::optional& identity_out, ResponseMetadata& meta_out, + std::string& error) +{ + meta_out = MetaFromFfi(result.meta); + if (!result.present) { + identity_out = std::nullopt; // proven absent + return true; + } + Identity identity; + if (!IdFromFfi(result.identity.id, identity.id, error)) return false; + identity.balance = result.identity.balance; + identity.revision = result.identity.revision; + identity.public_keys.reserve(result.identity.keys.size()); + for (const platform_ffi::FfiIdentityKey& key : result.identity.keys) { + identity.public_keys.push_back(KeyFromFfi(key)); + } + identity_out = std::move(identity); + return true; +} + +} // namespace + +bool SetContext(const std::string& network_id, uint32_t platform_activation_height, + std::string& error) +{ + return Bridged(error, [&] { + platform_ffi::set_context(network_id, platform_activation_height); + return true; + }); +} + +bool UpdateQuorumKeys(uint8_t llmq_type, const std::vector& keys, + std::string& error) +{ + return Bridged(error, [&] { + rust::Vec ffi_keys; + ffi_keys.reserve(keys.size()); + for (const BridgeQuorumKey& key : keys) { + platform_ffi::FfiQuorumKey ffi_key; + ffi_key.quorum_hash.reserve(key.proof_hash.size()); + for (const uint8_t byte : key.proof_hash) ffi_key.quorum_hash.push_back(byte); + ffi_key.pubkey.reserve(key.pubkey.size()); + for (const uint8_t byte : key.pubkey) ffi_key.pubkey.push_back(byte); + ffi_keys.push_back(std::move(ffi_key)); + } + platform_ffi::update_quorum_keys(llmq_type, std::move(ffi_keys)); + return true; + }); +} + +bool VerifyGetIdentityNonce(Span request, Span response, + std::optional& nonce_out, ResponseMetadata& meta_out, + std::string& error) +{ + return Bridged(error, [&] { + const platform_ffi::FfiVerifiedU64 result{ + platform_ffi::verify_get_identity_nonce(ToSlice(request), ToSlice(response))}; + nonce_out = result.present ? std::optional{result.value} : std::nullopt; + meta_out = MetaFromFfi(result.meta); + return true; + }); +} + +bool VerifyGetIdentityContractNonce(Span request, Span response, + std::optional& nonce_out, + ResponseMetadata& meta_out, std::string& error) +{ + return Bridged(error, [&] { + const platform_ffi::FfiVerifiedU64 result{platform_ffi::verify_get_identity_contract_nonce( + ToSlice(request), ToSlice(response))}; + nonce_out = result.present ? std::optional{result.value} : std::nullopt; + meta_out = MetaFromFfi(result.meta); + return true; + }); +} + +bool VerifyGetIdentity(Span request, Span response, + std::optional& identity_out, ResponseMetadata& meta_out, + std::string& error) +{ + return Bridged(error, [&] { + return IdentityFromFfi( + platform_ffi::verify_get_identity(ToSlice(request), ToSlice(response)), identity_out, + meta_out, error); + }); +} + +bool VerifyGetIdentityByPubKeyHash(Span request, Span response, + std::optional& identity_out, + ResponseMetadata& meta_out, std::string& error) +{ + return Bridged(error, [&] { + return IdentityFromFfi( + platform_ffi::verify_get_identity_by_pubkey_hash(ToSlice(request), ToSlice(response)), + identity_out, meta_out, error); + }); +} + +bool VerifyGetDocuments(Span request, Span response, + std::vector& documents_out, ResponseMetadata& meta_out, + std::string& error) +{ + return Bridged(error, [&] { + const platform_ffi::FfiVerifiedDocs result{ + platform_ffi::verify_get_documents(ToSlice(request), ToSlice(response))}; + documents_out.clear(); + documents_out.reserve(result.documents.size()); + for (const platform_ffi::FfiBytes& doc : result.documents) { + documents_out.emplace_back(doc.data.begin(), doc.data.end()); + } + meta_out = MetaFromFfi(result.meta); + return true; + }); +} + +bool VerifyGetContestedVoteState(Span request, Span response, + ContestedVoteState& out, ResponseMetadata& meta_out, + std::string& error) +{ + return Bridged(error, [&] { + const platform_ffi::FfiVerifiedContested result{ + platform_ffi::verify_get_contested_vote_state(ToSlice(request), ToSlice(response))}; + meta_out = MetaFromFfi(result.meta); + + out = ContestedVoteState{}; + out.contest_found = result.contest_found; + out.contenders.reserve(result.contenders.size()); + for (const platform_ffi::FfiContender& contender : result.contenders) { + Identifier id{}; + if (!IdFromFfi(contender.identity, id, error)) return false; + out.contenders.emplace_back(id, contender.has_votes ? contender.votes : 0); + } + if (result.has_abstain) out.abstain_votes = result.abstain_votes; + if (result.has_lock) out.lock_votes = result.lock_votes; + out.finished = result.finished; + out.locked = result.locked; + if (result.has_winner) { + Identifier winner{}; + if (!IdFromFfi(result.winner, winner, error)) return false; + out.winner = winner; + } + out.finished_at_time_ms = result.finished_at_time_ms; + return true; + }); +} + +} // namespace platform::drive diff --git a/src/platform/drive/queries.h b/src/platform/drive/queries.h new file mode 100644 index 000000000000..e533d7401ed3 --- /dev/null +++ b/src/platform/drive/queries.h @@ -0,0 +1,112 @@ +// 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_DRIVE_QUERIES_H +#define BITCOIN_PLATFORM_DRIVE_QUERIES_H + +#include +#include + +#include +#include +#include +#include +#include + +//! Proved-response verification for the DAPI queries the dash-qt Platform +//! GUI makes. Each adapter hands the exact protobuf request the transport +//! sent plus the full protobuf response it received to the Platform-owned CXX +//! bindings, where drive-proof-verifier's +//! FromProof reconstructs the query from the request, replays the GroveDB +//! proof, and verifies the Tenderdash BLS quorum threshold signature against +//! the quorum keys previously pushed via UpdateQuorumKeys. On success, +//! `meta_out` carries the signature-authenticated ResponseMetadata fields +//! (height, core-chain-locked height, time, chain id) the caller feeds to +//! the freshness tracker. +namespace platform::drive { + +using platform::Identifier; +using platform::Identity; +using platform::ResponseMetadata; + +//! Pushes the network context into the bridge's verification provider: +//! the chain name ("main", "test", "regtest", "devnet") selects the dpp +//! network, and `platform_activation_height` is the core height Platform +//! activated at (0 = unknown; only consulted by query paths the GUI does +//! not use). Must be called before any Verify* adapter. +bool SetContext(const std::string& network_id, uint32_t platform_activation_height, + std::string& error); + +//! A quorum BLS public key in the byte order DAPI proofs carry the quorum +//! hash (display order — the reverse of Dash Core's internal uint256 byte +//! order). +struct BridgeQuorumKey { + std::array proof_hash{}; + std::vector pubkey; //!< 48-byte basic-scheme BLS public key +}; + +//! Replaces the bridge-side key set of `llmq_type` with `keys` (the client +//! pushes the full active Platform-LLMQ set on every update). +bool UpdateQuorumKeys(uint8_t llmq_type, const std::vector& keys, + std::string& error); + +//! getIdentityNonce / getIdentityContractNonce. `nonce_out` is std::nullopt +//! when the nonce is cryptographically proven absent. +bool VerifyGetIdentityNonce(Span request, Span response, + std::optional& nonce_out, ResponseMetadata& meta_out, + std::string& error); +bool VerifyGetIdentityContractNonce(Span request, Span response, + std::optional& nonce_out, + ResponseMetadata& meta_out, std::string& error); + +//! getIdentity: one proof covering balance, revision and keys +//! (Drive::verify_full_identity_by_identity_id). `identity_out` is +//! std::nullopt when the identity is proven absent. +bool VerifyGetIdentity(Span request, Span response, + std::optional& identity_out, ResponseMetadata& meta_out, + std::string& error); + +//! getIdentityByPublicKeyHash: one proof resolving the unique key hash to +//! the full identity. `identity_out` is std::nullopt when no identity has +//! registered that key hash. +bool VerifyGetIdentityByPubKeyHash(Span request, Span response, + std::optional& identity_out, + ResponseMetadata& meta_out, std::string& error); + +//! getDocuments (DPNS domain / DashPay profile & contactRequest). The Drive +//! query — including cryptographic absence — is reconstructed from the +//! request's contract id, document type, where/order-by clauses and limit; +//! the verified documents come back platform-serialized (the input format +//! of the dpp::Decode* helpers). An empty vector means proven "no matches". +bool VerifyGetDocuments(Span request, Span response, + std::vector& documents_out, ResponseMetadata& meta_out, + std::string& error); + +//! Decoded contested-resource vote state +//! (getContestedResourceVoteState, result type VoteTally with locked and +//! abstaining tallies). +struct ContestedVoteState { + //! False when the contest is proven absent (no active or finished + //! contest for the resource). + bool contest_found{false}; + std::vector> contenders; //!< identity -> votes + std::optional abstain_votes; + std::optional lock_votes; + //! True once the poll's stored info reports awarded or locked; the + //! tallies and contenders then come from the final vote event. + bool finished{false}; + bool locked{false}; //!< finished with the name locked + std::optional winner; //!< finished and awarded to this identity + uint64_t finished_at_time_ms{0}; +}; + +//! getContestedResourceVoteState: the poll (contract, document type, index +//! values, tally options, count) is reconstructed from the request. +bool VerifyGetContestedVoteState(Span request, Span response, + ContestedVoteState& out, ResponseMetadata& meta_out, + std::string& error); + +} // namespace platform::drive + +#endif // BITCOIN_PLATFORM_DRIVE_QUERIES_H diff --git a/src/platform/params.cpp b/src/platform/params.cpp new file mode 100644 index 000000000000..2fd866f92a38 --- /dev/null +++ b/src/platform/params.cpp @@ -0,0 +1,42 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +namespace platform { + +std::optional GetParams(const std::string& network_id) +{ + // Tenderdash chain ids from dashpay/platform + // packages/dashmate/configs/defaults/get{Mainnet,Testnet}ConfigFactory.js. + // The testnet chain id changes when testnet Platform is reset; it is kept + // overridable through the GUI-only -platformchainid argument (see + // qt/platform/). + if (network_id == CBaseChainParams::MAIN) { + return Params{ + .network_id = network_id, + .tenderdash_chain_id = "evo1", + // 0.01 DASH; matches the DashPay mobile wallet default for an + // uncontested username registration. Contested (premium) names + // require additional prefunded balance, handled by the GUI flow. + .default_identity_funding_amount = 1000000, + .contested_identity_funding_amount = 25000000, + }; + } + if (network_id == CBaseChainParams::TESTNET) { + return Params{ + .network_id = network_id, + .tenderdash_chain_id = "dash-testnet-51", + .default_identity_funding_amount = 1000000, + .contested_identity_funding_amount = 25000000, + }; + } + // Platform is not deployed on this network (or, for devnets, the GUI + // requires explicit -platformchainid configuration). + return std::nullopt; +} + +} // namespace platform diff --git a/src/platform/params.h b/src/platform/params.h new file mode 100644 index 000000000000..136e939cdccd --- /dev/null +++ b/src/platform/params.h @@ -0,0 +1,68 @@ +// 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_PARAMS_H +#define BITCOIN_PLATFORM_PARAMS_H + +#include +#include +#include +#include + +namespace platform { + +using Identifier = std::array; + +//! Well-known system data contract identifiers. These are protocol constants +//! created at Platform genesis and identical on every network. +//! Source: dashpay/platform packages/dpns-contract/src/lib.rs and +//! packages/dashpay-contract/src/lib.rs (ID_BYTES). +inline constexpr Identifier DPNS_CONTRACT_ID{ + 230, 104, 198, 89, 175, 102, 174, 225, 231, 44, 24, 109, 222, 123, 91, 126, + 10, 29, 113, 42, 9, 196, 13, 87, 33, 246, 34, 191, 83, 197, 49, 85}; + +inline constexpr Identifier DASHPAY_CONTRACT_ID{ + 162, 161, 180, 172, 111, 239, 34, 234, 42, 26, 104, 232, 18, 54, 68, 179, + 87, 135, 95, 107, 65, 44, 24, 16, 146, 129, 193, 70, 231, 178, 113, 188}; + +//! Document id of the pre-registered "dash" top level domain DPNS document. +//! Source: packages/dpns-contract/src/lib.rs DPNS_DASH_TLD_DOCUMENT_ID. +inline constexpr Identifier DPNS_DASH_TLD_DOCUMENT_ID{ + 215, 242, 197, 63, 70, 169, 23, 171, 110, 91, 57, 162, 215, 188, 38, 11, + 100, 146, 137, 69, 55, 68, 209, 224, 212, 242, 106, 141, 142, 255, 55, 207}; + +//! Preorder salt of the "dash" TLD document. +//! Source: packages/dpns-contract/src/lib.rs DPNS_DASH_TLD_PREORDER_SALT. +inline constexpr Identifier DPNS_DASH_TLD_PREORDER_SALT{ + 224, 181, 8, 197, 163, 104, 37, 162, 6, 105, 58, 31, 65, 74, 161, 62, + 219, 236, 244, 60, 65, 227, 199, 153, 234, 158, 115, 123, 79, 154, 162, 38}; + +//! Per-network Platform parameters for networks where Platform is deployed. +struct Params { + //! Chain name as in CBaseChainParams ("main", "test", ...). + std::string network_id; + //! Tenderdash chain id, part of the quorum signature preimage + //! (CanonicalVote.chain_id). The LLMQ type used by Platform to sign + //! state roots is not duplicated here; read it from + //! Consensus::Params::llmqTypePlatform. + std::string tenderdash_chain_id; + //! Default amount (in duffs) locked to fund a new identity when + //! registering a username. Matches the DashPay mobile wallet defaults. + int64_t default_identity_funding_amount{0}; + //! Funding used for contested names. Includes the 0.2 DASH protocol vote + //! reserve plus headroom for identity/domain transition fees. + int64_t contested_identity_funding_amount{0}; + + //! Minimum credit conversion: 1 duff == 1000 platform credits. + static constexpr int64_t CREDITS_PER_DUFF{1000}; +}; + +//! Returns the Platform parameters for the given chain name +//! (CBaseChainParams::MAIN etc.), or std::nullopt if Platform is not +//! deployed on that network (in which case the GUI feature is disabled). +std::optional GetParams(const std::string& network_id); + +} // namespace platform + +#endif // BITCOIN_PLATFORM_PARAMS_H diff --git a/src/platform/statetransitions.h b/src/platform/statetransitions.h new file mode 100644 index 000000000000..d22d7cf4bc12 --- /dev/null +++ b/src/platform/statetransitions.h @@ -0,0 +1,143 @@ +// 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_STATETRANSITIONS_H +#define BITCOIN_PLATFORM_STATETRANSITIONS_H + +#include +#include + +#include +#include +#include +#include +#include +#include + +/** + * Construction and signing of DPP state transitions (bincode + * platform-serialization, pinned to a single Platform protocol version). + * Mirrors dashpay/platform packages/rs-dpp state_transition types; every + * builder returns the exact bytes to hand to + * PlatformClient::broadcastStateTransition. + * + * Signing is delegated through callbacks so private keys stay in the wallet + * (interfaces::Wallet::signPlatformDigest): the callback receives the + * double-SHA256 digest of the transition's signable bytes and must return a + * compact/recoverable ECDSA signature (65 bytes). + */ +namespace platform::st { + +//! Signs a 32-byte digest, returning a 65-byte compact recoverable ECDSA +//! signature. Returns false on failure (e.g. locked wallet). +using Signer = std::function& sig_out)>; + +template +struct Result { + std::optional value; + std::string error; + bool ok() const { return value.has_value(); } +}; + +//! Asset lock proof for identity create/top-up. +struct InstantAssetLockProof { + std::vector transaction; //!< serialized asset lock tx + std::vector instant_lock; //!< serialized islock message + uint32_t output_index{0}; //!< index of the OP_RETURN output in tx.vout +}; +struct ChainAssetLockProof { + uint32_t core_chain_locked_height{0}; + std::array out_point{}; //!< txid (big-endian) || index (LE u32) +}; + +//! Identity public key to register with a new identity. +struct NewIdentityKey { + uint32_t id{0}; + IdentityPublicKey::Purpose purpose{IdentityPublicKey::Purpose::AUTHENTICATION}; + IdentityPublicKey::SecurityLevel security_level{IdentityPublicKey::SecurityLevel::MASTER}; + std::vector pubkey; //!< compressed secp256k1 public key (33B) + //! Signs with the corresponding private key (each registered key must + //! prove ownership by signing the transition's signable bytes). + Signer signer; +}; + +struct BuiltTransition { + std::vector bytes; //!< serialized signed state transition + uint256 hash; //!< sha256(bytes) — wait handle for waitForStateTransitionResult +}; + +//! Compute the identity id for an asset lock outpoint +//! (DSHA256 of the 36-byte outpoint), as InstantAssetLockProof::createIdentifier. +Identifier IdentityIdFromOutpoint(const std::array& out_point); + +//! IdentityCreateTransition: registers public keys funded by the asset lock; +//! signed by the asset-lock one-time private key (asset_lock_signer). +Result BuildIdentityCreate( + const std::variant& proof, + const std::vector& keys, + const Signer& asset_lock_signer); + +//! DPNS preorder document create (documents batch transition), signed by the +//! identity HIGH-level authentication key. The salted domain hash +//! (DSHA256(salt || normalized_label || ".dash")) is derived on the Rust +//! side by the upstream DPNS document builder. +Result BuildDpnsPreorder( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::string& label, + const std::array& preorder_salt, + uint32_t signature_public_key_id, + const Signer& signer); + +//! DPNS domain document create. entropy is the 32-byte document entropy used +//! for the document id. preorder_salt must match the earlier preorder: +//! salted_domain_hash = DSHA256(salt || normalized_label || "." || parent). +Result BuildDpnsDomain( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::string& label, + const std::string& normalized_label, + const std::string& parent_domain, //!< "dash" + const std::array& preorder_salt, + uint32_t signature_public_key_id, + const Signer& signer); + +//! DashPay profile create (revision 1) or replace (revision > 1). +Result BuildProfile( + const Identifier& identity, + uint64_t identity_contract_nonce, + const Profile& profile, + uint64_t revision, + const std::optional& existing_document_id, //!< set for replace + uint32_t signature_public_key_id, + const Signer& signer); + +//! DashPay contactRequest create. +Result BuildContactRequest( + const Identifier& identity, + uint64_t identity_contract_nonce, + const ContactRequest& request, + uint32_t signature_public_key_id, + const Signer& signer); + +//! Compute the salted domain hash for a DPNS (pre)order: +//! DSHA256(salt || ) per +//! packages/rs-dpp DPNS logic (verify the exact concatenation against the +//! Rust implementation when implementing). +std::array SaltedDomainHash( + const std::array& salt, + const std::string& normalized_label, + const std::string& parent_domain); + +//! Homograph-safe normalization for DPNS labels (o/O->0, i/I/l/L->1, +//! lower-case) per platform convertToHomographSafeChars. +std::string NormalizeLabel(const std::string& label); + +//! True if the normalized label is contested (masternode vote) per the DPNS +//! v1 contract: length 3..19 and matches [a-hj-km-np-z0-1-]+ style rules. +bool IsContestedLabel(const std::string& normalized_label); + +} // namespace platform::st + +#endif // BITCOIN_PLATFORM_STATETRANSITIONS_H diff --git a/src/platform/transport/cbor.h b/src/platform/transport/cbor.h new file mode 100644 index 000000000000..e2e07b0980a1 --- /dev/null +++ b/src/platform/transport/cbor.h @@ -0,0 +1,67 @@ +// 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_TRANSPORT_CBOR_H +#define BITCOIN_PLATFORM_TRANSPORT_CBOR_H + +#include +#include +#include +#include + +/** + * Minimal CBOR writer — just enough to encode the `where` and `order_by` + * operands of a DAPI getDocuments v0 request, which are CBOR-encoded byte + * fields (arrays of [field, operator, value] / [field, direction] tuples). + * See dashpay/platform packages/dapi getDocuments handler (v0 CBOR path). + */ +namespace platform::transport::cbor { + +class Writer +{ +public: + void Array(size_t n) { WriteHead(4, n); } + void Uint(uint64_t v) { WriteHead(0, v); } + void Text(const std::string& s) + { + WriteHead(3, s.size()); + m_out.insert(m_out.end(), s.begin(), s.end()); + } + void Bytes(Span b) + { + WriteHead(2, b.size()); + m_out.insert(m_out.end(), b.begin(), b.end()); + } + void Bool(bool b) { m_out.push_back(b ? 0xf5 : 0xf4); } + + const std::vector& data() const { return m_out; } + std::vector take() { return std::move(m_out); } + +private: + void WriteHead(uint8_t major, uint64_t value) + { + const uint8_t mt = static_cast(major << 5); + if (value < 24) { + m_out.push_back(mt | static_cast(value)); + } else if (value <= 0xff) { + m_out.push_back(mt | 24); + m_out.push_back(static_cast(value)); + } else if (value <= 0xffff) { + m_out.push_back(mt | 25); + m_out.push_back(static_cast(value >> 8)); + m_out.push_back(static_cast(value)); + } else if (value <= 0xffffffff) { + m_out.push_back(mt | 26); + for (int i = 3; i >= 0; --i) m_out.push_back(static_cast(value >> (8 * i))); + } else { + m_out.push_back(mt | 27); + for (int i = 7; i >= 0; --i) m_out.push_back(static_cast(value >> (8 * i))); + } + } + std::vector m_out; +}; + +} // namespace platform::transport::cbor + +#endif // BITCOIN_PLATFORM_TRANSPORT_CBOR_H diff --git a/src/platform/transport/client.cpp b/src/platform/transport/client.cpp new file mode 100644 index 000000000000..5676e50ded65 --- /dev/null +++ b/src/platform/transport/client.cpp @@ -0,0 +1,633 @@ +// 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 + +namespace platform { + +namespace { + +constexpr char SVC[] = "/org.dash.platform.dapi.v0.Platform/"; +constexpr int CALL_TIMEOUT_MS = 20000; +//! Bound on how many distinct endpoints a single logical operation retries +//! before giving up, so a persistently failing query terminates. +constexpr size_t MAX_OP_ATTEMPTS{4}; + +pb::Writer VersionWrap(pb::Writer inner) +{ + pb::Writer w; + w.Message(1, inner.take()); + return w; +} + +class GrpcWebClient final : public PlatformClient +{ +public: + explicit GrpcWebClient(Params params) : m_params(std::move(params)) + { + // Push the network context into the bridge's verification provider. + // The Platform activation height is only consulted by FromProof + // paths the GUI does not use, so 0 ("unknown") is passed until a + // caller needs it. + std::string err; + if (!drive::SetContext(m_params.network_id, /*platform_activation_height=*/0, err)) { + LogPrintf("Platform client: unable to set verification context: %s\n", err); + } + m_worker = std::thread([this] { Run(); }); + } + ~GrpcWebClient() override { shutdown(); } + + void shutdown() override + { + if (m_stop.exchange(true)) return; + { + std::lock_guard lk(m_mtx); + m_queue.clear(); + } + m_cv.notify_all(); + if (m_worker.joinable()) m_worker.join(); + } + + void updateEndpoints(std::vector endpoints) override + { + std::lock_guard lk(m_mtx); + if (m_endpoints == endpoints) return; + m_endpoints = std::move(endpoints); + m_ep_index = m_endpoints.empty() ? 0 : m_ep_index % m_endpoints.size(); + } + void updateQuorumKeys(uint8_t llmq_type, std::vector keys) override + { + // Quorum-signature verification happens inside the Rust bridge; hand + // the keys over in the byte order DAPI proofs carry the quorum hash + // (display order, the reverse of uint256's internal order — the + // representation boundary QuorumKey::matchesProofHash documents). + std::vector bridge_keys; + bridge_keys.reserve(keys.size()); + for (const QuorumKey& key : keys) { + drive::BridgeQuorumKey bridge_key; + for (size_t i = 0; i < bridge_key.proof_hash.size(); ++i) { + bridge_key.proof_hash[i] = key.quorum_hash.begin()[bridge_key.proof_hash.size() - 1 - i]; + } + bridge_key.pubkey = key.pubkey; + bridge_keys.push_back(std::move(bridge_key)); + } + std::string err; + if (!drive::UpdateQuorumKeys(llmq_type, bridge_keys, err)) { + LogPrintf("Platform client: unable to update quorum keys: %s\n", err); + } + } + void updateCoreChainLockedHeight(int32_t height) override + { + std::lock_guard lk(m_mtx); + m_freshness.SetLocalChainLockHeight(height); + } + + // Queries — each enqueues a task on the worker thread. + void resolveName(const std::string& normalized_label, Callback> cb) override + { + Enqueue([=, this] { DoResolveName(normalized_label, cb); }); + } + void searchNames(const std::string& prefix, uint32_t limit, Callback> cb) override + { + Enqueue([=, this] { DoSearchNames(prefix, limit, cb); }); + } + void namesOfIdentity(const Identifier& identity, Callback> cb) override + { + Enqueue([=, this] { DoNamesOfIdentity(identity, cb); }); + } + void getIdentity(const Identifier& id, Callback> cb) override + { + Enqueue([=, this] { DoGetIdentity(id, cb); }); + } + void getIdentityByPublicKeyHash(const std::array& h, Callback> cb) override + { + Enqueue([=, this] { DoGetIdentityByPubKeyHash(h, cb); }); + } + void getIdentityNonce(const Identifier& id, Callback cb) override + { + Enqueue([=, this] { DoGetNonce(id, std::nullopt, cb); }); + } + void getIdentityContractNonce(const Identifier& id, const Identifier& contract, Callback cb) override + { + Enqueue([=, this] { DoGetNonce(id, contract, cb); }); + } + void getProfile(const Identifier& owner_id, Callback> cb) override + { + Enqueue([=, this] { DoGetProfile(owner_id, cb); }); + } + void getContactRequests(const Identifier& identity, bool to_me, uint64_t since_ms, + Callback> cb) override + { + Enqueue([=, this] { DoGetContactRequests(identity, to_me, since_ms, cb); }); + } + void getContestedNameState(const std::string& normalized_label, Callback cb) override + { + Enqueue([=, this] { DoGetContestedNameState(normalized_label, cb); }); + } + void broadcastStateTransition(const std::vector& st, Callback cb) override + { + Enqueue([=, this] { DoBroadcast(st, cb); }); + } + +private: + // ---- worker plumbing ---- + void Enqueue(std::function task) + { + { + std::lock_guard lk(m_mtx); + if (m_stop) return; + m_queue.push_back(std::move(task)); + } + m_cv.notify_one(); + } + void Run() + { + for (;;) { + std::function task; + { + std::unique_lock lk(m_mtx); + m_cv.wait(lk, [this] { return m_stop || !m_queue.empty(); }); + if (m_stop) return; + task = std::move(m_queue.front()); + m_queue.pop_front(); + } + task(); + } + } + + //! Stable identity of an endpoint for per-endpoint freshness tracking: + //! its deterministic-masternode proTxHash when known, else its address. + static std::string EndpointKey(const Endpoint& ep) + { + if (!ep.pro_tx_hash.IsNull()) return ep.pro_tx_hash.ToString(); + return ep.service.ToStringAddrPort(); + } + + //! Snapshot the current endpoint set and advance the round-robin cursor + //! once per logical operation, so successive operations start at + //! different nodes but a single operation can pin one node. + std::vector SnapshotEndpoints(size_t& start) + { + std::lock_guard lk(m_mtx); + start = m_ep_index++; + return m_endpoints; + } + + //! Round-robin pick of a single endpoint (advancing the cursor). + std::optional NextEndpoint() + { + std::lock_guard lk(m_mtx); + if (m_endpoints.empty()) return std::nullopt; + const Endpoint ep = m_endpoints[m_ep_index % m_endpoints.size()]; + ++m_ep_index; + return ep; + } + + //! Issue a single unary call to one specific endpoint (no rotation). + transport::GrpcCallResult CallOn(const Endpoint& ep, const std::string& method, + const std::vector& req) + { + if (m_stop) { + return { + .transport_ok = false, + .grpc_status = -1, + .grpc_message = {}, + .message = {}, + .transport_error = "Platform request interrupted", + }; + } + return transport::GrpcWebUnary(ep.service.ToStringAddr(), ep.service.GetPort(), std::string(SVC) + method, req, + CALL_TIMEOUT_MS, [this] { return m_stop.load(); }); + } + + //! Post-verification checks on the signature-authenticated metadata of a + //! proved response: the tenderdash chain id must be this network's (the + //! quorum signature covers it, so a cross-chain replay cannot forge it), + //! and the response must be fresh (transport::FreshnessTracker) so an + //! on-path attacker (TLS is unauthenticated by design) cannot feed a + //! stale-but-validly-signed response. `endpoint_key` identifies the + //! answering node so a lagging honest node is not rejected merely + //! because a different node was ahead. + bool AcceptMeta(const ResponseMetadata& meta, const std::string& endpoint_key, std::string& err) + { + if (meta.chain_id != m_params.tenderdash_chain_id) { + err = "response signed for tenderdash chain '" + meta.chain_id + "', expected '" + + m_params.tenderdash_chain_id + "'"; + return false; + } + std::lock_guard lk(m_mtx); + return m_freshness.Accept(endpoint_key, meta.height, meta.core_chain_locked_height, err); + } + + //! Rotating single-shot call with transport-level failover across + //! endpoints. On success, reports the answering endpoint's key (for + //! per-endpoint freshness) via `endpoint_key` when non-null. Suitable for + //! operations that make exactly one proved request; multi-proof + //! operations pin an endpoint via RetryAcrossEndpoints + CallOn instead. + transport::GrpcCallResult Call(const std::string& method, const std::vector& req, + std::string& transport_err, std::string* endpoint_key = nullptr) + { + const size_t n = [&] { std::lock_guard lk(m_mtx); return std::max(1, m_endpoints.size()); }(); + transport::GrpcCallResult last; + for (size_t i = 0; i < n; ++i) { + if (m_stop) { + transport_err = "Platform request interrupted"; + return last; + } + const auto ep = NextEndpoint(); + if (!ep) { transport_err = "no evonode endpoints available"; return {}; } + last = CallOn(*ep, method, req); + if (last.transport_ok) { + if (endpoint_key != nullptr) *endpoint_key = EndpointKey(*ep); + return last; + } + transport_err = last.transport_error; + } + return last; + } + + // ---- operations ---- + void DoBroadcast(const std::vector& st, const Callback& cb) + { + pb::Writer w; + w.Bytes(1, st); + std::string terr; + auto r = Call("broadcastStateTransition", w.data(), terr); + Result out; + if (!r.transport_ok) { out.error = terr; cb(out); return; } + BroadcastResult br; + br.accepted = (r.grpc_status == 0); + if (!br.accepted) { br.error = r.grpc_message; br.error_code = static_cast(r.grpc_status); } + out.value = br; + cb(out); + } + + void DoGetNonce(const Identifier& id, std::optional contract, const Callback& cb) + { + pb::Writer v0; + v0.Bytes(1, std::vector(id.begin(), id.end())); + std::string method; + if (contract) { + v0.Bytes(2, std::vector(contract->begin(), contract->end())); + v0.Bool(3, true); + method = "getIdentityContractNonce"; + } else { + v0.Bool(2, true); + method = "getIdentityNonce"; + } + const std::vector request{VersionWrap(std::move(v0)).take()}; + std::string terr, endpoint_key; + auto r = Call(method, request, terr, &endpoint_key); + Result out; + if (!r.transport_ok || r.grpc_status != 0) { out.error = r.transport_ok ? r.grpc_message : terr; cb(out); return; } + std::optional nonce; + ResponseMetadata meta; + std::string err; + const bool verified = contract + ? drive::VerifyGetIdentityContractNonce(request, r.message, nonce, meta, err) + : drive::VerifyGetIdentityNonce(request, r.message, nonce, meta, err); + if (!verified || !AcceptMeta(meta, endpoint_key, err)) { + out.error = method + ": proof verification failed: " + err; cb(out); return; + } + out.value = nonce.value_or(0); + out.metadata = std::move(meta); + cb(out); + } + + void DoGetIdentity(const Identifier& id, const Callback>& cb) + { + // Proof-verified full identity: a single getIdentity request whose + // proof covers balance, revision and keys at one height + // (Drive::verify_full_identity_by_identity_id inside the bridge). A + // response that fails verification retries against another endpoint. + Result> out; + + size_t start{0}; + const std::vector endpoints{SnapshotEndpoints(start)}; + if (endpoints.empty()) { out.error = "no evonode endpoints available"; cb(out); return; } + + pb::Writer v0; + v0.Bytes(1, std::vector(id.begin(), id.end())); + v0.Bool(2, true); // prove + const std::vector request{VersionWrap(std::move(v0)).take()}; + + bool succeeded{false}; + transport::RetryAcrossEndpoints(endpoints, start, MAX_OP_ATTEMPTS, + [&](const Endpoint& ep, size_t) -> transport::AttemptStatus { + auto r = CallOn(ep, "getIdentity", request); + if (!r.transport_ok || r.grpc_status != 0) { + out.error = r.transport_ok ? r.grpc_message : r.transport_error; + return transport::AttemptStatus::Retry; + } + std::optional identity; + ResponseMetadata meta; + std::string err; + if (!drive::VerifyGetIdentity(request, r.message, identity, meta, err) || + !AcceptMeta(meta, EndpointKey(ep), err)) { + out.error = "identity proof verification failed: " + err; + return transport::AttemptStatus::Retry; + } + out.value = std::move(identity); + out.metadata = std::move(meta); + out.error.clear(); + succeeded = true; + return transport::AttemptStatus::Success; + }); + + if (!succeeded && out.error.empty()) out.error = "identity query failed against all endpoints"; + cb(out); + } + + void DoGetIdentityByPubKeyHash(const std::array& h, const Callback>& cb) + { + // One proof resolves the unique key hash to the full identity + // (Drive::verify_full_identity_by_unique_public_key_hash inside the + // bridge); no follow-up getIdentity round trip is needed. + pb::Writer v0; + v0.Bytes(1, std::vector(h.begin(), h.end())); + v0.Bool(2, true); + const std::vector request{VersionWrap(std::move(v0)).take()}; + std::string terr, endpoint_key; + auto r = Call("getIdentityByPublicKeyHash", request, terr, &endpoint_key); + Result> out; + if (!r.transport_ok || r.grpc_status != 0) { out.error = r.transport_ok ? r.grpc_message : terr; cb(out); return; } + std::optional identity; + ResponseMetadata meta; + std::string err; + if (!drive::VerifyGetIdentityByPubKeyHash(request, r.message, identity, meta, err) || + !AcceptMeta(meta, endpoint_key, err)) { + out.error = err; cb(out); return; + } + out.value = std::move(identity); + out.metadata = std::move(meta); + cb(out); + } + + // Document queries (DPNS domain / DashPay profile & contactRequest). The + // server returns only a GroveDB proof; the bridge reconstructs the + // requested Drive query from the request bytes — including cryptographic + // absence — and binds the proven root to a locally-known Platform LLMQ + // key before document bytes escape. + std::vector> GetDocuments(const Identifier& contract, const std::string& doc_type, + const std::vector& where_cbor, uint32_t limit, + std::string& err, + const std::vector& order_by_cbor = {}) + { + pb::Writer v0; + v0.Bytes(1, std::vector(contract.begin(), contract.end())); + v0.Str(2, doc_type); + if (!where_cbor.empty()) v0.Bytes(3, where_cbor); + if (!order_by_cbor.empty()) v0.Bytes(4, order_by_cbor); + if (limit) v0.Varint(5, limit); + v0.Bool(8, true); // prove + const std::vector request{VersionWrap(std::move(v0)).take()}; + std::string terr, endpoint_key; + auto r = Call("getDocuments", request, terr, &endpoint_key); + std::vector> docs; + if (!r.transport_ok || r.grpc_status != 0) { + err = r.transport_ok ? r.grpc_message : terr; + LogPrintf("Platform getDocuments(%s) failed: %s\n", doc_type, err); + return docs; + } + ResponseMetadata meta; + if (!drive::VerifyGetDocuments(request, r.message, docs, meta, err)) { + LogPrintf("Platform getDocuments(%s) proof verification failed: %s\n", doc_type, err); + return {}; + } + if (!AcceptMeta(meta, endpoint_key, err)) { + LogPrintf("Platform getDocuments(%s) metadata rejected: %s\n", doc_type, err); + return {}; + } + return docs; + } + + //! drive-abci decodes each getContestedResourceVoteState index value as a + //! bincode (standard, big-endian) platform Value; strings are + //! Value::Text = declaration-order discriminant 18 + length + utf8 + //! (rs-platform-value src/lib.rs, rs-drive-abci + //! src/query/voting/contested_resource_vote_state/v0/mod.rs). + static std::vector BincodeTextValue(const std::string& text) + { + std::vector out; + out.push_back(18); + // bincode varint: single byte below 251; DPNS labels are <= 63 chars. + if (text.size() >= 251) return {}; + out.push_back(static_cast(text.size())); + out.insert(out.end(), text.begin(), text.end()); + return out; + } + + //! Mirrors drive-abci's default_query_limit for requests that pin `count` + //! so the locally reconstructed PathQuery matches the prover's exactly. + static constexpr uint16_t CONTESTED_VOTE_COUNT{100}; + + void DoGetContestedNameState(const std::string& normalized_label, const Callback& cb) + { + // getContestedResourceVoteState on the DPNS contested + // parentNameAndLabel index, VoteTally result type with locked and + // abstaining tallies (platform.proto + // GetContestedResourceVoteStateRequestV0). + pb::Writer v0; + v0.Bytes(1, std::vector(DPNS_CONTRACT_ID.begin(), DPNS_CONTRACT_ID.end())); + v0.Str(2, "domain"); + v0.Str(3, "parentNameAndLabel"); + v0.Bytes(4, BincodeTextValue("dash")); + v0.Bytes(4, BincodeTextValue(normalized_label)); + v0.Varint(5, 1); // ResultType::VOTE_TALLY + v0.Bool(6, true); // allow_include_locked_and_abstaining_vote_tally + v0.Varint(8, CONTESTED_VOTE_COUNT); + v0.Bool(9, true); // prove + const std::vector request{VersionWrap(std::move(v0)).take()}; + std::string terr, endpoint_key; + auto r = Call("getContestedResourceVoteState", request, terr, &endpoint_key); + Result out; + if (!r.transport_ok || r.grpc_status != 0) { + out.error = r.transport_ok ? r.grpc_message : terr; + cb(out); + return; + } + drive::ContestedVoteState state; + ResponseMetadata meta; + std::string err; + if (!drive::VerifyGetContestedVoteState(request, r.message, state, meta, err) || + !AcceptMeta(meta, endpoint_key, err)) { + out.error = "contested vote state proof verification failed: " + err; + cb(out); + return; + } + out.metadata = std::move(meta); + + ContestedNameState result; + result.normalized_label = normalized_label; + result.contenders = std::move(state.contenders); + result.abstain_votes = state.abstain_votes.value_or(0); + result.lock_votes = state.lock_votes.value_or(0); + if (!state.contest_found) { + result.status = ContestedNameState::Status::UNKNOWN; + } else if (!state.finished) { + result.status = ContestedNameState::Status::CONTEST_IN_PROGRESS; + } else if (state.locked) { + result.status = ContestedNameState::Status::LOCKED; + result.ends_at = state.finished_at_time_ms; + } else { + result.status = ContestedNameState::Status::WON; + result.winner = state.winner; + result.ends_at = state.finished_at_time_ms; + } + out.value = std::move(result); + cb(out); + } + + static std::vector DpnsWhere(const std::string& normalized_label, bool starts_with) + { + transport::cbor::Writer w; + w.Array(2); + w.Array(3); w.Text("normalizedParentDomainName"); w.Text("=="); w.Text("dash"); + w.Array(3); w.Text("normalizedLabel"); w.Text(starts_with ? "startsWith" : "=="); w.Text(normalized_label); + return w.take(); + } + + static std::vector DpnsPrefixOrderBy() + { + transport::cbor::Writer w; + w.Array(1); + w.Array(2); w.Text("normalizedLabel"); w.Text("asc"); + return w.take(); + } + + void DoResolveName(const std::string& normalized_label, const Callback>& cb) + { + std::string err; + auto docs = GetDocuments(DPNS_CONTRACT_ID, "domain", DpnsWhere(normalized_label, false), 1, err); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + if (docs.empty()) { out.value = std::optional{}; cb(out); return; } // available / absent + DpnsName name; + if (dpp::DecodeDpnsDomain(docs.front(), name)) { out.value = name; } + else { + out.error = "DPNS domain document decoding failed"; + LogPrintf("Platform resolveName(%s): %s (%u bytes)\n", normalized_label, out.error, + docs.front().size()); + } + cb(out); + } + + void DoSearchNames(const std::string& prefix, uint32_t limit, const Callback>& cb) + { + std::string err; + auto docs = GetDocuments(DPNS_CONTRACT_ID, "domain", DpnsWhere(prefix, true), limit, err, + DpnsPrefixOrderBy()); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector names; + for (const auto& d : docs) { + DpnsName n; + if (dpp::DecodeDpnsDomain(d, n)) names.push_back(std::move(n)); + } + out.value = std::move(names); + cb(out); + } + + void DoNamesOfIdentity(const Identifier& identity, const Callback>& cb) + { + transport::cbor::Writer w; + w.Array(1); + w.Array(3); w.Text("records.identity"); w.Text("=="); + w.Bytes(Span{identity.data(), identity.size()}); + std::string err; + constexpr uint32_t limit{100}; + auto docs = GetDocuments(DPNS_CONTRACT_ID, "domain", w.take(), limit, err); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector names; + for (const auto& d : docs) { + DpnsName n; + if (dpp::DecodeDpnsDomain(d, n)) names.push_back(std::move(n)); + } + out.value = std::move(names); + cb(out); + } + + void DoGetProfile(const Identifier& owner_id, const Callback>& cb) + { + transport::cbor::Writer w; + w.Array(1); + w.Array(3); w.Text("$ownerId"); w.Text("=="); w.Bytes(Span{owner_id.data(), owner_id.size()}); + std::string err; + auto docs = GetDocuments(DASHPAY_CONTRACT_ID, "profile", w.take(), 1, err); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + if (docs.empty()) { out.value = std::optional{}; cb(out); return; } + Profile prof; + if (dpp::DecodeDashPayProfile(docs.front(), prof)) out.value = prof; + else out.value = std::optional{}; + cb(out); + } + + void DoGetContactRequests(const Identifier& identity, bool to_me, uint64_t /*since_ms*/, + const Callback>& cb) + { + transport::cbor::Writer w; + w.Array(1); + w.Array(3); + w.Text(to_me ? "toUserId" : "$ownerId"); + w.Text("=="); + w.Bytes(Span{identity.data(), identity.size()}); + transport::cbor::Writer order_by; + order_by.Array(1); + order_by.Array(2); + order_by.Text("$createdAt"); + order_by.Text("asc"); + std::string err; + auto docs = GetDocuments(DASHPAY_CONTRACT_ID, "contactRequest", w.take(), 100, err, + order_by.take()); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector reqs; + for (const auto& d : docs) { + ContactRequest cr; + if (dpp::DecodeDashPayContactRequest(d, cr)) reqs.push_back(std::move(cr)); + } + out.value = std::move(reqs); + cb(out); + } + + Params m_params; + std::thread m_worker; + std::mutex m_mtx; + std::condition_variable m_cv; + std::deque> m_queue; + std::atomic_bool m_stop{false}; + std::vector m_endpoints; + size_t m_ep_index{0}; + //! Per-endpoint replay/staleness guard (guarded by m_mtx). + transport::FreshnessTracker m_freshness; +}; + +} // namespace + +std::unique_ptr MakeGrpcWebPlatformClient(const Params& params) +{ + return std::make_unique(params); +} + +} // namespace platform diff --git a/src/platform/transport/endpoint_retry.h b/src/platform/transport/endpoint_retry.h new file mode 100644 index 000000000000..c4aff20a2be5 --- /dev/null +++ b/src/platform/transport/endpoint_retry.h @@ -0,0 +1,48 @@ +// 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_TRANSPORT_ENDPOINT_RETRY_H +#define BITCOIN_PLATFORM_TRANSPORT_ENDPOINT_RETRY_H + +#include + +#include +#include +#include + +namespace platform::transport { + +enum class AttemptStatus { + Success, //!< the logical operation completed; stop. + Retry, //!< this endpoint could not answer; try the next one. +}; + +//! Runs a whole logical operation against a *single* pinned endpoint at a +//! time, moving to the next endpoint (round-robin from `start`) only when the +//! attempt returns Retry. Since getIdentity collapsed to a single proved +//! request, no in-tree operation issues multiple sub-queries per attempt any +//! more; DoGetIdentity still uses this helper for its retry-on-verification- +//! failure behaviour, and the pinning again matters the moment an operation +//! spans more than one proof. `attempt(endpoint, attempt_index)` performs the +//! full operation against `endpoint`. +//! +//! Returns the number of attempts made (0 when there are no endpoints). At +//! most min(max_attempts, endpoints.size()) distinct endpoints are tried, so +//! a persistently failing query terminates instead of looping. +template +size_t RetryAcrossEndpoints(const std::vector& endpoints, size_t start, + size_t max_attempts, Attempt&& attempt) +{ + if (endpoints.empty() || max_attempts == 0) return 0; + const size_t limit = std::min(max_attempts, endpoints.size()); + for (size_t i = 0; i < limit; ++i) { + const Endpoint& endpoint = endpoints[(start + i) % endpoints.size()]; + if (attempt(endpoint, i) == AttemptStatus::Success) return i + 1; + } + return limit; +} + +} // namespace platform::transport + +#endif // BITCOIN_PLATFORM_TRANSPORT_ENDPOINT_RETRY_H diff --git a/src/platform/transport/freshness.h b/src/platform/transport/freshness.h new file mode 100644 index 000000000000..25e61cab17a4 --- /dev/null +++ b/src/platform/transport/freshness.h @@ -0,0 +1,100 @@ +// 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_TRANSPORT_FRESHNESS_H +#define BITCOIN_PLATFORM_TRANSPORT_FRESHNESS_H + +#include + +#include +#include +#include + +namespace platform::transport { + +//! Bounds how stale a validly signed platform proof may be before the client +//! rejects it. TLS to evonodes is unauthenticated by design (integrity comes +//! from proof + quorum-signature verification), so without a freshness bound +//! an on-path attacker could replay an old but validly signed response. +//! +//! This is a best-effort staleness bound, not an airtight anti-rollback: +//! - The core-ChainLock floor only rejects proofs whose signed +//! core-chain-locked height trails the node's own best ChainLock by more +//! than MAX_CORE_CHAINLOCK_LAG core blocks, so a replay up to that window +//! old is still accepted. On a freshly started client (no per-endpoint +//! history yet) this floor is the only guard. +//! - The per-endpoint height watermark is monotonic *per endpoint*: it stops +//! a single node from rolling its own reported platform height backwards +//! (a replay from that node), while tolerating honest nodes that are a few +//! blocks apart. It is deliberately not a single global maximum, because a +//! global watermark rejects any endpoint lagging the fastest one by even a +//! block — normal propagation delay — turning availability into a failure. +//! +//! The tracker holds no locks; callers that share it across threads must +//! serialize access themselves. +class FreshnessTracker +{ +public: + //! Coarse staleness bound (in core blocks) between a proof's signed + //! core-chain-locked height and the node's own best ChainLock. ~288 + //! blocks is roughly half a day at 2.5 min/block; generous so normal + //! platform lag never trips it. + static constexpr int64_t MAX_CORE_CHAINLOCK_LAG{288}; + + //! Update the node's best locally verified core ChainLock height (the + //! anchor for the staleness floor). Monotonic; 0 means unknown. + void SetLocalChainLockHeight(int32_t height) + { + if (height > m_local_core_chainlocked_height) m_local_core_chainlocked_height = height; + } + + int32_t LocalChainLockHeight() const { return m_local_core_chainlocked_height; } + + //! Returns true if a proof from `endpoint_key` at the given signed + //! platform height / core-chain-locked height is fresh enough to accept, + //! and advances that endpoint's watermark. On rejection returns false and + //! sets `err`. `endpoint_key` identifies the answering evonode (e.g. its + //! proTxHash); distinct keys never interfere so honest cross-node lag is + //! tolerated. + bool Accept(const std::string& endpoint_key, uint64_t platform_height, + uint32_t signed_core_chainlocked_height, std::string& err) + { + // Absolute staleness floor against local chain state. + if (m_local_core_chainlocked_height > 0 && + static_cast(signed_core_chainlocked_height) + MAX_CORE_CHAINLOCK_LAG < + static_cast(m_local_core_chainlocked_height)) { + err = strprintf( + "stale platform proof: signed core chainlock height %u trails the local ChainLock " + "height %d by more than %d blocks", + signed_core_chainlocked_height, m_local_core_chainlocked_height, + MAX_CORE_CHAINLOCK_LAG); + return false; + } + // Per-endpoint monotonic platform height: a node must not roll its own + // reported height backwards. + auto it = m_endpoint_height.find(endpoint_key); + if (it != m_endpoint_height.end() && platform_height < it->second) { + err = strprintf( + "stale platform proof from endpoint %s: signed platform height %llu is below the " + "highest height %llu already verified from that endpoint (rollback/replay)", + endpoint_key, static_cast(platform_height), + static_cast(it->second)); + return false; + } + if (it == m_endpoint_height.end()) { + m_endpoint_height.emplace(endpoint_key, platform_height); + } else if (platform_height > it->second) { + it->second = platform_height; + } + return true; + } + +private: + std::map m_endpoint_height; + int32_t m_local_core_chainlocked_height{0}; +}; + +} // namespace platform::transport + +#endif // BITCOIN_PLATFORM_TRANSPORT_FRESHNESS_H diff --git a/src/platform/transport/grpcweb.cpp b/src/platform/transport/grpcweb.cpp new file mode 100644 index 000000000000..43252b8fdb10 --- /dev/null +++ b/src/platform/transport/grpcweb.cpp @@ -0,0 +1,256 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace platform::transport { + +namespace { + +//! Wrap a protobuf message in a single gRPC-Web data frame. +std::vector FrameMessage(const std::vector& msg) +{ + std::vector frame; + frame.reserve(5 + msg.size()); + frame.push_back(0x00); // data frame, not compressed + const uint32_t len = static_cast(msg.size()); + frame.push_back(static_cast(len >> 24)); + frame.push_back(static_cast(len >> 16)); + frame.push_back(static_cast(len >> 8)); + frame.push_back(static_cast(len)); + frame.insert(frame.end(), msg.begin(), msg.end()); + return frame; +} + +std::string ToLower(std::string s) +{ + // HTTP/gRPC-Web header names are ASCII; use a locale-independent fold so + // header matching cannot vary with the process locale. + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return (c >= 'A' && c <= 'Z') ? c - 'A' + 'a' : c; }); + return s; +} + +std::optional HeaderValue(const std::string& headers, const std::string& name) +{ + const std::string lower_headers{ToLower(headers)}; + const std::string needle{ToLower(name) + ":"}; + size_t pos{lower_headers.find(needle)}; + while (pos != std::string::npos && pos != 0 && lower_headers[pos - 1] != '\n') { + pos = lower_headers.find(needle, pos + 1); + } + if (pos == std::string::npos) return std::nullopt; + size_t start{pos + needle.size()}; + while (start < headers.size() && (headers[start] == ' ' || headers[start] == '\t')) + ++start; + const size_t line_end{headers.find("\r\n", start)}; + return headers.substr(start, line_end == std::string::npos ? std::string::npos : line_end - start); +} + +bool ParseGrpcStatus(const std::string& value, int& status) +{ + const char* first{value.data()}; + const char* last{first + value.size()}; + const auto [ptr, ec] = std::from_chars(first, last, status); + return !value.empty() && ec == std::errc{} && ptr == last && status >= 0 && status <= 16; +} + +bool Dechunk(const std::vector& payload, std::vector& out, std::string& error) +{ + static constexpr uint8_t CRLF[]{'\r', '\n'}; + static constexpr uint8_t HEADER_END[]{'\r', '\n', '\r', '\n'}; + size_t pos{0}; + while (pos < payload.size()) { + const auto line_end{std::search(payload.begin() + pos, payload.end(), std::begin(CRLF), std::end(CRLF))}; + if (line_end == payload.end()) { + error = "malformed chunk size"; + return false; + } + std::string hexlen(payload.begin() + pos, line_end); + if (const size_t ext{hexlen.find(';')}; ext != std::string::npos) hexlen.resize(ext); + size_t chunk_len{0}; + const char* first{hexlen.data()}; + const char* last{first + hexlen.size()}; + const auto [ptr, ec] = std::from_chars(first, last, chunk_len, 16); + if (hexlen.empty() || ec != std::errc{} || ptr != last) { + error = "invalid chunk size"; + return false; + } + pos = static_cast(line_end - payload.begin()) + 2; + if (chunk_len == 0) { + if (payload.size() - pos == 2 && payload[pos] == '\r' && payload[pos + 1] == '\n') return true; + const auto trailer_end{ + std::search(payload.begin() + pos, payload.end(), std::begin(HEADER_END), std::end(HEADER_END))}; + if (trailer_end != payload.end() && trailer_end + std::size(HEADER_END) == payload.end()) return true; + error = "malformed final chunk"; + return false; + } + if (chunk_len > payload.size() - pos) { + error = "truncated chunk"; + return false; + } + out.insert(out.end(), payload.begin() + pos, payload.begin() + pos + chunk_len); + pos += chunk_len; + if (payload.size() - pos < 2 || payload[pos] != '\r' || payload[pos + 1] != '\n') { + error = "missing chunk terminator"; + return false; + } + pos += 2; + } + error = "missing final chunk"; + return false; +} + +} // namespace + +GrpcCallResult ParseGrpcWebResponse(Span response) +{ + GrpcCallResult result; + static constexpr uint8_t HEADER_END[]{'\r', '\n', '\r', '\n'}; + const auto header_end{std::search(response.begin(), response.end(), std::begin(HEADER_END), std::end(HEADER_END))}; + if (header_end == response.end()) { + result.transport_error = "malformed HTTP response"; + return result; + } + const std::string headers(response.begin(), header_end); + std::vector payload(header_end + std::size(HEADER_END), response.end()); + + const size_t eol{headers.find("\r\n")}; + const std::string status_line{headers.substr(0, eol)}; + const size_t code_start{status_line.find(' ')}; + const size_t code_end{code_start == std::string::npos ? std::string::npos : status_line.find(' ', code_start + 1)}; + if (status_line.rfind("HTTP/1.", 0) != 0 || code_start == std::string::npos || + status_line.substr(code_start + 1, code_end - code_start - 1) != "200") { + result.transport_error = "HTTP error: " + status_line; + return result; + } + + if (const auto transfer_encoding{HeaderValue(headers, "transfer-encoding")}; + transfer_encoding && ToLower(*transfer_encoding).find("chunked") != std::string::npos) { + std::vector dechunked; + if (!Dechunk(payload, dechunked, result.transport_error)) return result; + payload = std::move(dechunked); + } + + bool saw_status{false}; + if (const auto status{HeaderValue(headers, "grpc-status")}) { + if (!ParseGrpcStatus(*status, result.grpc_status)) { + result.transport_error = "invalid grpc-status header"; + return result; + } + saw_status = true; + } + if (const auto message{HeaderValue(headers, "grpc-message")}) result.grpc_message = urlDecode(*message); + + size_t pos{0}; + bool saw_data{false}; + bool saw_trailers{false}; + while (pos < payload.size()) { + if (saw_trailers) { + result.transport_error = "data after gRPC-Web trailers"; + return result; + } + if (payload.size() - pos < 5) { + result.transport_error = "truncated gRPC-Web frame header"; + return result; + } + const uint8_t flags{payload[pos]}; + const uint32_t len = (static_cast(payload[pos + 1]) << 24) | + (static_cast(payload[pos + 2]) << 16) | + (static_cast(payload[pos + 3]) << 8) | static_cast(payload[pos + 4]); + pos += 5; + if (len > payload.size() - pos) { + result.transport_error = "truncated gRPC-Web frame"; + return result; + } + const Span frame{payload.data() + pos, len}; + pos += len; + + if (flags == 0x80) { + const std::string trailers(frame.begin(), frame.end()); + const auto status{HeaderValue(trailers, "grpc-status")}; + if (!status || !ParseGrpcStatus(*status, result.grpc_status)) { + result.transport_error = "missing or invalid grpc-status trailer"; + return result; + } + saw_status = true; + saw_trailers = true; + if (const auto message{HeaderValue(trailers, "grpc-message")}) result.grpc_message = urlDecode(*message); + } else if (flags == 0x00) { + if (saw_data) { + result.transport_error = "multiple messages in unary gRPC-Web response"; + return result; + } + result.message.assign(frame.begin(), frame.end()); + saw_data = true; + } else { + result.transport_error = "unsupported gRPC-Web frame flags"; + return result; + } + } + + if (!saw_status) { + result.transport_error = "missing grpc-status"; + return result; + } + if (result.grpc_status == 0 && !saw_data) { + result.transport_error = "missing unary response message"; + return result; + } + result.transport_ok = true; + return result; +} + +GrpcCallResult GrpcWebUnary(const std::string& host, uint16_t port, const std::string& path, + const std::vector& request, int timeout_ms, const std::function& interrupted) +{ + GrpcCallResult result; + + auto conn = TlsConnection::Connect(host, port, timeout_ms, result.transport_error, interrupted); + if (!conn) return result; + + const std::vector body = FrameMessage(request); + + std::string head; + head += "POST " + path + " HTTP/1.1\r\n"; + head += "Host: " + host + "\r\n"; + head += "Content-Type: application/grpc-web+proto\r\n"; + head += "Accept: application/grpc-web+proto\r\n"; + head += "X-Grpc-Web: 1\r\n"; + head += "t" + "e: trailers\r\n"; + head += "Content-Length: " + ToString(body.size()) + "\r\n"; + head += "Connection: close\r\n\r\n"; + + std::vector out(head.begin(), head.end()); + out.insert(out.end(), body.begin(), body.end()); + if (!conn->WriteAll(out, result.transport_error)) return result; + + // Read the full response (Connection: close → read to EOF). + std::vector resp; + std::vector chunk(16384); + for (;;) { + int n = conn->Read(chunk, result.transport_error); + if (n < 0) return result; + if (n == 0) break; + resp.insert(resp.end(), chunk.begin(), chunk.begin() + n); + if (resp.size() > 32u * 1024 * 1024) { // guard + result.transport_error = "response too large"; + return result; + } + } + + return ParseGrpcWebResponse(resp); +} + +} // namespace platform::transport diff --git a/src/platform/transport/grpcweb.h b/src/platform/transport/grpcweb.h new file mode 100644 index 000000000000..1392739a93e0 --- /dev/null +++ b/src/platform/transport/grpcweb.h @@ -0,0 +1,49 @@ +// 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_TRANSPORT_GRPCWEB_H +#define BITCOIN_PLATFORM_TRANSPORT_GRPCWEB_H + +#include +#include +#include +#include +#include + +/** + * A single gRPC-Web unary call over HTTP/1.1 + TLS. + * + * DAPI's Envoy gateway exposes the Platform gRPC service as gRPC-Web + * (application/grpc-web+proto) over HTTP/1.1, which lets a plain TLS client + * speak it without an HTTP/2 stack. Framing: each message is + * [1 byte flags][4 byte big-endian length][payload]; the response body is + * the response message frame followed by a trailers frame (flags bit 0x80) + * carrying grpc-status / grpc-message. + * (dashpay/platform packages/dashmate templates gateway/envoy — grpc_web + * filter.) + */ +namespace platform::transport { + +struct GrpcCallResult { + bool transport_ok{false}; //!< the HTTP/TLS exchange itself succeeded + int grpc_status{-1}; //!< gRPC status code from trailers (0 = OK) + std::string grpc_message; //!< grpc-message trailer (on error) + std::vector message; //!< decoded response protobuf (grpc_status==0) + std::string transport_error; //!< set when transport_ok is false +}; + +//! Parse a complete HTTP/1.1 gRPC-Web response. Exposed for focused tests of +//! the untrusted response framing; callers normally use GrpcWebUnary. +GrpcCallResult ParseGrpcWebResponse(Span response); + +//! Perform a unary gRPC-Web call. `path` is the full gRPC method path, e.g. +//! "/org.dash.platform.dapi.v0.Platform/getIdentity". `request` is the +//! serialized request protobuf. Blocking; intended to run on a worker thread. +GrpcCallResult GrpcWebUnary(const std::string& host, uint16_t port, const std::string& path, + const std::vector& request, int timeout_ms, + const std::function& interrupted = {}); + +} // namespace platform::transport + +#endif // BITCOIN_PLATFORM_TRANSPORT_GRPCWEB_H diff --git a/src/platform/transport/protobuf.cpp b/src/platform/transport/protobuf.cpp new file mode 100644 index 000000000000..038ad55d2f72 --- /dev/null +++ b/src/platform/transport/protobuf.cpp @@ -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. + +#include + +namespace platform::pb { + +void Writer::WriteVarint(uint64_t value) +{ + while (value >= 0x80) { + m_out.push_back(static_cast(value) | 0x80); + value >>= 7; + } + m_out.push_back(static_cast(value)); +} + +void Writer::WriteTag(uint32_t field, WireType wt) +{ + WriteVarint((static_cast(field) << 3) | static_cast(wt)); +} + +void Writer::Varint(uint32_t field, uint64_t value) +{ + WriteTag(field, WireType::Varint); + WriteVarint(value); +} + +void Writer::Bytes(uint32_t field, Span value) +{ + WriteTag(field, WireType::Len); + WriteVarint(value.size()); + m_out.insert(m_out.end(), value.begin(), value.end()); +} + +void Writer::Str(uint32_t field, const std::string& value) +{ + WriteTag(field, WireType::Len); + WriteVarint(value.size()); + m_out.insert(m_out.end(), value.begin(), value.end()); +} + +} // namespace platform::pb diff --git a/src/platform/transport/protobuf.h b/src/platform/transport/protobuf.h new file mode 100644 index 000000000000..c63d9aa637b7 --- /dev/null +++ b/src/platform/transport/protobuf.h @@ -0,0 +1,45 @@ +// 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_TRANSPORT_PROTOBUF_H +#define BITCOIN_PLATFORM_TRANSPORT_PROTOBUF_H + +#include +#include +#include +#include + +/** + * Minimal protobuf wire-format writer for the handful of DAPI Platform + * requests the GUI uses. Field numbers/types come from + * dashpay/platform packages/dapi-grpc/protos/platform/v0/platform.proto. + */ +namespace platform::pb { + +enum class WireType : uint8_t { Varint = 0, I64 = 1, Len = 2, I32 = 5 }; + +class Writer +{ +public: + void Varint(uint32_t field, uint64_t value); + void Bytes(uint32_t field, Span value); + void Bytes(uint32_t field, const std::vector& value) { Bytes(field, Span{value}); } + void Str(uint32_t field, const std::string& value); + void Bool(uint32_t field, bool value) { if (value) Varint(field, 1); } + //! Write a nested message (its already-serialized bytes) as a length- + //! delimited field. + void Message(uint32_t field, const std::vector& msg) { Bytes(field, msg); } + + const std::vector& data() const { return m_out; } + std::vector take() { return std::move(m_out); } + +private: + void WriteTag(uint32_t field, WireType wt); + void WriteVarint(uint64_t value); + std::vector m_out; +}; + +} // namespace platform::pb + +#endif // BITCOIN_PLATFORM_TRANSPORT_PROTOBUF_H diff --git a/src/platform/transport/tls.cpp b/src/platform/transport/tls.cpp new file mode 100644 index 000000000000..51b6990b74c5 --- /dev/null +++ b/src/platform/transport/tls.cpp @@ -0,0 +1,238 @@ +// 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 + +namespace platform::transport { + +struct TlsConnection::Impl { + mbedtls_ssl_context ssl; + mbedtls_ssl_config conf; + mbedtls_ctr_drbg_context ctr_drbg; + mbedtls_entropy_context entropy; + std::unique_ptr sock; + std::chrono::steady_clock::time_point deadline; + std::function interrupted; + bool ssl_ready{false}; + + Impl() + { + mbedtls_ssl_init(&ssl); + mbedtls_ssl_config_init(&conf); + mbedtls_ctr_drbg_init(&ctr_drbg); + mbedtls_entropy_init(&entropy); + } + ~Impl() + { + if (ssl_ready) mbedtls_ssl_close_notify(&ssl); + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&conf); + mbedtls_ctr_drbg_free(&ctr_drbg); + mbedtls_entropy_free(&entropy); + } + + static bool IsTemporarySocketError(int error) + { + return error == WSAEAGAIN || error == WSAEINTR || error == WSAEWOULDBLOCK || error == WSAEINPROGRESS; + } + + static int Send(void* context, const unsigned char* data, size_t size) + { + const Sock& sock{*static_cast(context)}; + const ssize_t ret{sock.Send(data, std::min(size, static_cast(INT_MAX)), MSG_NOSIGNAL)}; + if (ret >= 0) return static_cast(ret); + return IsTemporarySocketError(WSAGetLastError()) ? MBEDTLS_ERR_SSL_WANT_WRITE : MBEDTLS_ERR_NET_SEND_FAILED; + } + + static int Recv(void* context, unsigned char* data, size_t size) + { + const Sock& sock{*static_cast(context)}; + const ssize_t ret{sock.Recv(data, std::min(size, static_cast(INT_MAX)), 0)}; + if (ret >= 0) return static_cast(ret); + return IsTemporarySocketError(WSAGetLastError()) ? MBEDTLS_ERR_SSL_WANT_READ : MBEDTLS_ERR_NET_RECV_FAILED; + } + + bool Wait(Sock::Event requested, std::string& error) const + { + while (true) { + if (!CheckDeadline(error)) return false; + const auto now{std::chrono::steady_clock::now()}; + const auto remaining{std::chrono::duration_cast(deadline - now)}; + const auto wait_time{std::min(remaining, std::chrono::milliseconds{100})}; + Sock::Event occurred{0}; +#ifdef USE_POLL + constexpr SocketEventsMode FALLBACK_MODE{SocketEventsMode::Poll}; +#else + constexpr SocketEventsMode FALLBACK_MODE{SocketEventsMode::Select}; +#endif + const SocketEventsMode mode{g_socket_events_mode == SocketEventsMode::Unknown ? FALLBACK_MODE + : g_socket_events_mode}; + if (!sock->Wait(wait_time, requested, SocketEventsParams{mode}, &occurred)) { + error = "socket wait failed"; + return false; + } + if (occurred & requested) return true; + } + } + + bool WaitForTls(int ret, std::string& error) const + { + return Wait(ret == MBEDTLS_ERR_SSL_WANT_WRITE ? Sock::SEND : Sock::RECV, error); + } + + bool CheckDeadline(std::string& error) const + { + if (interrupted && interrupted()) { + error = "Platform request interrupted"; + return false; + } + if (std::chrono::steady_clock::now() >= deadline) { + error = "Platform request timed out"; + return false; + } + return true; + } +}; + +TlsConnection::TlsConnection() : m_impl(std::make_unique()) {} +TlsConnection::~TlsConnection() = default; + +std::unique_ptr TlsConnection::Connect(const std::string& host, uint16_t port, int timeout_ms, + std::string& error, const std::function& interrupted) +{ + auto conn = std::unique_ptr(new TlsConnection()); + Impl& s = *conn->m_impl; + s.deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds{std::max(timeout_ms, 1)}; + s.interrupted = interrupted; + + const char* pers = "dash-platform-gui"; + if (mbedtls_ctr_drbg_seed(&s.ctr_drbg, mbedtls_entropy_func, &s.entropy, + reinterpret_cast(pers), std::strlen(pers)) != 0) { + error = "ctr_drbg seed failed"; + return nullptr; + } + + const auto service{Lookup(host, port, /*fAllowLookup=*/false)}; + if (!service) { + error = "invalid Platform endpoint " + host; + return nullptr; + } + s.sock = CreateSock(service->GetSAFamily()); + if (!s.sock) { + error = "unable to create Platform socket"; + return nullptr; + } + sockaddr_storage addr; + socklen_t addr_len{sizeof(addr)}; + if (!service->GetSockAddr(reinterpret_cast(&addr), &addr_len)) { + error = "unsupported Platform endpoint"; + return nullptr; + } + if (s.sock->Connect(reinterpret_cast(&addr), addr_len) == SOCKET_ERROR) { + const int connect_error{WSAGetLastError()}; + if ((!Impl::IsTemporarySocketError(connect_error) && connect_error != WSAEINVAL) || + !s.Wait(Sock::RECV | Sock::SEND, error)) { + if (error.empty()) error = "tcp connection failed"; + return nullptr; + } + int socket_error{0}; + socklen_t error_len{sizeof(socket_error)}; + if (s.sock->GetSockOpt(SOL_SOCKET, SO_ERROR, &socket_error, &error_len) == SOCKET_ERROR || socket_error != 0) { + error = "tcp connection failed"; + return nullptr; + } + } + + if (mbedtls_ssl_config_defaults(&s.conf, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, + MBEDTLS_SSL_PRESET_DEFAULT) != 0) { + error = "ssl config defaults failed"; + return nullptr; + } + // See the class comment: transport certs are not chain-verified; response + // integrity is guaranteed by proof + quorum-signature verification. + mbedtls_ssl_conf_authmode(&s.conf, MBEDTLS_SSL_VERIFY_NONE); + mbedtls_ssl_conf_rng(&s.conf, mbedtls_ctr_drbg_random, &s.ctr_drbg); + + if (mbedtls_ssl_setup(&s.ssl, &s.conf) != 0) { + error = "ssl setup failed"; + return nullptr; + } + if (mbedtls_ssl_set_hostname(&s.ssl, host.c_str()) != 0) { + error = "unable to configure TLS server name"; + return nullptr; + } + mbedtls_ssl_set_bio(&s.ssl, s.sock.get(), Impl::Send, Impl::Recv, nullptr); + s.ssl_ready = true; + + int ret; + while ((ret = mbedtls_ssl_handshake(&s.ssl)) != 0) { + if (!s.CheckDeadline(error)) return nullptr; + if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { + if (!s.WaitForTls(ret, error)) return nullptr; + } else { + char buf[128]; + mbedtls_strerror(ret, buf, sizeof(buf)); + error = std::string("tls handshake failed: ") + buf; + return nullptr; + } + } + return conn; +} + +bool TlsConnection::WriteAll(Span data, std::string& error) +{ + size_t written = 0; + while (written < data.size()) { + if (!m_impl->CheckDeadline(error)) return false; + int ret = mbedtls_ssl_write(&m_impl->ssl, data.data() + written, data.size() - written); + if (ret > 0) { + written += static_cast(ret); + continue; + } + if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { + if (!m_impl->WaitForTls(ret, error)) return false; + continue; + } + char buf[128]; + mbedtls_strerror(ret, buf, sizeof(buf)); + error = std::string("tls write failed: ") + buf; + return false; + } + return true; +} + +int TlsConnection::Read(Span buf, std::string& error) +{ + for (;;) { + if (!m_impl->CheckDeadline(error)) return -1; + int ret = mbedtls_ssl_read(&m_impl->ssl, buf.data(), buf.size()); + if (ret >= 0) return ret; + if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { + if (!m_impl->WaitForTls(ret, error)) return -1; + continue; + } + if (ret == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) return 0; + char b[128]; + mbedtls_strerror(ret, b, sizeof(b)); + error = std::string("tls read failed: ") + b; + return -1; + } +} + +} // namespace platform::transport diff --git a/src/platform/transport/tls.h b/src/platform/transport/tls.h new file mode 100644 index 000000000000..73109c8275f8 --- /dev/null +++ b/src/platform/transport/tls.h @@ -0,0 +1,55 @@ +// 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_TRANSPORT_TLS_H +#define BITCOIN_PLATFORM_TRANSPORT_TLS_H + +#include +#include +#include +#include +#include +#include + +/** + * Minimal blocking TLS client over a TCP socket, backed by mbedTLS (3.6 LTS). + * + * Transport-level certificate verification is intentionally NOT enforced: + * evonode DAPI endpoints are reached by bare IP from the deterministic + * masternode list and their certificates do not chain to a public CA. + * Integrity comes entirely from the GroveDB proof + quorum signature + * verification applied to every response, exactly as the reference SDKs + * operate. If Platform later publishes a cert-pinning scheme this is where it + * would be enforced. + */ +namespace platform::transport { + +class TlsConnection +{ +public: + ~TlsConnection(); + + //! Connect to host:port with a timeout. Returns nullptr on failure + //! (error set). + static std::unique_ptr Connect(const std::string& host, uint16_t port, int timeout_ms, + std::string& error, const std::function& interrupted = {}); + + //! Write all bytes. Returns false on error. + bool WriteAll(Span data, std::string& error); + //! Read up to buf.size() bytes; returns count read (>0), 0 on clean EOF, + //! -1 on error. + int Read(Span buf, std::string& error); + + TlsConnection(const TlsConnection&) = delete; + TlsConnection& operator=(const TlsConnection&) = delete; + +private: + TlsConnection(); + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace platform::transport + +#endif // BITCOIN_PLATFORM_TRANSPORT_TLS_H diff --git a/src/platform/types.h b/src/platform/types.h new file mode 100644 index 000000000000..7fc9a39cd836 --- /dev/null +++ b/src/platform/types.h @@ -0,0 +1,119 @@ +// 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 + +namespace platform { + +using Bytes = std::vector; + +//! Identity public key record (DPP IdentityPublicKey, subset the GUI needs). +struct IdentityPublicKey { + enum class Type : uint8_t { ECDSA_SECP256K1 = 0, BLS12_381 = 1, ECDSA_HASH160 = 2, BIP13_SCRIPT_HASH = 3, EDDSA_25519_HASH160 = 4 }; + enum class Purpose : uint8_t { AUTHENTICATION = 0, ENCRYPTION = 1, DECRYPTION = 2, TRANSFER = 3, VOTING = 5 }; + enum class SecurityLevel : uint8_t { MASTER = 0, CRITICAL = 1, HIGH = 2, MEDIUM = 3 }; + + uint32_t id{0}; + Purpose purpose{Purpose::AUTHENTICATION}; + SecurityLevel security_level{SecurityLevel::MASTER}; + Type type{Type::ECDSA_SECP256K1}; + bool read_only{false}; + std::vector data; //!< serialized public key + std::optional disabled_at; +}; + +//! A Platform identity as the GUI sees it. +struct Identity { + Identifier id{}; + uint64_t balance{0}; //!< platform credits + uint64_t revision{0}; + std::vector public_keys; +}; + +//! A resolved DPNS name (domain document subset). +struct DpnsName { + std::string label; //!< as registered, e.g. "Alice" + std::string normalized_label; //!< homograph-safe lower-case, e.g. "al1ce" + std::string parent_domain; //!< normalized parent, e.g. "dash" + Identifier identity{}; //!< records.identity + Identifier document_id{}; +}; + +//! DashPay profile document (all fields optional per schema). +struct Profile { + Identifier document_id{}; + Identifier owner_id{}; + std::string display_name; + std::string public_message; + std::string avatar_url; + std::vector avatar_hash; //!< SHA256 of avatar (32B) if set + std::vector avatar_fingerprint; //!< dHash (8B) if set + uint64_t created_at{0}; //!< ms since epoch + uint64_t updated_at{0}; + uint64_t revision{0}; +}; + +//! DashPay contactRequest document. +struct ContactRequest { + Identifier owner_id{}; //!< sender identity + Identifier to_user_id{}; //!< recipient identity + std::vector encrypted_public_key; //!< 96B: IV(16) || AES-CBC(xpub) + uint32_t sender_key_index{0}; + uint32_t recipient_key_index{0}; + uint32_t account_reference{0}; + std::vector encrypted_account_label; //!< optional, 48-80B + uint32_t core_height_created_at{0}; + uint64_t created_at{0}; //!< ms since epoch + Identifier document_id{}; +}; + +//! Contested-resource (premium username) vote state. Status is +//! contest-global: WON means the contest finished with `winner` awarded the +//! name (callers compare against their own identity); UNKNOWN means no +//! contest exists for the label (proven absent). +struct ContestedNameState { + enum class Status { UNKNOWN, CONTEST_IN_PROGRESS, WON, LOST, LOCKED }; + Status status{Status::UNKNOWN}; + std::string normalized_label; + std::vector> contenders; //!< identity -> votes + uint32_t abstain_votes{0}; + uint32_t lock_votes{0}; + std::optional winner; //!< set when status == WON + std::optional ends_at; //!< ms since epoch (finish time once decided) +}; + +//! Result of broadcasting a state transition. +struct BroadcastResult { + bool accepted{false}; + //! When not accepted: a platform consensus error description. Note this + //! error channel is informational (not proof-backed); success is + //! confirmed separately through a proved re-query of the created object. + std::string error; + uint32_t error_code{0}; +}; + +//! Metadata every proved response is verified against. All fields are +//! covered by the Tenderdash quorum signature (they enter the StateId / +//! CanonicalVote sign bytes), so they are trustworthy once verification +//! succeeded. +struct ResponseMetadata { + uint64_t height{0}; //!< platform block height + uint32_t core_chain_locked_height{0}; + uint64_t time_ms{0}; + uint32_t protocol_version{0}; + std::string chain_id; //!< tenderdash chain id +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_TYPES_H diff --git a/src/platform/walletrecords.cpp b/src/platform/walletrecords.cpp new file mode 100644 index 000000000000..02295b99101c --- /dev/null +++ b/src/platform/walletrecords.cpp @@ -0,0 +1,74 @@ +// 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 platform { + +std::vector SerializeIdentityRecord(const IdentityRecord& r) +{ + CDataStream s(SER_DISK, CLIENT_VERSION); + s << r.version << static_cast(r.state) << r.funding_txid << r.funding_vout + << r.funding_key_index << r.funding_amount + << std::vector(r.identity_id.begin(), r.identity_id.end()) + << r.label << r.normalized_label + << std::vector(r.preorder_salt.begin(), r.preorder_salt.end()) + << r.contested << r.last_error << r.started_at; + const auto span = MakeUCharSpan(s); + return {span.begin(), span.end()}; +} + +bool DeserializeIdentityRecord(const std::vector& data, IdentityRecord& r) +{ + try { + CDataStream s(data, SER_DISK, CLIENT_VERSION); + uint8_t state{0}; + std::vector identity_id, salt; + s >> r.version; + if (r.version == 0 || r.version > IdentityRecord::CURRENT_VERSION) return false; + s >> state >> r.funding_txid >> r.funding_vout >> r.funding_key_index >> r.funding_amount >> + identity_id >> r.label >> r.normalized_label >> salt >> r.contested >> r.last_error >> + r.started_at; + if (identity_id.size() != r.identity_id.size() || salt.size() != r.preorder_salt.size()) return false; + std::copy(identity_id.begin(), identity_id.end(), r.identity_id.begin()); + std::copy(salt.begin(), salt.end(), r.preorder_salt.begin()); + r.state = static_cast(state); + return true; + } catch (const std::exception&) { + return false; + } +} + +std::vector EncodePaymentCursor(uint32_t next_index) +{ + return {static_cast(next_index), static_cast(next_index >> 8), + static_cast(next_index >> 16), static_cast(next_index >> 24)}; +} + +uint32_t DecodePaymentCursor(const std::vector& data) +{ + if (data.size() != 4) return 0; + return uint32_t{data[0]} | (uint32_t{data[1]} << 8) | (uint32_t{data[2]} << 16) | + (uint32_t{data[3]} << 24); +} + +uint32_t ComputePaymentCursor(uint32_t window, + const std::function(uint32_t)>& derive, + const std::set& wallet_output_scripts) +{ + uint32_t cursor{0}; + for (uint32_t index = 0; index < window; ++index) { + const auto script{derive(index)}; + if (!script) break; + if (wallet_output_scripts.count(*script)) cursor = index + 1; + } + return cursor; +} + +} // namespace platform diff --git a/src/platform/walletrecords.h b/src/platform/walletrecords.h new file mode 100644 index 000000000000..f7d67d34ed28 --- /dev/null +++ b/src/platform/walletrecords.h @@ -0,0 +1,88 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_PLATFORM_WALLETRECORDS_H +#define BITCOIN_PLATFORM_WALLETRECORDS_H + +#include +#include +#include