diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d5d97654009..bcc28f8f3a6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,6 +165,17 @@ jobs: base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + depends-linux64_platform_gui: + name: x86_64-pc-linux-gnu_platform_gui + uses: ./.github/workflows/build-depends.yml + needs: [check-skip, container, cache-sources] + if: ${{ vars.SKIP_LINUX64_PLATFORM_GUI == '' }} + with: + build-target: linux64_platform_gui + container-path: ${{ needs.container.outputs.path }} + base-image-digest: ${{ needs.check-skip.outputs.base-image-digest }} + runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }} + depends-mac: name: x86_64-apple-darwin uses: ./.github/workflows/build-depends.yml @@ -263,6 +274,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, lint] + 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 @@ -361,6 +386,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/.gitignore b/.gitignore index eab9f3f83ec1..8ffaccfc73ce 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ compile_commands.json # Linux perf profiling artifacts perf.data perf.data.old +src/qt/platform/moc_*.cpp diff --git a/ci/dash/matrix.sh b/ci/dash/matrix.sh index de01eaf6d6c7..2c1cc12213f6 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..15824a92c460 --- /dev/null +++ b/ci/test/00_setup_env_native_platform_gui.sh @@ -0,0 +1,24 @@ +#!/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 the extra client dependency (mbedtls); the vendored blake3 is +# always compiled into libdash_platform. Functional tests are skipped: the +# feature is exercised by the gated C++ unit tests (platform_proof_tests, +# platform_drive_tests, platform_dpp_tests, platformkeys_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 0b5ab13da6e4..ff49400acfd6 100644 --- a/configure.ac +++ b/configure.ac @@ -301,6 +301,16 @@ 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]) + dnl Enable different -fsanitize options AC_ARG_WITH([sanitizers], [AS_HELP_STRING([--with-sanitizers], @@ -887,6 +897,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 @@ -1966,6 +1986,28 @@ 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 is linked into dash-qt (and +dnl 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 + 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) + AM_CONDITIONAL([TARGET_DARWIN], [test "$TARGET_OS" = "darwin"]) AM_CONDITIONAL([BUILD_DARWIN], [test "$BUILD_OS" = "darwin"]) AM_CONDITIONAL([TARGET_LINUX], [test "$TARGET_OS" = "linux"]) @@ -2106,7 +2148,14 @@ CPPFLAGS_TEMP="$CPPFLAGS" unset CPPFLAGS CPPFLAGS="$CPPFLAGS_TEMP" -ac_configure_args="${ac_configure_args} --disable-shared --with-pic --enable-benchmark=no --enable-module-recovery --disable-module-ecdh --disable-openssl-tests" +dnl The ECDH module is required by the Dash Platform GUI (DashPay contact +dnl request encryption, DIP-15); it is compiled but unused otherwise. +if test "$enable_platform_gui" = "yes"; then + secp_ecdh_arg="--enable-module-ecdh" +else + secp_ecdh_arg="--disable-module-ecdh" +fi +ac_configure_args="${ac_configure_args} --disable-shared --with-pic --enable-benchmark=no --enable-module-recovery ${secp_ecdh_arg} --disable-openssl-tests" AC_CONFIG_SUBDIRS([src/dashbls src/secp256k1]) AC_OUTPUT @@ -2160,6 +2209,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 " gprof enabled = $enable_gprof" echo " werror = $enable_werror" echo diff --git a/contrib/devtools/copyright_header.py b/contrib/devtools/copyright_header.py index effd8633aa10..a834b48c84c8 100755 --- a/contrib/devtools/copyright_header.py +++ b/contrib/devtools/copyright_header.py @@ -36,6 +36,7 @@ EXCLUDE_DIRS = [ # git subtrees "src/crc32c/", + "src/crypto/blake3/", "src/crypto/ctaes/", "src/dashbls/", "src/gsl/", diff --git a/depends/Makefile b/depends/Makefile index c8510f4cc010..b80bc27b0c0b 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,10 @@ packages += $(multiprocess_packages) native_packages += $(multiprocess_native_packages) endif +ifeq ($(platform_packages_),) +packages += $(platform_packages) +endif + all_packages = $(packages) $(native_packages) meta_depends = Makefile config.guess config.sub funcs.mk builders/default.mk hosts/default.mk hosts/$(host_os).mk builders/$(build_os).mk @@ -257,6 +263,7 @@ $(host_prefix)/share/config.site : config.site.in $(host_prefix)/.stamp_$(final_ -e 's|@no_usdt@|$(NO_USDT)|' \ -e 's|@no_natpmp@|$(NO_NATPMP)|' \ -e 's|@multiprocess@|$(MULTIPROCESS)|' \ + -e 's|@platform_gui@|$(PLATFORM_GUI)|' \ -e 's|@lto@|$(LTO)|' \ -e 's|@no_harden@|$(NO_HARDEN)|' \ -e 's|@debug@|$(DEBUG)|' \ diff --git a/depends/config.site.in b/depends/config.site.in index 398a09b74c63..ff54ed3db74b 100644 --- a/depends/config.site.in +++ b/depends/config.site.in @@ -50,6 +50,10 @@ if test -z "$enable_multiprocess" && test -n "@multiprocess@"; then enable_multiprocess=yes fi +if test -z "$enable_platform_gui" && test -n "@platform_gui@"; then + enable_platform_gui=yes +fi + if test -z "$with_miniupnpc" && test -n "@no_upnp@"; then with_miniupnpc=no fi diff --git a/depends/packages/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/packages.mk b/depends/packages/packages.mk index 7e0bb2633219..05371494fc49 100644 --- a/depends/packages/packages.mk +++ b/depends/packages/packages.mk @@ -26,4 +26,6 @@ natpmp_packages=libnatpmp multiprocess_packages = libmultiprocess capnp multiprocess_native_packages = native_libmultiprocess native_capnp +platform_packages = mbedtls + usdt_linux_packages=systemtap diff --git a/src/Makefile.am b/src/Makefile.am index 4b446675291e..fa25be7bccd7 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -58,6 +58,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 @@ -123,7 +126,8 @@ EXTRA_LIBRARIES += \ $(LIBBITCOIN_IPC) \ $(LIBBITCOIN_WALLET) \ $(LIBBITCOIN_WALLET_TOOL) \ - $(LIBBITCOIN_ZMQ) + $(LIBBITCOIN_ZMQ) \ + $(LIBDASH_PLATFORM) if BUILD_BITCOIND bin_PROGRAMS += dashd @@ -468,6 +472,7 @@ BITCOIN_CORE_H = \ wallet/hdchain.h \ wallet/ismine.h \ wallet/load.h \ + wallet/platformkeys.h \ wallet/receive.h \ wallet/rpc/util.h \ wallet/rpc/wallet.h \ @@ -675,6 +680,55 @@ 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) \ + -DBLAKE3_NO_SSE2 -DBLAKE3_NO_SSE41 -DBLAKE3_NO_AVX2 -DBLAKE3_NO_AVX512 -DBLAKE3_USE_NEON=0 +libdash_platform_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) +libdash_platform_a_CFLAGS = $(AM_CFLAGS) $(PIE_FLAGS) +libdash_platform_a_SOURCES = \ + crypto/blake3/blake3.c \ + crypto/blake3/blake3.h \ + crypto/blake3/blake3_dispatch.c \ + crypto/blake3/blake3_impl.h \ + crypto/blake3/blake3_portable.c \ + platform/client.h \ + platform/dpp/bincode.cpp \ + platform/dpp/bincode.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/drive/quorumsig.cpp \ + platform/drive/quorumsig.h \ + platform/drive/verify.cpp \ + platform/drive/verify.h \ + platform/params.cpp \ + platform/params.h \ + platform/proof/grovedb.cpp \ + platform/proof/grovedb.h \ + platform/proof/merk.cpp \ + platform/proof/merk.h \ + platform/serialize.cpp \ + platform/serialize.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 +endif +# + # wallet # libbitcoin_wallet_a_CPPFLAGS = $(AM_CPPFLAGS) $(BITCOIN_INCLUDES) $(BOOST_CPPFLAGS) $(BDB_CPPFLAGS) $(SQLITE_CFLAGS) libbitcoin_wallet_a_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) @@ -719,6 +773,9 @@ endif if USE_BDB libbitcoin_wallet_a_SOURCES += wallet/bdb.cpp wallet/salvage.cpp endif +if ENABLE_PLATFORM_GUI +libbitcoin_wallet_a_SOURCES += wallet/platformkeys.cpp +endif # # wallet tool # diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 16296207eca1..364137fd8fd4 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -114,6 +114,20 @@ QT_MOC_CPP = \ qt/moc_walletmodel.cpp \ qt/moc_walletview.cpp +if ENABLE_PLATFORM_GUI +QT_MOC_CPP += \ + qt/platform/moc_contactflow.cpp \ + qt/platform/moc_contactpickerdialog.cpp \ + qt/platform/moc_contactsmodel.cpp \ + qt/platform/moc_contactspage.cpp \ + qt/platform/moc_createusernamewizard.cpp \ + qt/platform/moc_identityflow.cpp \ + qt/platform/moc_platformpage.cpp \ + qt/platform/moc_platformservice.cpp \ + qt/platform/moc_profiledialog.cpp \ + qt/platform/moc_usernamesearchdialog.cpp +endif + BITCOIN_MM = \ qt/macdockiconhandler.mm \ qt/macnotificationhandler.mm \ @@ -207,6 +221,20 @@ BITCOIN_QT_H = \ qt/walletview.h \ qt/winshutdownmonitor.h +if ENABLE_PLATFORM_GUI +BITCOIN_QT_H += \ + qt/platform/contactflow.h \ + qt/platform/contactpickerdialog.h \ + qt/platform/contactsmodel.h \ + qt/platform/contactspage.h \ + qt/platform/createusernamewizard.h \ + qt/platform/identityflow.h \ + qt/platform/platformpage.h \ + qt/platform/platformservice.h \ + qt/platform/profiledialog.h \ + qt/platform/usernamesearchdialog.h +endif + QT_RES_ICONS = \ qt/res/icons/address-book.png \ qt/res/icons/connect1_16.png \ @@ -331,12 +359,27 @@ BITCOIN_QT_WALLET_CPP = \ qt/walletmodeltransaction.cpp \ qt/walletview.cpp +BITCOIN_QT_PLATFORM_CPP = \ + qt/platform/contactflow.cpp \ + qt/platform/contactpickerdialog.cpp \ + qt/platform/contactsmodel.cpp \ + qt/platform/contactspage.cpp \ + qt/platform/createusernamewizard.cpp \ + qt/platform/identityflow.cpp \ + qt/platform/platformpage.cpp \ + qt/platform/platformservice.cpp \ + qt/platform/profiledialog.cpp \ + qt/platform/usernamesearchdialog.cpp + BITCOIN_QT_CPP = $(BITCOIN_QT_BASE_CPP) if TARGET_WINDOWS BITCOIN_QT_CPP += $(BITCOIN_QT_WINDOWS_CPP) endif if ENABLE_WALLET BITCOIN_QT_CPP += $(BITCOIN_QT_WALLET_CPP) +if ENABLE_PLATFORM_GUI +BITCOIN_QT_CPP += $(BITCOIN_QT_PLATFORM_CPP) +endif # ENABLE_PLATFORM_GUI endif # ENABLE_WALLET QT_RES_IMAGES = \ @@ -461,6 +504,9 @@ endif if ENABLE_ZMQ bitcoin_qt_ldadd += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +bitcoin_qt_ldadd += $(LIBDASH_PLATFORM) $(MBEDTLS_LIBS) +endif bitcoin_qt_ldadd += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) $(LIBMEMENV) \ $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ $(EVENT_PTHREADS_LIBS) $(EVENT_LIBS) $(GMP_LIBS) @@ -492,7 +538,7 @@ $(srcdir)/qt/dashstrings.cpp: FORCE # The resulted dash_en.xlf source file should follow Transifex requirements. # See: https://docs.transifex.com/formats/xliff#how-to-distinguish-between-a-source-file-and-a-translation-file -translate: $(srcdir)/qt/dashstrings.cpp $(QT_FORMS_UI) $(QT_FORMS_UI) $(BITCOIN_QT_BASE_CPP) qt/bitcoin.cpp $(BITCOIN_QT_WINDOWS_CPP) $(BITCOIN_QT_WALLET_CPP) $(BITCOIN_QT_H) $(BITCOIN_MM) +translate: $(srcdir)/qt/dashstrings.cpp $(QT_FORMS_UI) $(QT_FORMS_UI) $(BITCOIN_QT_BASE_CPP) qt/bitcoin.cpp $(BITCOIN_QT_WINDOWS_CPP) $(BITCOIN_QT_WALLET_CPP) $(BITCOIN_QT_PLATFORM_CPP) $(BITCOIN_QT_H) $(BITCOIN_MM) @test -n $(LUPDATE) || echo "lupdate is required for updating translations" $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(LUPDATE) -no-obsolete -I $(srcdir) -locations relative $^ -ts $(srcdir)/qt/locale/dash_en.ts @test -n $(LCONVERT) || echo "lconvert is required for updating translations" @@ -528,6 +574,7 @@ ui_%.h: %.ui $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(MOC) $(DEFAULT_INCLUDES) $(QT_INCLUDES_UNSUPPRESSED) $(MOC_DEFS) $< > $@ moc_%.cpp: %.h + @$(MKDIR_P) $(@D) $(AM_V_GEN) QT_SELECT=$(QT_SELECT) $(MOC) $(DEFAULT_INCLUDES) $(QT_INCLUDES_UNSUPPRESSED) $(MOC_DEFS) $< > $@ %.qm: %.ts diff --git a/src/Makefile.qttest.include b/src/Makefile.qttest.include index 62071d40e5a5..9a0d1ffab488 100644 --- a/src/Makefile.qttest.include +++ b/src/Makefile.qttest.include @@ -58,6 +58,9 @@ endif if ENABLE_ZMQ qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_ZMQ) $(ZMQ_LIBS) endif +if ENABLE_PLATFORM_GUI +qt_test_test_dash_qt_LDADD += $(LIBDASH_PLATFORM) $(MBEDTLS_LIBS) +endif qt_test_test_dash_qt_LDADD += $(LIBBITCOIN_CLI) $(LIBBITCOIN_COMMON) $(LIBBITCOIN_UTIL) $(LIBBITCOIN_CONSENSUS) $(LIBBITCOIN_CRYPTO) $(LIBDASHBLS) $(LIBUNIVALUE) $(LIBLEVELDB) \ $(LIBMEMENV) $(BACKTRACE_LIBS) $(QT_LIBS) $(QT_DBUS_LIBS) $(QT_TEST_LIBS) \ $(QR_LIBS) $(BDB_LIBS) $(MINIUPNPC_LIBS) $(NATPMP_LIBS) $(SQLITE_LIBS) $(LIBSECP256K1) \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index e2f1782e5d42..2ec4413963a3 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -208,6 +208,22 @@ 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 \ + test/platform_proof_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/element_vectors.json \ + test/data/platform/grovedb_proof_vectors.json \ + test/data/platform/merk_proof_vectors.json \ + test/data/platform/quorum_sig_vectors.json +endif + if ENABLE_WALLET BITCOIN_TESTS += \ wallet/test/bip39_tests.cpp \ @@ -225,6 +241,10 @@ BITCOIN_TESTS += \ wallet/test/rpc_util_tests.cpp \ wallet/test/scriptpubkeyman_tests.cpp +if ENABLE_PLATFORM_GUI +BITCOIN_TESTS += wallet/test/platformkeys_tests.cpp +endif + FUZZ_SUITE_LD_COMMON +=\ $(SQLITE_LIBS) \ $(BDB_LIBS) @@ -259,6 +279,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) $(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) diff --git a/src/crypto/blake3/LICENSE b/src/crypto/blake3/LICENSE new file mode 100644 index 000000000000..d512ca94d0ca --- /dev/null +++ b/src/crypto/blake3/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Jack O'Connor and Samuel Neves + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/crypto/blake3/README.md b/src/crypto/blake3/README.md new file mode 100644 index 000000000000..7c6c93d33a34 --- /dev/null +++ b/src/crypto/blake3/README.md @@ -0,0 +1,17 @@ +# BLAKE3 (vendored) + +Portable C implementation of BLAKE3, vendored from +https://github.com/BLAKE3-team/BLAKE3 at tag `1.8.5` (`c/` directory). + +Only the portable backend is vendored (`blake3.c`, `blake3_dispatch.c`, +`blake3_portable.c`); it is compiled with the `BLAKE3_NO_SSE2`, +`BLAKE3_NO_SSE41`, `BLAKE3_NO_AVX2`, `BLAKE3_NO_AVX512` and (implicitly, by not +defining `BLAKE3_USE_NEON`) no-NEON configuration, so no SIMD sources are +required. BLAKE3 is used exclusively by the Dash Platform GUI client library +(`--enable-platform-gui`) for GroveDB merk proof verification; it is not linked +into `dashd` or any consensus code. + +Do not modify these files. To update, re-copy from a tagged upstream release +and update this README and the build definitions in `src/Makefile.am`. + +License: Apache-2.0 OR CC0-1.0 (see `LICENSE`). diff --git a/src/crypto/blake3/blake3.c b/src/crypto/blake3/blake3.c new file mode 100644 index 000000000000..00f91f444922 --- /dev/null +++ b/src/crypto/blake3/blake3.c @@ -0,0 +1,651 @@ +#include +#include +#include +#include + +#include "blake3.h" +#include "blake3_impl.h" + +const char *blake3_version(void) { return BLAKE3_VERSION_STRING; } + +INLINE void chunk_state_init(blake3_chunk_state *self, const uint32_t key[8], + uint8_t flags) { + memcpy(self->cv, key, BLAKE3_KEY_LEN); + self->chunk_counter = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + self->buf_len = 0; + self->blocks_compressed = 0; + self->flags = flags; +} + +INLINE void chunk_state_reset(blake3_chunk_state *self, const uint32_t key[8], + uint64_t chunk_counter) { + memcpy(self->cv, key, BLAKE3_KEY_LEN); + self->chunk_counter = chunk_counter; + self->blocks_compressed = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + self->buf_len = 0; +} + +INLINE size_t chunk_state_len(const blake3_chunk_state *self) { + return (BLAKE3_BLOCK_LEN * (size_t)self->blocks_compressed) + + ((size_t)self->buf_len); +} + +INLINE size_t chunk_state_fill_buf(blake3_chunk_state *self, + const uint8_t *input, size_t input_len) { + size_t take = BLAKE3_BLOCK_LEN - ((size_t)self->buf_len); + if (take > input_len) { + take = input_len; + } + uint8_t *dest = self->buf + ((size_t)self->buf_len); + memcpy(dest, input, take); + self->buf_len += (uint8_t)take; + return take; +} + +INLINE uint8_t chunk_state_maybe_start_flag(const blake3_chunk_state *self) { + if (self->blocks_compressed == 0) { + return CHUNK_START; + } else { + return 0; + } +} + +typedef struct { + uint32_t input_cv[8]; + uint64_t counter; + uint8_t block[BLAKE3_BLOCK_LEN]; + uint8_t block_len; + uint8_t flags; +} output_t; + +INLINE output_t make_output(const uint32_t input_cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { + output_t ret; + memcpy(ret.input_cv, input_cv, 32); + memcpy(ret.block, block, BLAKE3_BLOCK_LEN); + ret.block_len = block_len; + ret.counter = counter; + ret.flags = flags; + return ret; +} + +// Chaining values within a given chunk (specifically the compress_in_place +// interface) are represented as words. This avoids unnecessary bytes<->words +// conversion overhead in the portable implementation. However, the hash_many +// interface handles both user input and parent node blocks, so it accepts +// bytes. For that reason, chaining values in the CV stack are represented as +// bytes. +INLINE void output_chaining_value(const output_t *self, uint8_t cv[32]) { + uint32_t cv_words[8]; + memcpy(cv_words, self->input_cv, 32); + blake3_compress_in_place(cv_words, self->block, self->block_len, + self->counter, self->flags); + store_cv_words(cv, cv_words); +} + +INLINE void output_root_bytes(const output_t *self, uint64_t seek, uint8_t *out, + size_t out_len) { + if (out_len == 0) { + return; + } + uint64_t output_block_counter = seek / 64; + size_t offset_within_block = seek % 64; + uint8_t wide_buf[64]; + if(offset_within_block) { + blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); + const size_t available_bytes = 64 - offset_within_block; + const size_t bytes = out_len > available_bytes ? available_bytes : out_len; + memcpy(out, wide_buf + offset_within_block, bytes); + out += bytes; + out_len -= bytes; + output_block_counter += 1; + } + if(out_len / 64) { + blake3_xof_many(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, out, out_len / 64); + } + output_block_counter += out_len / 64; + out += out_len & -64; + out_len -= out_len & -64; + if(out_len) { + blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); + memcpy(out, wide_buf, out_len); + } +} + +INLINE void chunk_state_update(blake3_chunk_state *self, const uint8_t *input, + size_t input_len) { + if (self->buf_len > 0) { + size_t take = chunk_state_fill_buf(self, input, input_len); + input += take; + input_len -= take; + if (input_len > 0) { + blake3_compress_in_place( + self->cv, self->buf, BLAKE3_BLOCK_LEN, self->chunk_counter, + self->flags | chunk_state_maybe_start_flag(self)); + self->blocks_compressed += 1; + self->buf_len = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + } + } + + while (input_len > BLAKE3_BLOCK_LEN) { + blake3_compress_in_place(self->cv, input, BLAKE3_BLOCK_LEN, + self->chunk_counter, + self->flags | chunk_state_maybe_start_flag(self)); + self->blocks_compressed += 1; + input += BLAKE3_BLOCK_LEN; + input_len -= BLAKE3_BLOCK_LEN; + } + + chunk_state_fill_buf(self, input, input_len); +} + +INLINE output_t chunk_state_output(const blake3_chunk_state *self) { + uint8_t block_flags = + self->flags | chunk_state_maybe_start_flag(self) | CHUNK_END; + return make_output(self->cv, self->buf, self->buf_len, self->chunk_counter, + block_flags); +} + +INLINE output_t parent_output(const uint8_t block[BLAKE3_BLOCK_LEN], + const uint32_t key[8], uint8_t flags) { + return make_output(key, block, BLAKE3_BLOCK_LEN, 0, flags | PARENT); +} + +// Given some input larger than one chunk, return the number of bytes that +// should go in the left subtree. This is the largest power-of-2 number of +// chunks that leaves at least 1 byte for the right subtree. +INLINE size_t left_subtree_len(size_t input_len) { + // Subtract 1 to reserve at least one byte for the right side. input_len + // should always be greater than BLAKE3_CHUNK_LEN. + size_t full_chunks = (input_len - 1) / BLAKE3_CHUNK_LEN; + return round_down_to_power_of_2(full_chunks) * BLAKE3_CHUNK_LEN; +} + +// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE chunks at the same time +// on a single thread. Write out the chunk chaining values and return the +// number of chunks hashed. These chunks are never the root and never empty; +// those cases use a different codepath. +INLINE size_t compress_chunks_parallel(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out) { +#if defined(BLAKE3_TESTING) + assert(0 < input_len); + assert(input_len <= MAX_SIMD_DEGREE * BLAKE3_CHUNK_LEN); +#endif + + const uint8_t *chunks_array[MAX_SIMD_DEGREE]; + size_t input_position = 0; + size_t chunks_array_len = 0; + while (input_len - input_position >= BLAKE3_CHUNK_LEN) { + chunks_array[chunks_array_len] = &input[input_position]; + input_position += BLAKE3_CHUNK_LEN; + chunks_array_len += 1; + } + + blake3_hash_many(chunks_array, chunks_array_len, + BLAKE3_CHUNK_LEN / BLAKE3_BLOCK_LEN, key, chunk_counter, + true, flags, CHUNK_START, CHUNK_END, out); + + // Hash the remaining partial chunk, if there is one. Note that the empty + // chunk (meaning the empty message) is a different codepath. + if (input_len > input_position) { + uint64_t counter = chunk_counter + (uint64_t)chunks_array_len; + blake3_chunk_state chunk_state; + chunk_state_init(&chunk_state, key, flags); + chunk_state.chunk_counter = counter; + chunk_state_update(&chunk_state, &input[input_position], + input_len - input_position); + output_t output = chunk_state_output(&chunk_state); + output_chaining_value(&output, &out[chunks_array_len * BLAKE3_OUT_LEN]); + return chunks_array_len + 1; + } else { + return chunks_array_len; + } +} + +// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE parents at the same time +// on a single thread. Write out the parent chaining values and return the +// number of parents hashed. (If there's an odd input chaining value left over, +// return it as an additional output.) These parents are never the root and +// never empty; those cases use a different codepath. +INLINE size_t compress_parents_parallel(const uint8_t *child_chaining_values, + size_t num_chaining_values, + const uint32_t key[8], uint8_t flags, + uint8_t *out) { +#if defined(BLAKE3_TESTING) + assert(2 <= num_chaining_values); + assert(num_chaining_values <= 2 * MAX_SIMD_DEGREE_OR_2); +#endif + + const uint8_t *parents_array[MAX_SIMD_DEGREE_OR_2]; + size_t parents_array_len = 0; + while (num_chaining_values - (2 * parents_array_len) >= 2) { + parents_array[parents_array_len] = + &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN]; + parents_array_len += 1; + } + + blake3_hash_many(parents_array, parents_array_len, 1, key, + 0, // Parents always use counter 0. + false, flags | PARENT, + 0, // Parents have no start flags. + 0, // Parents have no end flags. + out); + + // If there's an odd child left over, it becomes an output. + if (num_chaining_values > 2 * parents_array_len) { + memcpy(&out[parents_array_len * BLAKE3_OUT_LEN], + &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN], + BLAKE3_OUT_LEN); + return parents_array_len + 1; + } else { + return parents_array_len; + } +} + +// The wide helper function returns (writes out) an array of chaining values +// and returns the length of that array. The number of chaining values returned +// is the dynamically detected SIMD degree, at most MAX_SIMD_DEGREE. Or fewer, +// if the input is shorter than that many chunks. The reason for maintaining a +// wide array of chaining values going back up the tree, is to allow the +// implementation to hash as many parents in parallel as possible. +// +// As a special case when the SIMD degree is 1, this function will still return +// at least 2 outputs. This guarantees that this function doesn't perform the +// root compression. (If it did, it would use the wrong flags, and also we +// wouldn't be able to implement extendable output.) Note that this function is +// not used when the whole input is only 1 chunk long; that's a different +// codepath. +// +// Why not just have the caller split the input on the first update(), instead +// of implementing this special rule? Because we don't want to limit SIMD or +// multi-threading parallelism for that update(). +size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out, bool use_tbb) { + // Note that the single chunk case does *not* bump the SIMD degree up to 2 + // when it is 1. If this implementation adds multi-threading in the future, + // this gives us the option of multi-threading even the 2-chunk case, which + // can help performance on smaller platforms. + if (input_len <= blake3_simd_degree() * BLAKE3_CHUNK_LEN) { + return compress_chunks_parallel(input, input_len, key, chunk_counter, flags, + out); + } + + // With more than simd_degree chunks, we need to recurse. Start by dividing + // the input into left and right subtrees. (Note that this is only optimal + // as long as the SIMD degree is a power of 2. If we ever get a SIMD degree + // of 3 or something, we'll need a more complicated strategy.) + size_t left_input_len = left_subtree_len(input_len); + size_t right_input_len = input_len - left_input_len; + const uint8_t *right_input = &input[left_input_len]; + uint64_t right_chunk_counter = + chunk_counter + (uint64_t)(left_input_len / BLAKE3_CHUNK_LEN); + + // Make space for the child outputs. Here we use MAX_SIMD_DEGREE_OR_2 to + // account for the special case of returning 2 outputs when the SIMD degree + // is 1. + uint8_t cv_array[2 * MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; + size_t degree = blake3_simd_degree(); + if (left_input_len > BLAKE3_CHUNK_LEN && degree == 1) { + // The special case: We always use a degree of at least two, to make + // sure there are two outputs. Except, as noted above, at the chunk + // level, where we allow degree=1. (Note that the 1-chunk-input case is + // a different codepath.) + degree = 2; + } + uint8_t *right_cvs = &cv_array[degree * BLAKE3_OUT_LEN]; + + // Recurse! + size_t left_n = SIZE_MAX; + size_t right_n = SIZE_MAX; + +#if defined(BLAKE3_USE_TBB) + blake3_compress_subtree_wide_join_tbb( + key, flags, use_tbb, + // left-hand side + input, left_input_len, chunk_counter, cv_array, &left_n, + // right-hand side + right_input, right_input_len, right_chunk_counter, right_cvs, &right_n); +#else + left_n = blake3_compress_subtree_wide( + input, left_input_len, key, chunk_counter, flags, cv_array, use_tbb); + right_n = blake3_compress_subtree_wide(right_input, right_input_len, key, + right_chunk_counter, flags, right_cvs, + use_tbb); +#endif // BLAKE3_USE_TBB + + // The special case again. If simd_degree=1, then we'll have left_n=1 and + // right_n=1. Rather than compressing them into a single output, return + // them directly, to make sure we always have at least two outputs. + if (left_n == 1) { + memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); + return 2; + } + + // Otherwise, do one layer of parent node compression. + size_t num_chaining_values = left_n + right_n; + return compress_parents_parallel(cv_array, num_chaining_values, key, flags, + out); +} + +// Hash a subtree with compress_subtree_wide(), and then condense the resulting +// list of chaining values down to a single parent node. Don't compress that +// last parent node, however. Instead, return its message bytes (the +// concatenated chaining values of its children). This is necessary when the +// first call to update() supplies a complete subtree, because the topmost +// parent node of that subtree could end up being the root. It's also necessary +// for extended output in the general case. +// +// As with compress_subtree_wide(), this function is not used on inputs of 1 +// chunk or less. That's a different codepath. +INLINE void +compress_subtree_to_parent_node(const uint8_t *input, size_t input_len, + const uint32_t key[8], uint64_t chunk_counter, + uint8_t flags, uint8_t out[2 * BLAKE3_OUT_LEN], + bool use_tbb) { +#if defined(BLAKE3_TESTING) + assert(input_len > BLAKE3_CHUNK_LEN); +#endif + + uint8_t cv_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; + size_t num_cvs = blake3_compress_subtree_wide(input, input_len, key, + chunk_counter, flags, cv_array, use_tbb); + assert(num_cvs <= MAX_SIMD_DEGREE_OR_2); + // The following loop never executes when MAX_SIMD_DEGREE_OR_2 is 2, because + // as we just asserted, num_cvs will always be <=2 in that case. But GCC + // (particularly GCC 8.5) can't tell that it never executes, and if NDEBUG is + // set then it emits incorrect warnings here. We tried a few different + // hacks to silence these, but in the end our hacks just produced different + // warnings (see https://github.com/BLAKE3-team/BLAKE3/pull/380). Out of + // desperation, we ifdef out this entire loop when we know it's not needed. +#if MAX_SIMD_DEGREE_OR_2 > 2 + // If MAX_SIMD_DEGREE_OR_2 is greater than 2 and there's enough input, + // compress_subtree_wide() returns more than 2 chaining values. Condense + // them into 2 by forming parent nodes repeatedly. + uint8_t out_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN / 2]; + while (num_cvs > 2) { + num_cvs = + compress_parents_parallel(cv_array, num_cvs, key, flags, out_array); + memcpy(cv_array, out_array, num_cvs * BLAKE3_OUT_LEN); + } +#endif + memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); +} + +INLINE void hasher_init_base(blake3_hasher *self, const uint32_t key[8], + uint8_t flags) { + memcpy(self->key, key, BLAKE3_KEY_LEN); + chunk_state_init(&self->chunk, key, flags); + self->cv_stack_len = 0; +} + +void blake3_hasher_init(blake3_hasher *self) { hasher_init_base(self, IV, 0); } + +void blake3_hasher_init_keyed(blake3_hasher *self, + const uint8_t key[BLAKE3_KEY_LEN]) { + uint32_t key_words[8]; + load_key_words(key, key_words); + hasher_init_base(self, key_words, KEYED_HASH); +} + +void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, + size_t context_len) { + blake3_hasher context_hasher; + hasher_init_base(&context_hasher, IV, DERIVE_KEY_CONTEXT); + blake3_hasher_update(&context_hasher, context, context_len); + uint8_t context_key[BLAKE3_KEY_LEN]; + blake3_hasher_finalize(&context_hasher, context_key, BLAKE3_KEY_LEN); + uint32_t context_key_words[8]; + load_key_words(context_key, context_key_words); + hasher_init_base(self, context_key_words, DERIVE_KEY_MATERIAL); +} + +void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context) { + blake3_hasher_init_derive_key_raw(self, context, strlen(context)); +} + +// As described in hasher_push_cv() below, we do "lazy merging", delaying +// merges until right before the next CV is about to be added. This is +// different from the reference implementation. Another difference is that we +// aren't always merging 1 chunk at a time. Instead, each CV might represent +// any power-of-two number of chunks, as long as the smaller-above-larger stack +// order is maintained. Instead of the "count the trailing 0-bits" algorithm +// described in the spec, we use a "count the total number of 1-bits" variant +// that doesn't require us to retain the subtree size of the CV on top of the +// stack. The principle is the same: each CV that should remain in the stack is +// represented by a 1-bit in the total number of chunks (or bytes) so far. +INLINE void hasher_merge_cv_stack(blake3_hasher *self, uint64_t total_len) { + size_t post_merge_stack_len = (size_t)popcnt(total_len); + while (self->cv_stack_len > post_merge_stack_len) { + uint8_t *parent_node = + &self->cv_stack[(self->cv_stack_len - 2) * BLAKE3_OUT_LEN]; + output_t output = parent_output(parent_node, self->key, self->chunk.flags); + output_chaining_value(&output, parent_node); + self->cv_stack_len -= 1; + } +} + +// In reference_impl.rs, we merge the new CV with existing CVs from the stack +// before pushing it. We can do that because we know more input is coming, so +// we know none of the merges are root. +// +// This setting is different. We want to feed as much input as possible to +// compress_subtree_wide(), without setting aside anything for the chunk_state. +// If the user gives us 64 KiB, we want to parallelize over all 64 KiB at once +// as a single subtree, if at all possible. +// +// This leads to two problems: +// 1) This 64 KiB input might be the only call that ever gets made to update. +// In this case, the root node of the 64 KiB subtree would be the root node +// of the whole tree, and it would need to be ROOT finalized. We can't +// compress it until we know. +// 2) This 64 KiB input might complete a larger tree, whose root node is +// similarly going to be the root of the whole tree. For example, maybe +// we have 196 KiB (that is, 128 + 64) hashed so far. We can't compress the +// node at the root of the 256 KiB subtree until we know how to finalize it. +// +// The second problem is solved with "lazy merging". That is, when we're about +// to add a CV to the stack, we don't merge it with anything first, as the +// reference impl does. Instead we do merges using the *previous* CV that was +// added, which is sitting on top of the stack, and we put the new CV +// (unmerged) on top of the stack afterwards. This guarantees that we never +// merge the root node until finalize(). +// +// Solving the first problem requires an additional tool, +// compress_subtree_to_parent_node(). That function always returns the top +// *two* chaining values of the subtree it's compressing. We then do lazy +// merging with each of them separately, so that the second CV will always +// remain unmerged. (That also helps us support extendable output when we're +// hashing an input all-at-once.) +INLINE void hasher_push_cv(blake3_hasher *self, uint8_t new_cv[BLAKE3_OUT_LEN], + uint64_t chunk_counter) { + hasher_merge_cv_stack(self, chunk_counter); + memcpy(&self->cv_stack[self->cv_stack_len * BLAKE3_OUT_LEN], new_cv, + BLAKE3_OUT_LEN); + self->cv_stack_len += 1; +} + +INLINE void blake3_hasher_update_base(blake3_hasher *self, const void *input, + size_t input_len, bool use_tbb) { + // Explicitly checking for zero avoids causing UB by passing a null pointer + // to memcpy. This comes up in practice with things like: + // std::vector v; + // blake3_hasher_update(&hasher, v.data(), v.size()); + if (input_len == 0) { + return; + } + + const uint8_t *input_bytes = (const uint8_t *)input; + + // If we have some partial chunk bytes in the internal chunk_state, we need + // to finish that chunk first. + if (chunk_state_len(&self->chunk) > 0) { + size_t take = BLAKE3_CHUNK_LEN - chunk_state_len(&self->chunk); + if (take > input_len) { + take = input_len; + } + chunk_state_update(&self->chunk, input_bytes, take); + input_bytes += take; + input_len -= take; + // If we've filled the current chunk and there's more coming, finalize this + // chunk and proceed. In this case we know it's not the root. + if (input_len > 0) { + output_t output = chunk_state_output(&self->chunk); + uint8_t chunk_cv[32]; + output_chaining_value(&output, chunk_cv); + hasher_push_cv(self, chunk_cv, self->chunk.chunk_counter); + chunk_state_reset(&self->chunk, self->key, self->chunk.chunk_counter + 1); + } else { + return; + } + } + + // Now the chunk_state is clear, and we have more input. If there's more than + // a single chunk (so, definitely not the root chunk), hash the largest whole + // subtree we can, with the full benefits of SIMD (and maybe in the future, + // multi-threading) parallelism. Two restrictions: + // - The subtree has to be a power-of-2 number of chunks. Only subtrees along + // the right edge can be incomplete, and we don't know where the right edge + // is going to be until we get to finalize(). + // - The subtree must evenly divide the total number of chunks up until this + // point (if total is not 0). If the current incomplete subtree is only + // waiting for 1 more chunk, we can't hash a subtree of 4 chunks. We have + // to complete the current subtree first. + // Because we might need to break up the input to form powers of 2, or to + // evenly divide what we already have, this part runs in a loop. + while (input_len > BLAKE3_CHUNK_LEN) { + size_t subtree_len = round_down_to_power_of_2(input_len); + uint64_t count_so_far = self->chunk.chunk_counter * BLAKE3_CHUNK_LEN; + // Shrink the subtree_len until it evenly divides the count so far. We know + // that subtree_len itself is a power of 2, so we can use a bitmasking + // trick instead of an actual remainder operation. (Note that if the caller + // consistently passes power-of-2 inputs of the same size, as is hopefully + // typical, this loop condition will always fail, and subtree_len will + // always be the full length of the input.) + // + // An aside: We don't have to shrink subtree_len quite this much. For + // example, if count_so_far is 1, we could pass 2 chunks to + // compress_subtree_to_parent_node. Since we'll get 2 CVs back, we'll still + // get the right answer in the end, and we might get to use 2-way SIMD + // parallelism. The problem with this optimization, is that it gets us + // stuck always hashing 2 chunks. The total number of chunks will remain + // odd, and we'll never graduate to higher degrees of parallelism. See + // https://github.com/BLAKE3-team/BLAKE3/issues/69. + while ((((uint64_t)(subtree_len - 1)) & count_so_far) != 0) { + subtree_len /= 2; + } + // The shrunken subtree_len might now be 1 chunk long. If so, hash that one + // chunk by itself. Otherwise, compress the subtree into a pair of CVs. + uint64_t subtree_chunks = subtree_len / BLAKE3_CHUNK_LEN; + if (subtree_len <= BLAKE3_CHUNK_LEN) { + blake3_chunk_state chunk_state; + chunk_state_init(&chunk_state, self->key, self->chunk.flags); + chunk_state.chunk_counter = self->chunk.chunk_counter; + chunk_state_update(&chunk_state, input_bytes, subtree_len); + output_t output = chunk_state_output(&chunk_state); + uint8_t cv[BLAKE3_OUT_LEN]; + output_chaining_value(&output, cv); + hasher_push_cv(self, cv, chunk_state.chunk_counter); + } else { + // This is the high-performance happy path, though getting here depends + // on the caller giving us a long enough input. + uint8_t cv_pair[2 * BLAKE3_OUT_LEN]; + compress_subtree_to_parent_node(input_bytes, subtree_len, self->key, + self->chunk.chunk_counter, + self->chunk.flags, cv_pair, use_tbb); + hasher_push_cv(self, cv_pair, self->chunk.chunk_counter); + hasher_push_cv(self, &cv_pair[BLAKE3_OUT_LEN], + self->chunk.chunk_counter + (subtree_chunks / 2)); + } + self->chunk.chunk_counter += subtree_chunks; + input_bytes += subtree_len; + input_len -= subtree_len; + } + + // If there's any remaining input less than a full chunk, add it to the chunk + // state. In that case, also do a final merge loop to make sure the subtree + // stack doesn't contain any unmerged pairs. The remaining input means we + // know these merges are non-root. This merge loop isn't strictly necessary + // here, because hasher_push_chunk_cv already does its own merge loop, but it + // simplifies blake3_hasher_finalize below. + if (input_len > 0) { + chunk_state_update(&self->chunk, input_bytes, input_len); + hasher_merge_cv_stack(self, self->chunk.chunk_counter); + } +} + +void blake3_hasher_update(blake3_hasher *self, const void *input, + size_t input_len) { + bool use_tbb = false; + blake3_hasher_update_base(self, input, input_len, use_tbb); +} + +#if defined(BLAKE3_USE_TBB) +void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, + size_t input_len) { + bool use_tbb = true; + blake3_hasher_update_base(self, input, input_len, use_tbb); +} +#endif // BLAKE3_USE_TBB + +void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, + size_t out_len) { + blake3_hasher_finalize_seek(self, 0, out, out_len); +} + +void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, + uint8_t *out, size_t out_len) { + // Explicitly checking for zero avoids causing UB by passing a null pointer + // to memcpy. This comes up in practice with things like: + // std::vector v; + // blake3_hasher_finalize(&hasher, v.data(), v.size()); + if (out_len == 0) { + return; + } + + // If the subtree stack is empty, then the current chunk is the root. + if (self->cv_stack_len == 0) { + output_t output = chunk_state_output(&self->chunk); + output_root_bytes(&output, seek, out, out_len); + return; + } + // If there are any bytes in the chunk state, finalize that chunk and do a + // roll-up merge between that chunk hash and every subtree in the stack. In + // this case, the extra merge loop at the end of blake3_hasher_update + // guarantees that none of the subtrees in the stack need to be merged with + // each other first. Otherwise, if there are no bytes in the chunk state, + // then the top of the stack is a chunk hash, and we start the merge from + // that. + output_t output; + size_t cvs_remaining; + if (chunk_state_len(&self->chunk) > 0) { + cvs_remaining = self->cv_stack_len; + output = chunk_state_output(&self->chunk); + } else { + // There are always at least 2 CVs in the stack in this case. + cvs_remaining = self->cv_stack_len - 2; + output = parent_output(&self->cv_stack[cvs_remaining * 32], self->key, + self->chunk.flags); + } + while (cvs_remaining > 0) { + cvs_remaining -= 1; + uint8_t parent_block[BLAKE3_BLOCK_LEN]; + memcpy(parent_block, &self->cv_stack[cvs_remaining * 32], 32); + output_chaining_value(&output, &parent_block[32]); + output = parent_output(parent_block, self->key, self->chunk.flags); + } + output_root_bytes(&output, seek, out, out_len); +} + +void blake3_hasher_reset(blake3_hasher *self) { + chunk_state_reset(&self->chunk, self->key, 0); + self->cv_stack_len = 0; +} diff --git a/src/crypto/blake3/blake3.h b/src/crypto/blake3/blake3.h new file mode 100644 index 000000000000..423154ff7585 --- /dev/null +++ b/src/crypto/blake3/blake3.h @@ -0,0 +1,86 @@ +#ifndef BLAKE3_H +#define BLAKE3_H + +#include +#include + +#if !defined(BLAKE3_API) +# if defined(_WIN32) || defined(__CYGWIN__) +# if defined(BLAKE3_DLL) +# if defined(BLAKE3_DLL_EXPORTS) +# define BLAKE3_API __declspec(dllexport) +# else +# define BLAKE3_API __declspec(dllimport) +# endif +# define BLAKE3_PRIVATE +# else +# define BLAKE3_API +# define BLAKE3_PRIVATE +# endif +# elif __GNUC__ >= 4 +# define BLAKE3_API __attribute__((visibility("default"))) +# define BLAKE3_PRIVATE __attribute__((visibility("hidden"))) +# else +# define BLAKE3_API +# define BLAKE3_PRIVATE +# endif +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define BLAKE3_VERSION_STRING "1.8.5" +#define BLAKE3_KEY_LEN 32 +#define BLAKE3_OUT_LEN 32 +#define BLAKE3_BLOCK_LEN 64 +#define BLAKE3_CHUNK_LEN 1024 +#define BLAKE3_MAX_DEPTH 54 + +// This struct is a private implementation detail. It has to be here because +// it's part of the blake3_hasher structure defined below. +typedef struct { + uint32_t cv[8]; + uint64_t chunk_counter; + uint8_t buf[BLAKE3_BLOCK_LEN]; + uint8_t buf_len; + uint8_t blocks_compressed; + uint8_t flags; +} blake3_chunk_state; + +typedef struct { + uint32_t key[8]; + blake3_chunk_state chunk; + uint8_t cv_stack_len; + // The stack size is MAX_DEPTH + 1 because we do lazy merging. For example, + // with 7 chunks, we have 3 entries in the stack. Adding an 8th chunk + // requires a 4th entry, rather than merging everything down to 1, because we + // don't know whether more input is coming. This is different from how the + // reference implementation does things. + uint8_t cv_stack[(BLAKE3_MAX_DEPTH + 1) * BLAKE3_OUT_LEN]; +} blake3_hasher; + +BLAKE3_API const char *blake3_version(void); +BLAKE3_API void blake3_hasher_init(blake3_hasher *self); +BLAKE3_API void blake3_hasher_init_keyed(blake3_hasher *self, + const uint8_t key[BLAKE3_KEY_LEN]); +BLAKE3_API void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context); +BLAKE3_API void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, + size_t context_len); +BLAKE3_API void blake3_hasher_update(blake3_hasher *self, const void *input, + size_t input_len); +#if defined(BLAKE3_USE_TBB) +BLAKE3_API void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, + size_t input_len); +#endif // BLAKE3_USE_TBB +BLAKE3_API void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, + size_t out_len); +BLAKE3_API void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, + uint8_t *out, size_t out_len); +BLAKE3_API void blake3_hasher_reset(blake3_hasher *self); + +#ifdef __cplusplus +} +#endif + +#endif /* BLAKE3_H */ diff --git a/src/crypto/blake3/blake3_dispatch.c b/src/crypto/blake3/blake3_dispatch.c new file mode 100644 index 000000000000..14dfbbe0c8f3 --- /dev/null +++ b/src/crypto/blake3/blake3_dispatch.c @@ -0,0 +1,332 @@ +#include +#include +#include + +#include "blake3_impl.h" + +#if defined(_MSC_VER) +#include +#endif + +#if defined(IS_X86) +#if defined(_MSC_VER) +#include +#elif defined(__GNUC__) +#include +#else +#undef IS_X86 /* Unimplemented! */ +#endif +#endif + +#if !defined(BLAKE3_ATOMICS) +#if defined(__has_include) +#if __has_include() && !defined(_MSC_VER) +#define BLAKE3_ATOMICS 1 +#else +#define BLAKE3_ATOMICS 0 +#endif /* __has_include() && !defined(_MSC_VER) */ +#else +#define BLAKE3_ATOMICS 0 +#endif /* defined(__has_include) */ +#endif /* BLAKE3_ATOMICS */ + +#if BLAKE3_ATOMICS +#define ATOMIC_INT _Atomic int +#define ATOMIC_LOAD(x) x +#define ATOMIC_STORE(x, y) x = y +#elif defined(_MSC_VER) +#define ATOMIC_INT LONG +#define ATOMIC_LOAD(x) InterlockedOr(&x, 0) +#define ATOMIC_STORE(x, y) InterlockedExchange(&x, y) +#else +#define ATOMIC_INT int +#define ATOMIC_LOAD(x) x +#define ATOMIC_STORE(x, y) x = y +#endif + +#define MAYBE_UNUSED(x) (void)((x)) + +#if defined(IS_X86) +static uint64_t xgetbv(void) { +#if defined(_MSC_VER) + return _xgetbv(0); +#else + uint32_t eax = 0, edx = 0; + __asm__ __volatile__("xgetbv\n" : "=a"(eax), "=d"(edx) : "c"(0)); + return ((uint64_t)edx << 32) | eax; +#endif +} + +static void cpuid(uint32_t out[4], uint32_t id) { +#if defined(_MSC_VER) + __cpuid((int *)out, id); +#elif defined(__i386__) || defined(_M_IX86) + __asm__ __volatile__("movl %%ebx, %1\n" + "cpuid\n" + "xchgl %1, %%ebx\n" + : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id)); +#else + __asm__ __volatile__("cpuid\n" + : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id)); +#endif +} + +static void cpuidex(uint32_t out[4], uint32_t id, uint32_t sid) { +#if defined(_MSC_VER) + __cpuidex((int *)out, id, sid); +#elif defined(__i386__) || defined(_M_IX86) + __asm__ __volatile__("movl %%ebx, %1\n" + "cpuid\n" + "xchgl %1, %%ebx\n" + : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id), "c"(sid)); +#else + __asm__ __volatile__("cpuid\n" + : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id), "c"(sid)); +#endif +} + + +enum cpu_feature { + SSE2 = 1 << 0, + SSSE3 = 1 << 1, + SSE41 = 1 << 2, + AVX = 1 << 3, + AVX2 = 1 << 4, + AVX512F = 1 << 5, + AVX512VL = 1 << 6, + /* ... */ + UNDEFINED = 1 << 30 +}; + +#if !defined(BLAKE3_TESTING) +static /* Allow the variable to be controlled manually for testing */ +#endif + ATOMIC_INT g_cpu_features = UNDEFINED; + +#if !defined(BLAKE3_TESTING) +static +#endif + enum cpu_feature + get_cpu_features(void) { + + /* If TSAN detects a data race here, try compiling with -DBLAKE3_ATOMICS=1 */ + enum cpu_feature features = ATOMIC_LOAD(g_cpu_features); + if (features != UNDEFINED) { + return features; + } else { +#if defined(IS_X86) + uint32_t regs[4] = {0}; + uint32_t *eax = ®s[0], *ebx = ®s[1], *ecx = ®s[2], *edx = ®s[3]; + (void)edx; + features = 0; + cpuid(regs, 0); + const int max_id = *eax; + cpuid(regs, 1); +#if defined(__amd64__) || defined(_M_X64) + features |= SSE2; +#else + if (*edx & (1UL << 26)) + features |= SSE2; +#endif + if (*ecx & (1UL << 9)) + features |= SSSE3; + if (*ecx & (1UL << 19)) + features |= SSE41; + + if (*ecx & (1UL << 27)) { // OSXSAVE + const uint64_t mask = xgetbv(); + if ((mask & 6) == 6) { // SSE and AVX states + if (*ecx & (1UL << 28)) + features |= AVX; + if (max_id >= 7) { + cpuidex(regs, 7, 0); + if (*ebx & (1UL << 5)) + features |= AVX2; + if ((mask & 224) == 224) { // Opmask, ZMM_Hi256, Hi16_Zmm + if (*ebx & (1UL << 31)) + features |= AVX512VL; + if (*ebx & (1UL << 16)) + features |= AVX512F; + } + } + } + } + ATOMIC_STORE(g_cpu_features, features); + return features; +#else + /* How to detect NEON? */ + return 0; +#endif + } +} +#endif + +void blake3_compress_in_place(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_compress_in_place_avx512(cv, block, block_len, counter, flags); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_compress_in_place_sse41(cv, block, block_len, counter, flags); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_compress_in_place_sse2(cv, block, block_len, counter, flags); + return; + } +#endif +#endif + blake3_compress_in_place_portable(cv, block, block_len, counter, flags); +} + +void blake3_compress_xof(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64]) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_compress_xof_avx512(cv, block, block_len, counter, flags, out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_compress_xof_sse41(cv, block, block_len, counter, flags, out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_compress_xof_sse2(cv, block, block_len, counter, flags, out); + return; + } +#endif +#endif + blake3_compress_xof_portable(cv, block, block_len, counter, flags, out); +} + + +void blake3_xof_many(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64], size_t outblocks) { + if (outblocks == 0) { + // The current assembly implementation always outputs at least 1 block. + return; + } +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(_WIN32) && !defined(__CYGWIN__) && !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_xof_many_avx512(cv, block, block_len, counter, flags, out, outblocks); + return; + } +#endif +#endif + for(size_t i = 0; i < outblocks; ++i) { + blake3_compress_xof(cv, block, block_len, counter + i, flags, out + 64*i); + } +} + +void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], uint64_t counter, + bool increment_counter, uint8_t flags, + uint8_t flags_start, uint8_t flags_end, uint8_t *out) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { + blake3_hash_many_avx512(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_AVX2) + if (features & AVX2) { + blake3_hash_many_avx2(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_hash_many_sse41(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_hash_many_sse2(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#endif + +#if BLAKE3_USE_NEON == 1 + blake3_hash_many_neon(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, out); + return; +#endif + + blake3_hash_many_portable(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); +} + +// The dynamically detected SIMD degree of the current platform. +size_t blake3_simd_degree(void) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { + return 16; + } +#endif +#if !defined(BLAKE3_NO_AVX2) + if (features & AVX2) { + return 8; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + return 4; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + return 4; + } +#endif +#endif +#if BLAKE3_USE_NEON == 1 + return 4; +#endif + return 1; +} diff --git a/src/crypto/blake3/blake3_impl.h b/src/crypto/blake3/blake3_impl.h new file mode 100644 index 000000000000..88e71e41e90f --- /dev/null +++ b/src/crypto/blake3/blake3_impl.h @@ -0,0 +1,333 @@ +#ifndef BLAKE3_IMPL_H +#define BLAKE3_IMPL_H + +#include +#include +#include +#include +#include + +#include "blake3.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// internal flags +enum blake3_flags { + CHUNK_START = 1 << 0, + CHUNK_END = 1 << 1, + PARENT = 1 << 2, + ROOT = 1 << 3, + KEYED_HASH = 1 << 4, + DERIVE_KEY_CONTEXT = 1 << 5, + DERIVE_KEY_MATERIAL = 1 << 6, +}; + +// This C implementation tries to support recent versions of GCC, Clang, and +// MSVC. +#if defined(_MSC_VER) +#define INLINE static __forceinline +#else +#define INLINE static inline __attribute__((always_inline)) +#endif + +#ifdef __cplusplus +#define NOEXCEPT noexcept +#else +#define NOEXCEPT +#endif + +#if (defined(__x86_64__) || defined(_M_X64)) && !defined(_M_ARM64EC) +#define IS_X86 +#define IS_X86_64 +#endif + +#if defined(__i386__) || defined(_M_IX86) +#define IS_X86 +#define IS_X86_32 +#endif + +#if defined(__aarch64__) || defined(_M_ARM64) || defined(_M_ARM64EC) +#define IS_AARCH64 +#endif + +#if defined(IS_X86) +#if defined(_MSC_VER) +#include +#endif +#endif + +#if !defined(BLAKE3_USE_NEON) + // If BLAKE3_USE_NEON not manually set, autodetect based on AArch64ness + #if defined(IS_AARCH64) + #if defined(__ARM_BIG_ENDIAN) + #define BLAKE3_USE_NEON 0 + #else + #define BLAKE3_USE_NEON 1 + #endif + #else + #define BLAKE3_USE_NEON 0 + #endif +#endif + +#if defined(IS_X86) +#define MAX_SIMD_DEGREE 16 +#elif BLAKE3_USE_NEON == 1 +#define MAX_SIMD_DEGREE 4 +#else +#define MAX_SIMD_DEGREE 1 +#endif + +// There are some places where we want a static size that's equal to the +// MAX_SIMD_DEGREE, but also at least 2. +#define MAX_SIMD_DEGREE_OR_2 (MAX_SIMD_DEGREE > 2 ? MAX_SIMD_DEGREE : 2) + +static const uint32_t IV[8] = {0x6A09E667UL, 0xBB67AE85UL, 0x3C6EF372UL, + 0xA54FF53AUL, 0x510E527FUL, 0x9B05688CUL, + 0x1F83D9ABUL, 0x5BE0CD19UL}; + +static const uint8_t MSG_SCHEDULE[7][16] = { + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8}, + {3, 4, 10, 12, 13, 2, 7, 14, 6, 5, 9, 0, 11, 15, 8, 1}, + {10, 7, 12, 9, 14, 3, 13, 15, 4, 0, 11, 2, 5, 8, 1, 6}, + {12, 13, 9, 11, 15, 10, 14, 8, 7, 2, 5, 3, 0, 1, 6, 4}, + {9, 14, 11, 5, 8, 12, 15, 1, 13, 3, 0, 10, 2, 6, 4, 7}, + {11, 15, 5, 0, 1, 9, 8, 6, 14, 10, 2, 12, 3, 4, 7, 13}, +}; + +/* Find index of the highest set bit */ +/* x is assumed to be nonzero. */ +static unsigned int highest_one(uint64_t x) { +#if defined(__GNUC__) || defined(__clang__) + return 63 ^ (unsigned int)__builtin_clzll(x); +#elif defined(_MSC_VER) && defined(IS_X86_64) + unsigned long index; + _BitScanReverse64(&index, x); + return index; +#elif defined(_MSC_VER) && defined(IS_X86_32) + if(x >> 32) { + unsigned long index; + _BitScanReverse(&index, (unsigned long)(x >> 32)); + return 32 + index; + } else { + unsigned long index; + _BitScanReverse(&index, (unsigned long)x); + return index; + } +#else + unsigned int c = 0; + if(x & 0xffffffff00000000ULL) { x >>= 32; c += 32; } + if(x & 0x00000000ffff0000ULL) { x >>= 16; c += 16; } + if(x & 0x000000000000ff00ULL) { x >>= 8; c += 8; } + if(x & 0x00000000000000f0ULL) { x >>= 4; c += 4; } + if(x & 0x000000000000000cULL) { x >>= 2; c += 2; } + if(x & 0x0000000000000002ULL) { c += 1; } + return c; +#endif +} + +// Count the number of 1 bits. +INLINE unsigned int popcnt(uint64_t x) { +#if defined(__GNUC__) || defined(__clang__) + return (unsigned int)__builtin_popcountll(x); +#else + unsigned int count = 0; + while (x != 0) { + count += 1; + x &= x - 1; + } + return count; +#endif +} + +// Largest power of two less than or equal to x. As a special case, returns 1 +// when x is 0. +INLINE uint64_t round_down_to_power_of_2(uint64_t x) { + return 1ULL << highest_one(x | 1); +} + +INLINE uint32_t counter_low(uint64_t counter) { return (uint32_t)counter; } + +INLINE uint32_t counter_high(uint64_t counter) { + return (uint32_t)(counter >> 32); +} + +INLINE uint32_t load32(const void *src) { + const uint8_t *p = (const uint8_t *)src; + return ((uint32_t)(p[0]) << 0) | ((uint32_t)(p[1]) << 8) | + ((uint32_t)(p[2]) << 16) | ((uint32_t)(p[3]) << 24); +} + +INLINE void load_key_words(const uint8_t key[BLAKE3_KEY_LEN], + uint32_t key_words[8]) { + key_words[0] = load32(&key[0 * 4]); + key_words[1] = load32(&key[1 * 4]); + key_words[2] = load32(&key[2 * 4]); + key_words[3] = load32(&key[3 * 4]); + key_words[4] = load32(&key[4 * 4]); + key_words[5] = load32(&key[5 * 4]); + key_words[6] = load32(&key[6 * 4]); + key_words[7] = load32(&key[7 * 4]); +} + +INLINE void load_block_words(const uint8_t block[BLAKE3_BLOCK_LEN], + uint32_t block_words[16]) { + for (size_t i = 0; i < 16; i++) { + block_words[i] = load32(&block[i * 4]); + } +} + +INLINE void store32(void *dst, uint32_t w) { + uint8_t *p = (uint8_t *)dst; + p[0] = (uint8_t)(w >> 0); + p[1] = (uint8_t)(w >> 8); + p[2] = (uint8_t)(w >> 16); + p[3] = (uint8_t)(w >> 24); +} + +INLINE void store_cv_words(uint8_t bytes_out[32], uint32_t cv_words[8]) { + store32(&bytes_out[0 * 4], cv_words[0]); + store32(&bytes_out[1 * 4], cv_words[1]); + store32(&bytes_out[2 * 4], cv_words[2]); + store32(&bytes_out[3 * 4], cv_words[3]); + store32(&bytes_out[4 * 4], cv_words[4]); + store32(&bytes_out[5 * 4], cv_words[5]); + store32(&bytes_out[6 * 4], cv_words[6]); + store32(&bytes_out[7 * 4], cv_words[7]); +} + +void blake3_compress_in_place(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64]); + +void blake3_xof_many(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64], size_t outblocks); + +void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], uint64_t counter, + bool increment_counter, uint8_t flags, + uint8_t flags_start, uint8_t flags_end, uint8_t *out); + +size_t blake3_simd_degree(void); + +BLAKE3_PRIVATE size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out, bool use_tbb); + +#if defined(BLAKE3_USE_TBB) +BLAKE3_PRIVATE void blake3_compress_subtree_wide_join_tbb( + // shared params + const uint32_t key[8], uint8_t flags, bool use_tbb, + // left-hand side params + const uint8_t *l_input, size_t l_input_len, uint64_t l_chunk_counter, + uint8_t *l_cvs, size_t *l_n, + // right-hand side params + const uint8_t *r_input, size_t r_input_len, uint64_t r_chunk_counter, + uint8_t *r_cvs, size_t *r_n) NOEXCEPT; +#endif + +// Declarations for implementation-specific functions. +void blake3_compress_in_place_portable(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof_portable(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); + +void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); + +#if defined(IS_X86) +#if !defined(BLAKE3_NO_SSE2) +void blake3_compress_in_place_sse2(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); +void blake3_compress_xof_sse2(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); +void blake3_hash_many_sse2(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_SSE41) +void blake3_compress_in_place_sse41(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); +void blake3_compress_xof_sse41(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); +void blake3_hash_many_sse41(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_AVX2) +void blake3_hash_many_avx2(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_AVX512) +void blake3_compress_in_place_avx512(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof_avx512(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); + +void blake3_hash_many_avx512(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); + +#if !defined(_WIN32) && !defined(__CYGWIN__) +void blake3_xof_many_avx512(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t* out, size_t outblocks); +#endif +#endif +#endif + +#if BLAKE3_USE_NEON == 1 +void blake3_hash_many_neon(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* BLAKE3_IMPL_H */ diff --git a/src/crypto/blake3/blake3_portable.c b/src/crypto/blake3/blake3_portable.c new file mode 100644 index 000000000000..062dd1b47fb6 --- /dev/null +++ b/src/crypto/blake3/blake3_portable.c @@ -0,0 +1,160 @@ +#include "blake3_impl.h" +#include + +INLINE uint32_t rotr32(uint32_t w, uint32_t c) { + return (w >> c) | (w << (32 - c)); +} + +INLINE void g(uint32_t *state, size_t a, size_t b, size_t c, size_t d, + uint32_t x, uint32_t y) { + state[a] = state[a] + state[b] + x; + state[d] = rotr32(state[d] ^ state[a], 16); + state[c] = state[c] + state[d]; + state[b] = rotr32(state[b] ^ state[c], 12); + state[a] = state[a] + state[b] + y; + state[d] = rotr32(state[d] ^ state[a], 8); + state[c] = state[c] + state[d]; + state[b] = rotr32(state[b] ^ state[c], 7); +} + +INLINE void round_fn(uint32_t state[16], const uint32_t *msg, size_t round) { + // Select the message schedule based on the round. + const uint8_t *schedule = MSG_SCHEDULE[round]; + + // Mix the columns. + g(state, 0, 4, 8, 12, msg[schedule[0]], msg[schedule[1]]); + g(state, 1, 5, 9, 13, msg[schedule[2]], msg[schedule[3]]); + g(state, 2, 6, 10, 14, msg[schedule[4]], msg[schedule[5]]); + g(state, 3, 7, 11, 15, msg[schedule[6]], msg[schedule[7]]); + + // Mix the rows. + g(state, 0, 5, 10, 15, msg[schedule[8]], msg[schedule[9]]); + g(state, 1, 6, 11, 12, msg[schedule[10]], msg[schedule[11]]); + g(state, 2, 7, 8, 13, msg[schedule[12]], msg[schedule[13]]); + g(state, 3, 4, 9, 14, msg[schedule[14]], msg[schedule[15]]); +} + +INLINE void compress_pre(uint32_t state[16], const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags) { + uint32_t block_words[16]; + block_words[0] = load32(block + 4 * 0); + block_words[1] = load32(block + 4 * 1); + block_words[2] = load32(block + 4 * 2); + block_words[3] = load32(block + 4 * 3); + block_words[4] = load32(block + 4 * 4); + block_words[5] = load32(block + 4 * 5); + block_words[6] = load32(block + 4 * 6); + block_words[7] = load32(block + 4 * 7); + block_words[8] = load32(block + 4 * 8); + block_words[9] = load32(block + 4 * 9); + block_words[10] = load32(block + 4 * 10); + block_words[11] = load32(block + 4 * 11); + block_words[12] = load32(block + 4 * 12); + block_words[13] = load32(block + 4 * 13); + block_words[14] = load32(block + 4 * 14); + block_words[15] = load32(block + 4 * 15); + + state[0] = cv[0]; + state[1] = cv[1]; + state[2] = cv[2]; + state[3] = cv[3]; + state[4] = cv[4]; + state[5] = cv[5]; + state[6] = cv[6]; + state[7] = cv[7]; + state[8] = IV[0]; + state[9] = IV[1]; + state[10] = IV[2]; + state[11] = IV[3]; + state[12] = counter_low(counter); + state[13] = counter_high(counter); + state[14] = (uint32_t)block_len; + state[15] = (uint32_t)flags; + + round_fn(state, &block_words[0], 0); + round_fn(state, &block_words[0], 1); + round_fn(state, &block_words[0], 2); + round_fn(state, &block_words[0], 3); + round_fn(state, &block_words[0], 4); + round_fn(state, &block_words[0], 5); + round_fn(state, &block_words[0], 6); +} + +void blake3_compress_in_place_portable(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { + uint32_t state[16]; + compress_pre(state, cv, block, block_len, counter, flags); + cv[0] = state[0] ^ state[8]; + cv[1] = state[1] ^ state[9]; + cv[2] = state[2] ^ state[10]; + cv[3] = state[3] ^ state[11]; + cv[4] = state[4] ^ state[12]; + cv[5] = state[5] ^ state[13]; + cv[6] = state[6] ^ state[14]; + cv[7] = state[7] ^ state[15]; +} + +void blake3_compress_xof_portable(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]) { + uint32_t state[16]; + compress_pre(state, cv, block, block_len, counter, flags); + + store32(&out[0 * 4], state[0] ^ state[8]); + store32(&out[1 * 4], state[1] ^ state[9]); + store32(&out[2 * 4], state[2] ^ state[10]); + store32(&out[3 * 4], state[3] ^ state[11]); + store32(&out[4 * 4], state[4] ^ state[12]); + store32(&out[5 * 4], state[5] ^ state[13]); + store32(&out[6 * 4], state[6] ^ state[14]); + store32(&out[7 * 4], state[7] ^ state[15]); + store32(&out[8 * 4], state[8] ^ cv[0]); + store32(&out[9 * 4], state[9] ^ cv[1]); + store32(&out[10 * 4], state[10] ^ cv[2]); + store32(&out[11 * 4], state[11] ^ cv[3]); + store32(&out[12 * 4], state[12] ^ cv[4]); + store32(&out[13 * 4], state[13] ^ cv[5]); + store32(&out[14 * 4], state[14] ^ cv[6]); + store32(&out[15 * 4], state[15] ^ cv[7]); +} + +INLINE void hash_one_portable(const uint8_t *input, size_t blocks, + const uint32_t key[8], uint64_t counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t out[BLAKE3_OUT_LEN]) { + uint32_t cv[8]; + memcpy(cv, key, BLAKE3_KEY_LEN); + uint8_t block_flags = flags | flags_start; + while (blocks > 0) { + if (blocks == 1) { + block_flags |= flags_end; + } + blake3_compress_in_place_portable(cv, input, BLAKE3_BLOCK_LEN, counter, + block_flags); + input = &input[BLAKE3_BLOCK_LEN]; + blocks -= 1; + block_flags = flags; + } + store_cv_words(out, cv); +} + +void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out) { + while (num_inputs > 0) { + hash_one_portable(inputs[0], blocks, key, counter, flags, flags_start, + flags_end, out); + if (increment_counter) { + counter += 1; + } + inputs += 1; + num_inputs -= 1; + out = &out[BLAKE3_OUT_LEN]; + } +} diff --git a/src/hash.cpp b/src/hash.cpp index 0544468bfd94..bbb652f597b0 100644 --- a/src/hash.cpp +++ b/src/hash.cpp @@ -75,6 +75,11 @@ void BIP32Hash(const ChainCode &chainCode, unsigned int nChild, unsigned char he CHMAC_SHA512(chainCode.begin(), chainCode.size()).Write(&header, 1).Write(data, 32).Write(num, 4).Finalize(output); } +void DIP14Hash(const ChainCode& chainCode, const unsigned char nChild[32], unsigned char header, const unsigned char data[32], unsigned char output[64]) +{ + CHMAC_SHA512(chainCode.begin(), chainCode.size()).Write(&header, 1).Write(data, 32).Write(nChild, 32).Finalize(output); +} + uint256 SHA256Uint256(const uint256& input) { uint256 result; diff --git a/src/hash.h b/src/hash.h index 8db12e811cd4..bba188207066 100644 --- a/src/hash.h +++ b/src/hash.h @@ -242,6 +242,11 @@ unsigned int MurmurHash3(unsigned int nHashSeed, Span vData void BIP32Hash(const ChainCode &chainCode, unsigned int nChild, unsigned char header, const unsigned char data[32], unsigned char output[64]); +/** DIP-14 child key derivation HMAC: like BIP32Hash but with a 256-bit child + * index, serialized big-endian (ser256). Used for Dash Platform (DashPay) + * derivation paths. */ +void DIP14Hash(const ChainCode& chainCode, const unsigned char nChild[32], unsigned char header, const unsigned char data[32], unsigned char output[64]); + /** Return a HashWriter primed for tagged hashes (as specified in BIP 340). * * The returned object will have SHA256(tag) written to it twice (= 64 bytes). diff --git a/src/interfaces/node.h b/src/interfaces/node.h index 21bab8a95ec3..f5d0a44e05db 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -74,6 +74,9 @@ class MnEntry virtual bool isBanned() const = 0; virtual CService getNetInfoPrimary() const = 0; + //! Platform HTTPS (DAPI gateway) endpoints from the extended address + //! list; empty for non-evo masternodes. + virtual std::vector getPlatformHTTPSAddrs() const = 0; virtual MnType getType() const = 0; virtual UniValue toJson() const = 0; virtual const CKeyID& getKeyIdOwner() const = 0; @@ -213,6 +216,19 @@ class LLMQ int32_t m_expiry_height{0}; }; virtual std::vector getQuorumStats() = 0; + struct PlatformQuorum { + uint256 m_quorum_hash{}; + std::vector m_pubkey{}; //!< serialized BLS public key (basic scheme) + int32_t m_height{0}; + }; + //! Locally retained quorums of the given LLMQ type with their public + //! keys. Used by the GUI Platform client to verify Platform state-root + //! quorum signatures against locally synced quorum data. This includes + //! retained signing quorums that are no longer in the active signing set. + virtual std::vector getPlatformQuorums(uint8_t llmq_type) = 0; + //! Serialized InstantSend lock for the given txid, or empty if the + //! transaction has no islock (used to build asset lock proofs). + virtual std::vector getInstantSendLock(const uint256& txid) = 0; virtual void setContext(node::NodeContext* context) {} }; diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 134180f3728a..96bdf15f57f3 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -130,6 +130,51 @@ class Wallet //! Sign special transaction payload virtual bool signSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) = 0; + //! Dash Platform (DIP-13) key classes served by the platform key provider + //! methods below. Raw private keys never cross this interface; the wallet + //! derives keys from its HD seed on demand and only returns public keys, + //! signatures and ECDH secrets. All methods fail when the wallet is + //! locked or has no HD seed, and always fail unless the wallet was built + //! with --enable-platform-gui. + enum class PlatformKeyType { + IdentityAuth, //!< m/9'/coin'/5'/0'/0'/'/' + RegistrationFunding, //!< m/9'/coin'/5'/1'/' + TopupFunding, //!< m/9'/coin'/5'/2'/' + InvitationFunding, //!< m/9'/coin'/5'/3'/' + }; + + //! Derive and return the public key for a platform key. For + //! IdentityAuth, account is the identity index; for funding keys it is + //! ignored. + virtual bool getPlatformPubKey(PlatformKeyType type, uint32_t account, uint32_t index, CPubKey& pubkey_out) = 0; + + //! Sign a 32-byte digest with a platform key (compact/recoverable ECDSA, + //! as used by Platform state transitions). + virtual bool signPlatformDigest(PlatformKeyType type, uint32_t account, uint32_t index, const uint256& digest, std::vector& vchSig) = 0; + + //! ECDH shared secret between the identity authentication key + //! (identity_index, key_index) and a counterparty public key, using the + //! libsecp256k1 ECDH KDF. Used for DashPay contact request encryption. + virtual bool platformECDHSecret(uint32_t identity_index, uint32_t key_index, const CPubKey& counterparty, SecureVector& secret_out) = 0; + + //! DIP-15 friendship extended public key (pubkey + chain code) at + //! m/9'/coin'/15'/'//. For our receiving chain + //! user_a is our identity id and user_b the contact's. + virtual bool getFriendshipXpub(uint32_t account, const uint256& user_a_id, const uint256& user_b_id, CPubKey& pubkey_out, uint256& chaincode_out) = 0; + + //! Import the local private DIP-15 receiving chain for a friendship as a + //! ranged descriptor. The contact's own receiving chain is deliberately + //! never imported (its outputs must not be IsMine, or payments to the + //! contact would decompose as payments-to-self); payment destinations are + //! derived statelessly via getFriendshipPaymentDestination instead. + virtual bool importFriendshipKeychains(uint32_t account, const uint256& my_id, + const uint256& their_id, const std::string& label, std::string& error) = 0; + + //! Derive a contact payment destination from their serialized DIP-15 + //! friendship xpub without advancing any wallet-global keypool. + virtual bool getFriendshipPaymentDestination(const CPubKey& their_pubkey, + const uint256& their_chaincode, uint32_t index, CTxDestination& destination_out) = 0; + //! Return whether wallet has private key. virtual bool isSpendable(const CScript& script) = 0; virtual bool isSpendable(const CTxDestination& dest) = 0; @@ -158,6 +203,14 @@ class Wallet //! Save or remove receive request. virtual bool setAddressReceiveRequest(const CTxDestination& dest, const std::string& id, const std::string& value) = 0; + //! Write (or, with an empty value, erase) a generic Platform GUI data + //! record. Records are persisted in the wallet database and travel with + //! backups; they are opaque to the wallet itself. + virtual bool writePlatformData(const std::string& key, const std::vector& value) = 0; + + //! All Platform GUI data records whose key starts with prefix. + virtual std::map> getPlatformData(const std::string& prefix) = 0; + //! Display address on external signer virtual bool displayAddress(const CTxDestination& dest) = 0; @@ -195,6 +248,15 @@ class Wallet int& change_pos, CAmount& fee) = 0; + //! Create a signed (uncommitted) asset lock transaction converting + //! credit_amount duffs into Platform credits, with a single credit + //! output paying P2PKH to credit_pubkey (typically a registration + //! funding key from getPlatformPubKey). Broadcast the result with + //! commitTransaction(). Fails unless built with --enable-platform-gui. + virtual util::Result createAssetLockTransaction(CAmount credit_amount, + const CPubKey& credit_pubkey, + const wallet::CCoinControl& coin_control) = 0; + //! Commit transaction. virtual void commitTransaction(CTransactionRef tx, WalletValueMap value_map, diff --git a/src/key.cpp b/src/key.cpp index 84a068d71454..19bbe8bbfbf0 100644 --- a/src/key.cpp +++ b/src/key.cpp @@ -13,6 +13,8 @@ #include #include +#include + static secp256k1_context* secp256k1_context_sign = nullptr; /** These functions are taken from the libsecp256k1 distribution and are very ugly. */ @@ -305,6 +307,32 @@ bool CKey::Derive(CKey& keyChild, ChainCode &ccChild, unsigned int nChild, const return ret; } +bool CKey::Derive256(CKey& keyChild, ChainCode& ccChild, Span nChild, bool hardened, const ChainCode& cc) const { + assert(IsValid()); + assert(IsCompressed()); + assert(nChild.size() == 32); + // DIP-14 compatibility mode: indexes below 2^32 derive exactly as BIP32, + // with the hardened flag folded into the high bit of the 32-bit index. + if (std::all_of(nChild.begin(), nChild.begin() + 28, [](unsigned char c) { return c == 0; })) { + uint32_t child32 = ReadBE32(nChild.data() + 28); + return Derive(keyChild, ccChild, child32 | (hardened ? 0x80000000u : 0), cc); + } + std::vector> vout(64); + if (!hardened) { + CPubKey pubkey = GetPubKey(); + assert(pubkey.size() == CPubKey::COMPRESSED_SIZE); + DIP14Hash(cc, nChild.data(), *pubkey.begin(), pubkey.begin() + 1, vout.data()); + } else { + assert(size() == 32); + DIP14Hash(cc, nChild.data(), 0, begin(), vout.data()); + } + memcpy(ccChild.begin(), vout.data() + 32, 32); + keyChild.Set(begin(), begin() + 32, true); + bool ret = secp256k1_ec_seckey_tweak_add(secp256k1_context_sign, (unsigned char*)keyChild.begin(), vout.data()); + if (!ret) keyChild.ClearKeyData(); + return ret; +} + EllSwiftPubKey CKey::EllSwiftCreate(Span ent32) const { assert(keydata); diff --git a/src/key.h b/src/key.h index c4c713c02f24..25c4277dd328 100644 --- a/src/key.h +++ b/src/key.h @@ -157,6 +157,11 @@ class CKey //! Derive BIP32 child key. [[nodiscard]] bool Derive(CKey& keyChild, ChainCode &ccChild, unsigned int nChild, const ChainCode& cc) const; + //! Derive DIP-14 child key with a 256-bit index (32 bytes, big-endian). + //! Indexes below 2^32 fall back to BIP32 derivation for compatibility + //! (the hardened flag is then folded into the 32-bit index). + [[nodiscard]] bool Derive256(CKey& keyChild, ChainCode& ccChild, Span nChild, bool hardened, const ChainCode& cc) const; + /** * Verify thoroughly whether a private key and a public key match. * This is done using a different mechanism than just regenerating it. diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 08587e77a973..da0baa084d62 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -131,6 +131,16 @@ class MnEntryImpl : public MnEntry bool isBanned() const override { return m_dmn->pdmnState->IsBanned(); } CService getNetInfoPrimary() const override { return m_dmn->pdmnState->netInfo->GetPrimary(); } + std::vector getPlatformHTTPSAddrs() const override + { + std::vector ret; + for (const auto& entry : m_dmn->pdmnState->netInfo->GetEntries(NetInfoPurpose::PLATFORM_HTTPS)) { + if (const auto service_opt{entry.GetAddrPort()}) { + ret.push_back(*service_opt); + } + } + return ret; + } MnType getType() const override { return m_dmn->nType; } UniValue toJson() const override { return m_dmn->ToJson(); } const CKeyID& getKeyIdOwner() const override { return m_dmn->pdmnState->keyIDOwner; } @@ -532,6 +542,49 @@ class LLMQImpl : public LLMQ } return stats; } + std::vector getPlatformQuorums(uint8_t llmq_type) override + { + std::vector ret; + if (!context().llmq_ctx || !context().llmq_ctx->qman || !context().chainman) { + return ret; + } + const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; + if (!pindex) { + return ret; + } + const auto type{static_cast(llmq_type)}; + const auto llmq_params{Params().GetLLMQ(type)}; + if (!llmq_params.has_value()) { + return ret; + } + // Drive proofs may be signed by an older Platform quorum while they + // are still consensus-valid and retained locally. Export the full + // retained-key window, not only the current signing-active set. + const auto quorum_count{static_cast(std::max(llmq_params->signingActiveQuorumCount, + llmq_params->keepOldKeys))}; + for (const auto& q : context().llmq_ctx->qman->ScanQuorums(type, pindex, quorum_count)) { + if (!q->qc->quorumPublicKey.IsValid()) continue; + ret.emplace_back(PlatformQuorum{ + .m_quorum_hash = q->qc->quorumHash, + .m_pubkey = q->qc->quorumPublicKey.ToByteVector(/*specificLegacyScheme=*/false), + .m_height = q->m_quorum_base_block_index ? q->m_quorum_base_block_index->nHeight : 0, + }); + } + return ret; + } + std::vector getInstantSendLock(const uint256& txid) override + { + if (!context().llmq_ctx || !context().llmq_ctx->isman) { + return {}; + } + const auto islock{context().llmq_ctx->isman->GetInstantSendLockByTxid(txid)}; + if (!islock) { + return {}; + } + CDataStream ds(SER_NETWORK, PROTOCOL_VERSION); + ds << *islock; + return {UCharCast(ds.data()), UCharCast(ds.data()) + ds.size()}; + } void setContext(NodeContext* context) override { m_context = context; diff --git a/src/platform/README.md b/src/platform/README.md new file mode 100644 index 000000000000..7a0168f92c5b --- /dev/null +++ b/src/platform/README.md @@ -0,0 +1,29 @@ +# 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.*`); +- codecs for the wire formats Platform uses: a bincode-v2 subset, a protobuf + wire-format subset for the DAPI gRPC messages, and DPP (Dash Platform + Protocol) object serialization; +- a GroveDB/merk proof verifier (blake3-based) mirroring the upstream Rust + `verify` feature slice, plus the Tenderdash quorum-signature check that + binds a proof's root hash to a Platform block signed by an LLMQ quorum; +- 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..0fac944dcd0e --- /dev/null +++ b/src/platform/client.h @@ -0,0 +1,125 @@ +// 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{}; +}; + +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/bincode.cpp b/src/platform/dpp/bincode.cpp new file mode 100644 index 000000000000..6059337787e0 --- /dev/null +++ b/src/platform/dpp/bincode.cpp @@ -0,0 +1,168 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +namespace platform::dpp { + +namespace { +constexpr uint8_t VARINT_U16{0xfb}; // 251 +constexpr uint8_t VARINT_U32{0xfc}; // 252 +constexpr uint8_t VARINT_U64{0xfd}; // 253 +constexpr uint8_t VARINT_U128{0xfe}; // 254 (unused by DPP; rejected) + +//! Zigzag encoding as used by bincode-v2 for signed varints. +uint64_t ZigZag(int64_t value) +{ + // Equivalent to (value << 1) ^ (value >> 63) with an arithmetic right + // shift, but without a signed shift (implementation-defined pre-C++20): + // the sign-extended mask is all-ones for negatives, zero otherwise. + const uint64_t sign_mask{value < 0 ? ~uint64_t{0} : uint64_t{0}}; + return (static_cast(value) << 1) ^ sign_mask; +} + +int64_t UnZigZag(uint64_t value) +{ + return static_cast((value >> 1) ^ (~(value & 1) + 1)); +} +} // namespace + +void Writer::WriteVarint(uint64_t value) +{ + if (value < VARINT_U16) { + m_data.push_back(static_cast(value)); + } else if (value <= 0xffff) { + m_data.push_back(VARINT_U16); + m_data.push_back(static_cast(value >> 8)); + m_data.push_back(static_cast(value)); + } else if (value <= 0xffffffff) { + m_data.push_back(VARINT_U32); + for (int shift = 24; shift >= 0; shift -= 8) { + m_data.push_back(static_cast(value >> shift)); + } + } else { + m_data.push_back(VARINT_U64); + for (int shift = 56; shift >= 0; shift -= 8) { + m_data.push_back(static_cast(value >> shift)); + } + } +} + +void Writer::WriteSignedVarint(int64_t value) +{ + WriteVarint(ZigZag(value)); +} + +void Writer::WriteByteVec(Span bytes) +{ + WriteVarint(bytes.size()); + WriteBytes(bytes); +} + +void Writer::WriteString(const std::string& str) +{ + WriteVarint(str.size()); + m_data.insert(m_data.end(), str.begin(), str.end()); +} + +bool Reader::SetError(const std::string& error) +{ + if (m_error.empty()) m_error = error; + return false; +} + +bool Reader::ReadU8(uint8_t& out) +{ + if (HasError()) return false; + if (Remaining() < 1) return SetError("bincode: unexpected end of data"); + out = m_data[m_pos++]; + return true; +} + +bool Reader::ReadInto(Span out) +{ + if (HasError()) return false; + if (Remaining() < out.size()) return SetError("bincode: unexpected end of data"); + std::copy(m_data.begin() + m_pos, m_data.begin() + m_pos + out.size(), out.begin()); + m_pos += out.size(); + return true; +} + +bool Reader::ReadBool(bool& out) +{ + uint8_t byte; + if (!ReadU8(byte)) return false; + if (byte > 1) return SetError(strprintf("bincode: invalid bool byte 0x%02x", byte)); + out = byte == 1; + return true; +} + +bool Reader::ReadVarint(uint64_t& out) +{ + uint8_t first; + if (!ReadU8(first)) return false; + if (first < VARINT_U16) { + out = first; + return true; + } + int width{0}; + switch (first) { + case VARINT_U16: width = 2; break; + case VARINT_U32: width = 4; break; + case VARINT_U64: width = 8; break; + case VARINT_U128: + default: + return SetError(strprintf("bincode: unsupported varint marker 0x%02x", first)); + } + if (Remaining() < static_cast(width)) return SetError("bincode: truncated varint"); + uint64_t value{0}; + for (int i = 0; i < width; ++i) { + value = (value << 8) | m_data[m_pos++]; // big-endian + } + out = value; + return true; +} + +bool Reader::ReadSignedVarint(int64_t& out) +{ + uint64_t raw; + if (!ReadVarint(raw)) return false; + out = UnZigZag(raw); + return true; +} + +bool Reader::ReadByteVec(Bytes& out, size_t max_len) +{ + uint64_t len; + if (!ReadVarint(len)) return false; + if (len > max_len) return SetError(strprintf("bincode: byte vector length %d exceeds limit %d", len, max_len)); + if (Remaining() < len) return SetError("bincode: truncated byte vector"); + out.assign(m_data.begin() + m_pos, m_data.begin() + m_pos + len); + m_pos += len; + return true; +} + +bool Reader::ReadString(std::string& out, size_t max_len) +{ + uint64_t len; + if (!ReadVarint(len)) return false; + if (len > max_len) return SetError(strprintf("bincode: string length %d exceeds limit %d", len, max_len)); + if (Remaining() < len) return SetError("bincode: truncated string"); + out.assign(m_data.begin() + m_pos, m_data.begin() + m_pos + len); + m_pos += len; + return true; +} + +bool Reader::ReadOptionTag(bool& has_value) +{ + uint8_t byte; + if (!ReadU8(byte)) return false; + if (byte > 1) return SetError(strprintf("bincode: invalid Option tag 0x%02x", byte)); + has_value = byte == 1; + return true; +} + +} // namespace platform::dpp diff --git a/src/platform/dpp/bincode.h b/src/platform/dpp/bincode.h new file mode 100644 index 000000000000..6f3599c06285 --- /dev/null +++ b/src/platform/dpp/bincode.h @@ -0,0 +1,101 @@ +// 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_BINCODE_H +#define BITCOIN_PLATFORM_DPP_BINCODE_H + +#include + +#include +#include +#include + +/** + * bincode-v2 codec in the configuration used by rs-platform-serialization + * for every DPP object: bincode::config::standard().with_big_endian(), i.e. + * variable-width integers with BIG-endian payloads. + * + * Source: dashpay/platform (tag v4.0.0) + * packages/rs-platform-serialization-derive/src/{serialize,deserialize}_*.rs — + * every derived PlatformSerialize/PlatformDeserialize impl builds + * `bincode::config::standard().with_big_endian()`. Note this matches the + * GroveDB proof envelope config; bincode's default little-endian standard() + * config is NOT used anywhere on the DPP wire (validated against the + * rs-dpp-generated vectors in src/test/data/platform/). + * + * Varint scheme (bincode "varint" encoding): + * value < 251 -> 1 byte + * value <= 0xffff -> 0xfb marker + u16 big-endian + * value <= 0xffffffff -> 0xfc marker + u32 big-endian + * otherwise -> 0xfd marker + u64 big-endian + * (0xfe marker introduces u128, which DPP never uses; rejected on read) + * Signed integers are zigzag-encoded and then written as unsigned varints. + * Fixed-size arrays are raw bytes without a length prefix; Vec/String + * carry a varint length; Option is one byte (0/1); enum discriminants are + * the varint variant index in declaration order. + */ +namespace platform::dpp { + +using Bytes = std::vector; + +class Writer +{ +public: + void WriteU8(uint8_t byte) { m_data.push_back(byte); } + void WriteBytes(Span bytes) { m_data.insert(m_data.end(), bytes.begin(), bytes.end()); } + void WriteBool(bool value) { m_data.push_back(value ? 1 : 0); } + //! Unsigned bincode varint (big-endian payloads). + void WriteVarint(uint64_t value); + //! Signed bincode varint: zigzag, then WriteVarint. + void WriteSignedVarint(int64_t value); + //! Vec / BinaryData: varint length + raw bytes. + void WriteByteVec(Span bytes); + //! String: varint byte length + UTF-8 bytes. + void WriteString(const std::string& str); + //! Option tag: 1 byte, 0 (None) or 1 (Some; value follows). + void WriteOptionTag(bool has_value) { m_data.push_back(has_value ? 1 : 0); } + + const Bytes& Data() const { return m_data; } + Bytes Take() { return std::move(m_data); } + +private: + Bytes m_data; +}; + +//! Forward-only reader; all Read* methods return false on truncation or +//! malformed input and latch the first error. No exceptions. +class Reader +{ +public: + explicit Reader(Span data) : m_data(data) {} + + size_t Remaining() const { return m_data.size() - m_pos; } + bool IsEof() const { return m_pos == m_data.size(); } + bool HasError() const { return !m_error.empty(); } + const std::string& GetError() const { return m_error; } + //! Records an error (first error wins) and returns false. + bool SetError(const std::string& error); + + bool ReadU8(uint8_t& out); + //! Reads exactly out.size() raw bytes (fixed-size array). + bool ReadInto(Span out); + bool ReadBool(bool& out); + bool ReadVarint(uint64_t& out); + bool ReadSignedVarint(int64_t& out); + //! Vec: varint length (bounded by max_len) + raw bytes. + bool ReadByteVec(Bytes& out, size_t max_len); + //! String: varint length (bounded by max_len) + UTF-8 bytes. + bool ReadString(std::string& out, size_t max_len); + //! Option tag: exactly 0 or 1. + bool ReadOptionTag(bool& has_value); + +private: + Span m_data; + size_t m_pos{0}; + std::string m_error; +}; + +} // namespace platform::dpp + +#endif // BITCOIN_PLATFORM_DPP_BINCODE_H diff --git a/src/platform/dpp/document.cpp b/src/platform/dpp/document.cpp new file mode 100644 index 000000000000..0f21bf1df953 --- /dev/null +++ b/src/platform/dpp/document.cpp @@ -0,0 +1,316 @@ +// 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 { + +Value Value::U32(uint32_t value) +{ + Value ret; + ret.m_kind = ValueKind::U32; + ret.m_int = value; + return ret; +} + +Value Value::U64(uint64_t value) +{ + Value ret; + ret.m_kind = ValueKind::U64; + ret.m_int = value; + return ret; +} + +Value Value::MakeBytes(Bytes bytes) +{ + Value ret; + ret.m_kind = ValueKind::BYTES; + ret.m_bytes = std::move(bytes); + return ret; +} + +Value Value::MakeBytes32(const std::array& bytes) +{ + Value ret; + ret.m_kind = ValueKind::BYTES32; + ret.m_bytes.assign(bytes.begin(), bytes.end()); + return ret; +} + +Value Value::Id(const Identifier& id) +{ + Value ret; + ret.m_kind = ValueKind::IDENTIFIER; + ret.m_bytes.assign(id.begin(), id.end()); + return ret; +} + +Value Value::Text(std::string text) +{ + Value ret; + ret.m_kind = ValueKind::TEXT; + ret.m_text = std::move(text); + return ret; +} + +Value Value::Boolean(bool value) +{ + Value ret; + ret.m_kind = ValueKind::BOOL; + ret.m_bool = value; + return ret; +} + +Value Value::MakeMap(Map entries) +{ + Value ret; + ret.m_kind = ValueKind::MAP; + ret.m_map = std::make_shared(std::move(entries)); + return ret; +} + +void Value::Encode(Writer& writer) const +{ + writer.WriteVarint(static_cast(m_kind)); + switch (m_kind) { + case ValueKind::U32: + case ValueKind::U64: + writer.WriteVarint(m_int); + break; + case ValueKind::BYTES: + writer.WriteByteVec(m_bytes); + break; + case ValueKind::BYTES32: + case ValueKind::IDENTIFIER: + // Fixed-size arrays carry no length prefix. + assert(m_bytes.size() == 32); + writer.WriteBytes(m_bytes); + break; + case ValueKind::TEXT: + writer.WriteString(m_text); + break; + case ValueKind::BOOL: + writer.WriteBool(m_bool); + break; + case ValueKind::MAP: + assert(m_map); + writer.WriteVarint(m_map->size()); + for (const auto& [key, value] : *m_map) { + key.Encode(writer); + value.Encode(writer); + } + break; + default: + assert(false); // unsupported Value kind + } +} + +void EncodeDocumentData(Writer& writer, const DocumentData& data) +{ + writer.WriteVarint(data.size()); + for (const auto& [key, value] : data) { + writer.WriteString(key); + value.Encode(writer); + } +} + +Identifier GenerateDocumentId(const Identifier& contract_id, + const Identifier& owner_id, + const std::string& document_type_name, + Span entropy) +{ + CHash256 hasher; + hasher.Write(contract_id); + hasher.Write(owner_id); + hasher.Write(MakeUCharSpan(document_type_name)); + hasher.Write(entropy); + Identifier ret; + uint256 hash; + hasher.Finalize(hash); + std::copy(hash.begin(), hash.end(), ret.begin()); + return ret; +} + +// ----------------------------------------------------------------------------- +// Stored-document decoders. These are deliberately schema-specific, but they +// follow rs-dpp DocumentV0::{serialize_v0,v1,v2} exactly: bincode varints for +// version/revision and variable lengths, a big-endian timestamp bitmap, then +// contract properties in their declared position order. Optional properties +// carry a one-byte presence marker; fixed byte arrays do not carry a length. +// ----------------------------------------------------------------------------- + +namespace { +class StoredDocumentReader +{ +public: + explicit StoredDocumentReader(Span bytes) : m_bytes(bytes) {} + + bool U8(uint8_t& out) { if (m_pos == m_bytes.size()) return false; out = m_bytes[m_pos++]; return true; } + bool Bytes(size_t count, std::vector& out) { + if (count > Remaining()) return false; + out.assign(m_bytes.begin() + m_pos, m_bytes.begin() + m_pos + count); m_pos += count; return true; + } + bool IdentifierValue(Identifier& out) { + if (out.size() > Remaining()) return false; + std::copy(m_bytes.begin() + m_pos, m_bytes.begin() + m_pos + out.size(), out.begin()); + m_pos += out.size(); return true; + } + bool Varint(uint64_t& out) { + uint8_t first; + if (!U8(first)) return false; + if (first < 0xfb) { out = first; return true; } + const size_t width = first == 0xfb ? 2 : first == 0xfc ? 4 : first == 0xfd ? 8 : 0; + if (width == 0 || width > Remaining()) return false; + out = 0; + for (size_t i = 0; i < width; ++i) out = (out << 8) | m_bytes[m_pos++]; + return true; + } + bool BE16(uint16_t& out) { + if (Remaining() < 2) return false; + out = (uint16_t{m_bytes[m_pos]} << 8) | m_bytes[m_pos + 1]; m_pos += 2; return true; + } + bool BE32(uint32_t& out) { + if (Remaining() < 4) return false; + out = 0; for (int i = 0; i < 4; ++i) out = (out << 8) | m_bytes[m_pos++]; return true; + } + bool BE64(uint64_t& out) { + if (Remaining() < 8) return false; + out = 0; for (int i = 0; i < 8; ++i) out = (out << 8) | m_bytes[m_pos++]; return true; + } + bool String(std::string& out, size_t max) { + uint64_t len; + if (!Varint(len) || len > max || len > Remaining()) return false; + out.assign(m_bytes.begin() + m_pos, m_bytes.begin() + m_pos + len); m_pos += len; return true; + } + bool Optional(bool& present) { uint8_t marker; if (!U8(marker) || marker > 1) return false; present = marker == 1; return true; } + bool Skip(size_t count) { if (count > Remaining()) return false; m_pos += count; return true; } + size_t Position() const { return m_pos; } + size_t Remaining() const { return m_bytes.size() - m_pos; } +private: + Span m_bytes; + size_t m_pos{0}; +}; + +bool Header(StoredDocumentReader& reader, bool revisioned, bool transferable, uint64_t& version, + Identifier& id, Identifier& owner, uint64_t& revision, + uint16_t& flags) +{ + revision = 0; + if (!reader.Varint(version) || version > 2 || !reader.IdentifierValue(id) || + !reader.IdentifierValue(owner)) return false; + if (version >= 2 && transferable) { + bool creator_present; + if (!reader.Optional(creator_present) || (creator_present && !reader.Skip(32))) return false; + } + return (!revisioned || reader.Varint(revision)) && reader.BE16(flags); +} + +bool SkipTimes(StoredDocumentReader& reader, uint16_t flags, Profile* profile = nullptr, + ContactRequest* contact = nullptr) +{ + for (uint16_t bit = 1; bit <= 32; bit <<= 1) { + if (!(flags & bit)) continue; + uint64_t value; + if (!reader.BE64(value)) return false; + if (profile && bit == 1) profile->created_at = value; + if (profile && bit == 2) profile->updated_at = value; + if (contact && bit == 1) contact->created_at = value; + } + for (uint16_t bit = 64; bit <= 256; bit <<= 1) { + if (!(flags & bit)) continue; + uint32_t value; + if (!reader.BE32(value)) return false; + if (contact && bit == 64) contact->core_height_created_at = value; + } + return true; +} +} // namespace + +size_t DecodeDocumentHeader(Span doc, Identifier& id_out, Identifier& owner_out, uint64_t& revision_out) +{ + StoredDocumentReader reader{doc}; + uint64_t version; + uint16_t flags; + if (!Header(reader, true, false, version, id_out, owner_out, revision_out, flags)) return 0; + return reader.Position(); +} + +bool DecodeDpnsDomain(Span doc, DpnsName& out) +{ + StoredDocumentReader r{doc}; + uint64_t version, revision; + uint16_t flags; + Identifier owner; + if (!Header(r, true, true, version, out.document_id, owner, revision, flags) || !SkipTimes(r, flags)) return false; + // DPNS domains use seller-set trade mode in protocol 12, so a price + // presence marker follows timestamps even when no sale price is set. + bool price_present; + if (!r.Optional(price_present) || (price_present && !r.Skip(8))) return false; + if (!r.String(out.label, 63) || !r.String(out.normalized_label, 63)) return false; + bool parent_present; + if (!r.Optional(parent_present)) return false; + std::string display_parent; + if (parent_present && !r.String(display_parent, 63)) return false; + if (!r.String(out.parent_domain, 63)) return false; + // preorderSalt is required on document creation but transient in the + // contract, so Drive omits its value from stored documents. The schema + // slot still carries the optional-property marker; tolerate a value for + // older protocol fixtures, but do not require one. + bool salt_present; + if (!r.Optional(salt_present) || (salt_present && !r.Skip(32))) return false; + uint64_t records_len; + if (!r.Varint(records_len) || records_len > r.Remaining()) return false; + bool identity_present; + if (!r.Optional(identity_present) || !identity_present || !r.IdentifierValue(out.identity)) return false; + // The records object currently contains only identity; consume any future + // bytes within the length before parsing subdomainRules. + if (records_len < 33 || !r.Skip(records_len - 33)) return false; + uint64_t rules_len; + uint8_t allow_subdomains; + return r.Varint(rules_len) && rules_len == 1 && r.U8(allow_subdomains) && allow_subdomains <= 1; +} + +bool DecodeDashPayProfile(Span doc, Profile& out) +{ + StoredDocumentReader r{doc}; + uint64_t version; + uint16_t flags; + if (!Header(r, true, false, version, out.document_id, out.owner_id, out.revision, flags) || + !SkipTimes(r, flags, &out)) return false; + bool present; + if (!r.Optional(present) || (present && !r.String(out.avatar_url, 2048))) return false; + if (!r.Optional(present) || (present && !r.Bytes(32, out.avatar_hash))) return false; + if (!r.Optional(present) || (present && !r.Bytes(8, out.avatar_fingerprint))) return false; + if (!r.Optional(present) || (present && !r.String(out.public_message, 140))) return false; + if (!r.Optional(present) || (present && !r.String(out.display_name, 25))) return false; + return true; +} + +bool DecodeDashPayContactRequest(Span doc, ContactRequest& out) +{ + StoredDocumentReader r{doc}; + uint64_t version, unused_revision; + uint16_t flags; + if (!Header(r, false, false, version, out.document_id, out.owner_id, unused_revision, flags) || + !SkipTimes(r, flags, nullptr, &out) || !r.IdentifierValue(out.to_user_id) || + !r.Bytes(96, out.encrypted_public_key)) return false; + uint64_t value; + // The unbounded JSON-schema integer fields are I64 in DPP. Stored v0 + // encoded all integer properties as I64 as well, so all versions use 8B. + if (!r.BE64(value) || value > UINT32_MAX) return false; out.sender_key_index = value; + if (!r.BE64(value) || value > UINT32_MAX) return false; out.recipient_key_index = value; + if (!r.BE64(value) || value > UINT32_MAX) return false; out.account_reference = value; + bool present; + if (!r.Optional(present)) return false; + if (present) { uint64_t len; if (!r.Varint(len) || len < 48 || len > 80 || !r.Bytes(len, out.encrypted_account_label)) return false; } + if (!r.Optional(present)) return false; + if (present) { uint64_t len; std::vector ignored; if (!r.Varint(len) || len < 38 || len > 102 || !r.Bytes(len, ignored)) return false; } + return true; +} + +} // namespace platform::dpp diff --git a/src/platform/dpp/document.h b/src/platform/dpp/document.h new file mode 100644 index 000000000000..72a16da854f4 --- /dev/null +++ b/src/platform/dpp/document.h @@ -0,0 +1,126 @@ +// 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 + +#include +#include +#include +#include +#include +#include +#include + +/** + * platform_value::Value encoding for document properties, restricted to the + * kinds the DPNS and DashPay document types need. Mirrors dashpay/platform + * (tag v4.0.0) packages/rs-platform-value/src/lib.rs: `Value` is a plain + * bincode Encode enum, so each value is the varint variant index in + * declaration order followed by the payload. + * + * Document create/replace transitions carry the user properties as + * BTreeMap (rs-dpp + * .../document_create_transition/v0/mod.rs `data`), which bincode encodes as + * a varint entry count followed by (key, value) pairs in ascending byte-wise + * key order — matched here by std::map. + */ +namespace platform::dpp { + +//! Variant indexes of platform_value::Value (declaration order in +//! rs-platform-value/src/lib.rs). +enum class ValueKind : uint8_t { + U128 = 0, + I128 = 1, + U64 = 2, + I64 = 3, + U32 = 4, + I32 = 5, + U16 = 6, + I16 = 7, + U8 = 8, + I8 = 9, + BYTES = 10, + BYTES20 = 11, + BYTES32 = 12, + BYTES36 = 13, + ENUM_U8 = 14, + ENUM_STRING = 15, + IDENTIFIER = 16, + FLOAT = 17, + TEXT = 18, + BOOL = 19, + NULL_VALUE = 20, + ARRAY = 21, + MAP = 22, +}; + +//! Subset of platform_value::Value used by the four GUI document types. +class Value +{ +public: + using Map = std::vector>; + + static Value U32(uint32_t value); + static Value U64(uint64_t value); + static Value MakeBytes(Bytes bytes); + static Value MakeBytes32(const std::array& bytes); + static Value Id(const Identifier& id); + static Value Text(std::string text); + static Value Boolean(bool value); + static Value MakeMap(Map entries); + + void Encode(Writer& writer) const; + +private: + Value() = default; + + ValueKind m_kind{ValueKind::NULL_VALUE}; + uint64_t m_int{0}; + bool m_bool{false}; + Bytes m_bytes; //!< BYTES / BYTES32 / IDENTIFIER payload + std::string m_text; //!< TEXT payload + std::shared_ptr m_map; //!< MAP payload +}; + +//! Document properties in BTreeMap order. +using DocumentData = std::map; + +//! Encodes a document data map: varint count + sorted (key, value) pairs. +void EncodeDocumentData(Writer& writer, const DocumentData& data); + +//! Document id: DSHA256(contract_id || owner_id || type_name || entropy), +//! per rs-dpp src/document/generate_document_id.rs +//! Document::generate_document_id_v0. +Identifier GenerateDocumentId(const Identifier& contract_id, + const Identifier& owner_id, + const std::string& document_type_name, + Span entropy); + +//! Decode a stored (platform-serialized) document's common header: +//! version byte, $id (32), $ownerId (32), revision (varint). Returns the +//! offset just past the header, or 0 on malformed input. +size_t DecodeDocumentHeader(Span doc, Identifier& id_out, + Identifier& owner_out, uint64_t& revision_out); + +//! Decode the GUI-relevant fields of a DPNS `domain` document. This is a +//! targeted decoder over the known DPNS v1 property order (label, +//! normalizedLabel, parentDomainName, normalizedParentDomainName, +//! preorderSalt, records.identity); it is not a general contract-schema +//! driven decoder. 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..7f53eb781d72 --- /dev/null +++ b/src/platform/dpp/identity.cpp @@ -0,0 +1,159 @@ +// 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::dpp { + +namespace { + +//! rs-dpp limits: identity 15000 bytes, standalone key 2000 bytes. +constexpr size_t MAX_IDENTITY_SIZE{15000}; +constexpr size_t MAX_KEY_SIZE{2000}; +constexpr size_t MAX_KEY_DATA{256}; +constexpr size_t MAX_KEYS{100}; +constexpr size_t MAX_DOCUMENT_TYPE_NAME{256}; + +//! Reads one IdentityPublicKey (enum { V0 } + IdentityPublicKeyV0 fields). +bool ReadIdentityPublicKey(Reader& reader, platform::IdentityPublicKey& out) +{ + uint64_t version; + if (!reader.ReadVarint(version)) return false; + if (version != 0) return reader.SetError(strprintf("unsupported IdentityPublicKey version %d", version)); + + uint64_t id, purpose, security_level, key_type; + if (!reader.ReadVarint(id)) return false; + if (id > std::numeric_limits::max()) return reader.SetError("key id out of range"); + if (!reader.ReadVarint(purpose)) return false; + if (purpose > 6) return reader.SetError(strprintf("unknown key purpose %d", purpose)); + if (!reader.ReadVarint(security_level)) return false; + if (security_level > 3) return reader.SetError(strprintf("unknown key security level %d", security_level)); + + // contract_bounds: Option; both variants start with the + // bound contract id, SingleContractDocumentType adds a type name. The + // GUI types do not surface bounds, so the payload is validated and + // skipped. + bool has_bounds; + if (!reader.ReadOptionTag(has_bounds)) return false; + if (has_bounds) { + uint64_t bounds_kind; + if (!reader.ReadVarint(bounds_kind)) return false; + if (bounds_kind > 1) return reader.SetError(strprintf("unknown contract bounds kind %d", bounds_kind)); + Identifier bound_contract; + if (!reader.ReadInto(bound_contract)) return false; + if (bounds_kind == 1) { + std::string document_type_name; + if (!reader.ReadString(document_type_name, MAX_DOCUMENT_TYPE_NAME)) return false; + } + } + + if (!reader.ReadVarint(key_type)) return false; + if (key_type > 4) return reader.SetError(strprintf("unknown key type %d", key_type)); + + bool read_only; + if (!reader.ReadBool(read_only)) return false; + + Bytes data; + if (!reader.ReadByteVec(data, MAX_KEY_DATA)) return false; + + bool has_disabled_at; + if (!reader.ReadOptionTag(has_disabled_at)) return false; + uint64_t disabled_at{0}; + if (has_disabled_at && !reader.ReadVarint(disabled_at)) return false; + + out.id = static_cast(id); + out.purpose = static_cast(purpose); + out.security_level = static_cast(security_level); + out.type = static_cast(key_type); + out.read_only = read_only; + out.data = std::move(data); + out.disabled_at = has_disabled_at ? std::optional{disabled_at} : std::nullopt; + return true; +} + +} // namespace + +std::optional DecodeIdentity(Span bytes, std::string& error) +{ + if (bytes.size() > MAX_IDENTITY_SIZE) { + error = "identity too large"; + return std::nullopt; + } + Reader reader{bytes}; + uint64_t version; + if (!reader.ReadVarint(version) || version != 0) { + error = reader.HasError() ? reader.GetError() : strprintf("unsupported Identity version %d", version); + return std::nullopt; + } + + platform::Identity identity; + if (!reader.ReadInto(identity.id)) { + error = reader.GetError(); + return std::nullopt; + } + + uint64_t num_keys; + if (!reader.ReadVarint(num_keys) || num_keys > MAX_KEYS) { + error = reader.HasError() ? reader.GetError() : "too many identity keys"; + return std::nullopt; + } + std::optional last_key_id; + for (uint64_t i = 0; i < num_keys; ++i) { + uint64_t map_key_id; + platform::IdentityPublicKey key; + if (!reader.ReadVarint(map_key_id) || !ReadIdentityPublicKey(reader, key)) { + error = reader.GetError(); + return std::nullopt; + } + // BTreeMap keys are strictly ascending. + if (last_key_id && map_key_id <= *last_key_id) { + error = "identity key ids not strictly ascending"; + return std::nullopt; + } + last_key_id = map_key_id; + if (map_key_id != key.id) { + error = strprintf("identity key map id %d does not match key id %d", map_key_id, key.id); + return std::nullopt; + } + identity.public_keys.push_back(std::move(key)); + } + + if (!reader.ReadVarint(identity.balance) || !reader.ReadVarint(identity.revision)) { + error = reader.GetError(); + return std::nullopt; + } + if (!reader.IsEof()) { + error = "trailing bytes after identity"; + return std::nullopt; + } + return identity; +} + +std::optional DecodeIdentityPublicKey(Span bytes, + std::string& error) +{ + if (bytes.size() > MAX_KEY_SIZE) { + error = "identity public key too large"; + return std::nullopt; + } + Reader reader{bytes}; + platform::IdentityPublicKey key; + if (!ReadIdentityPublicKey(reader, key)) { + error = reader.GetError(); + return std::nullopt; + } + if (!reader.IsEof()) { + error = "trailing bytes after identity public key"; + return std::nullopt; + } + return key; +} + +} // namespace platform::dpp diff --git a/src/platform/dpp/identity.h b/src/platform/dpp/identity.h new file mode 100644 index 000000000000..b5c38fa19a43 --- /dev/null +++ b/src/platform/dpp/identity.h @@ -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. + +#ifndef BITCOIN_PLATFORM_DPP_IDENTITY_H +#define BITCOIN_PLATFORM_DPP_IDENTITY_H + +#include +#include + +#include +#include + +/** + * Decoding of platform-serialized Identity / IdentityPublicKey objects + * (bincode standard()+big_endian(), see platform/dpp/bincode.h) into the + * platform::Identity / platform::IdentityPublicKey GUI types. + * + * Wire layout mirrors dashpay/platform (tag v4.0.0): + * - Identity: rs-dpp/src/identity/identity.rs — enum { V0 } with + * #[platform_serialize(limit = 15000, unversioned)]; IdentityV0 is + * { id: Identifier, public_keys: BTreeMap, + * balance: u64, revision: u64 } (rs-dpp/src/identity/v0/mod.rs). + * - IdentityPublicKey: rs-dpp/src/identity/identity_public_key/mod.rs — + * enum { V0 } with #[platform_serialize(limit = 2000, unversioned)]; + * IdentityPublicKeyV0 is { id, purpose, security_level, + * contract_bounds: Option, key_type, read_only, + * data: BinaryData, disabled_at: Option } + * (rs-dpp/src/identity/identity_public_key/v0/mod.rs). + */ +namespace platform::dpp { + +//! Decodes a platform-serialized Identity. Returns std::nullopt and sets +//! error on malformed input (including trailing bytes). +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..e365c2e3a8c3 --- /dev/null +++ b/src/platform/dpp/statetransitions.cpp @@ -0,0 +1,528 @@ +// 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 + +/** + * DPP state transition construction, byte-exact with dashpay/platform tag + * v4.0.0 (Platform protocol version 12), validated against rs-dpp-generated + * vectors in src/test/data/platform/dpp_st_vectors.json. + * + * Wire layout (bincode standard()+big_endian(), see platform/dpp/bincode.h): + * - StateTransition (rs-dpp/src/state_transition/mod.rs) is an unversioned + * enum; variant indexes: DataContractCreate=0, DataContractUpdate=1, + * Batch=2, IdentityCreate=3, ... + * - Batch document transitions serialize as BatchTransition::V1 with + * DocumentBaseTransition::V1 (token_payment_info = None): protocol + * versions 9..=12 all pin STATE_TRANSITION_SERIALIZATION_VERSIONS_V2 + * (rs-platform-version/src/version/dpp_versions/ + * dpp_state_transition_serialization_versions/v2.rs) whose + * batch_state_transition/document_base_state_transition + * default_current_version is 1. + * - Signing (rust-dashcore dash/src/signer.rs, rs-dpp state_transition + * sign_by_private_key): digest = double-SHA256 of the transition's + * signable bytes (the serialization with every + * #[platform_signable(exclude_from_sig_hash)] field omitted); the + * signature is the 65-byte compact recoverable ECDSA form whose header + * byte is 27 + recovery_id + 4 (compressed-pubkey convention). + */ +namespace platform::st { + +namespace { + +using dpp::Bytes; +using dpp::DocumentData; +using dpp::Value; +using dpp::Writer; + +//! StateTransition enum variant indexes. +constexpr uint64_t ST_BATCH{2}; +constexpr uint64_t ST_IDENTITY_CREATE{3}; +//! BatchTransition::V1 (BatchedTransition vector; protocol >= 9). +constexpr uint64_t BATCH_V1{1}; +//! BatchedTransition::Document / ::Token variant indexes. +constexpr uint64_t BATCHED_DOCUMENT{0}; +//! DocumentTransition variant indexes. +constexpr uint64_t DOC_CREATE{0}; +constexpr uint64_t DOC_REPLACE{1}; +//! DocumentBaseTransition::V1 (adds Option). +constexpr uint64_t DOC_BASE_V1{1}; +//! AssetLockProof variant indexes. +constexpr uint64_t PROOF_INSTANT{0}; +constexpr uint64_t PROOF_CHAIN{1}; + +constexpr size_t COMPACT_SIG_SIZE{65}; +constexpr size_t COMPRESSED_PUBKEY_SIZE{33}; + +//! Vote-resolution fund attached to contested DPNS domain creates: +//! rs-platform-version .../fee/vote_resolution_fund_fees/v1.rs +//! contested_document_vote_resolution_fund_required_amount (0.2 DASH in +//! credits), keyed by the contested unique index of the DPNS domain type. +constexpr uint64_t CONTESTED_DOCUMENT_FUND_CREDITS{20000000000}; +const std::string CONTESTED_INDEX_NAME{"parentNameAndLabel"}; + +uint256 DoubleSha(Span data) +{ + uint256 out; + CHash256().Write(data).Finalize(out); + return out; +} + +uint256 SingleSha(Span data) +{ + uint256 out; + CSHA256().Write(data.data(), data.size()).Finalize(out.begin()); + 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); +} + +//! One document create/replace inside a Batch transition. +struct DocumentSpec { + bool is_replace{false}; + Identifier document_id{}; + uint64_t identity_contract_nonce{0}; + std::string document_type_name; + Identifier data_contract_id{}; + std::array entropy{}; //!< create only + uint64_t revision{0}; //!< replace only + DocumentData data; + //! (index name, credits); create only. + std::optional> prefunded_voting_balance; +}; + +//! Serializes a single-document BatchTransition::V1. With signature == +//! nullptr the signable form is produced (signature_public_key_id and +//! signature are #[platform_signable(exclude_from_sig_hash)] in +//! BatchTransitionV1). +Bytes EncodeDocumentBatch(const Identifier& owner_id, + const DocumentSpec& spec, + uint32_t signature_public_key_id, + const Bytes* signature) +{ + Writer writer; + writer.WriteVarint(ST_BATCH); + writer.WriteVarint(BATCH_V1); + writer.WriteBytes(owner_id); + writer.WriteVarint(1); // transitions: Vec + writer.WriteVarint(BATCHED_DOCUMENT); + writer.WriteVarint(spec.is_replace ? DOC_REPLACE : DOC_CREATE); + writer.WriteVarint(0); // DocumentCreateTransition::V0 / DocumentReplaceTransition::V0 + // DocumentBaseTransition::V1 + writer.WriteVarint(DOC_BASE_V1); + writer.WriteBytes(spec.document_id); + writer.WriteVarint(spec.identity_contract_nonce); + writer.WriteString(spec.document_type_name); + writer.WriteBytes(spec.data_contract_id); + writer.WriteOptionTag(false); // token_payment_info + if (spec.is_replace) { + writer.WriteVarint(spec.revision); + EncodeDocumentData(writer, spec.data); + } else { + writer.WriteBytes(spec.entropy); + EncodeDocumentData(writer, spec.data); + writer.WriteOptionTag(spec.prefunded_voting_balance.has_value()); + if (spec.prefunded_voting_balance) { + writer.WriteString(spec.prefunded_voting_balance->first); + writer.WriteVarint(spec.prefunded_voting_balance->second); + } + } + writer.WriteVarint(0); // user_fee_increase + if (signature != nullptr) { + writer.WriteVarint(signature_public_key_id); + writer.WriteByteVec(*signature); + } + return writer.Take(); +} + +Result SignDocumentBatch(const Identifier& owner_id, + const DocumentSpec& spec, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (!signer) return {std::nullopt, "no signer provided"}; + const Bytes signable{EncodeDocumentBatch(owner_id, spec, signature_public_key_id, nullptr)}; + const uint256 digest{DoubleSha(signable)}; + Bytes signature; + if (!signer(digest, signature)) { + return {std::nullopt, "signing failed (wallet locked or key unavailable)"}; + } + if (signature.size() != COMPACT_SIG_SIZE) { + return {std::nullopt, strprintf("unexpected signature size %d (want %d)", signature.size(), COMPACT_SIG_SIZE)}; + } + BuiltTransition built; + built.bytes = EncodeDocumentBatch(owner_id, spec, signature_public_key_id, &signature); + built.hash = SingleSha(built.bytes); + return {std::move(built), ""}; +} + +//! Serializes an IdentityCreateTransition (::V0). With signatures == nullptr +//! the signable form is produced: per-key signatures, the outer signature +//! and the identity id are all excluded from the signable bytes +//! (rs-dpp .../identity_create_transition/v0/mod.rs, .../public_key_in_creation/v0/mod.rs). +Bytes EncodeIdentityCreate(const std::vector& keys, + const std::variant& proof, + const std::vector* key_signatures, + const Bytes* signature, + const Identifier* identity_id) +{ + Writer writer; + writer.WriteVarint(ST_IDENTITY_CREATE); + writer.WriteVarint(0); // IdentityCreateTransition::V0 + writer.WriteVarint(keys.size()); + for (size_t i = 0; i < keys.size(); ++i) { + const NewIdentityKey& key{keys[i]}; + // IdentityPublicKeyInCreation::V0; field order differs from + // IdentityPublicKeyV0 (key_type before purpose). + writer.WriteVarint(0); + writer.WriteVarint(key.id); + writer.WriteVarint(0); // KeyType::ECDSA_SECP256K1 + writer.WriteVarint(static_cast(key.purpose)); + writer.WriteVarint(static_cast(key.security_level)); + writer.WriteOptionTag(false); // contract_bounds + writer.WriteBool(false); // read_only + writer.WriteByteVec(key.pubkey); + if (key_signatures != nullptr) writer.WriteByteVec((*key_signatures)[i]); + } + if (const auto* instant = std::get_if(&proof)) { + // AssetLockProof::Instant serializes through serde as + // RawInstantLockProof { instant_lock, transaction, output_index } + // with the lock and transaction as raw consensus bytes + // (rs-dpp .../asset_lock_proof/instant/instant_asset_lock_proof.rs). + writer.WriteVarint(PROOF_INSTANT); + writer.WriteByteVec(instant->instant_lock); + writer.WriteByteVec(instant->transaction); + writer.WriteVarint(instant->output_index); + } else { + const auto& chain = std::get(proof); + // AssetLockProof::Chain serializes through serde as + // { core_chain_locked_height, out_point: { txid: bytes, vout } }. + writer.WriteVarint(PROOF_CHAIN); + writer.WriteVarint(chain.core_chain_locked_height); + writer.WriteByteVec(Span{chain.out_point}.first(32)); + writer.WriteVarint(ReadLE32(chain.out_point.data() + 32)); + } + writer.WriteVarint(0); // user_fee_increase + if (signature != nullptr) { + writer.WriteByteVec(*signature); + writer.WriteBytes(*identity_id); + } + return writer.Take(); +} + +} // 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"}; + + // rs-dpp registers Identity::public_keys() (a BTreeMap keyed by id), so + // the keys serialize in ascending id order with no duplicates. + std::vector sorted_keys{keys}; + std::sort(sorted_keys.begin(), sorted_keys.end(), + [](const NewIdentityKey& a, const NewIdentityKey& b) { return a.id < b.id; }); + for (size_t i = 0; i < sorted_keys.size(); ++i) { + const NewIdentityKey& key{sorted_keys[i]}; + if (i > 0 && key.id == sorted_keys[i - 1].id) { + return {std::nullopt, strprintf("duplicate identity key id %d", key.id)}; + } + if (key.pubkey.size() != COMPRESSED_PUBKEY_SIZE) { + return {std::nullopt, strprintf("identity key %d: unexpected public key size %d", key.id, key.pubkey.size())}; + } + if (!key.signer) return {std::nullopt, strprintf("identity key %d: no signer", key.id)}; + } + + // Derive the identity id from the asset lock outpoint. + Identifier identity_id; + if (const auto* instant = std::get_if(&proof)) { + // The txid of a Dash special transaction is the double-SHA256 of its + // full serialization (including the extra payload), so the asset + // lock transaction does not need to be re-parsed here. + const uint256 txid{DoubleSha(instant->transaction)}; + std::array out_point; + std::copy(txid.begin(), txid.end(), out_point.begin()); + WriteLE32(out_point.data() + 32, instant->output_index); + identity_id = IdentityIdFromOutpoint(out_point); + } else { + identity_id = IdentityIdFromOutpoint(std::get(proof).out_point); + } + + // Every registered key proves ownership by signing the same digest as + // the asset-lock key: the double-SHA256 of the signable bytes (rs-dpp + // IdentityCreateTransitionMethodsV0::try_from_identity_with_signer_and_private_key). + const Bytes signable{EncodeIdentityCreate(sorted_keys, proof, nullptr, nullptr, nullptr)}; + const uint256 digest{DoubleSha(signable)}; + + std::vector key_signatures; + key_signatures.reserve(sorted_keys.size()); + for (const NewIdentityKey& key : sorted_keys) { + Bytes sig; + if (!key.signer(digest, sig)) { + return {std::nullopt, strprintf("identity key %d: signing failed", key.id)}; + } + if (sig.size() != COMPACT_SIG_SIZE) { + return {std::nullopt, strprintf("identity key %d: unexpected signature size %d", key.id, sig.size())}; + } + key_signatures.push_back(std::move(sig)); + } + Bytes asset_lock_signature; + if (!asset_lock_signer(digest, asset_lock_signature)) { + return {std::nullopt, "asset lock key: signing failed"}; + } + if (asset_lock_signature.size() != COMPACT_SIG_SIZE) { + return {std::nullopt, strprintf("asset lock key: unexpected signature size %d", asset_lock_signature.size())}; + } + + BuiltTransition built; + built.bytes = EncodeIdentityCreate(sorted_keys, proof, &key_signatures, &asset_lock_signature, &identity_id); + built.hash = SingleSha(built.bytes); + return {std::move(built), ""}; +} + +Result BuildDpnsPreorder( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::array& salted_domain_hash, + uint32_t signature_public_key_id, + const Signer& signer) +{ + DocumentSpec spec; + spec.identity_contract_nonce = identity_contract_nonce; + spec.document_type_name = "preorder"; + spec.data_contract_id = DPNS_CONTRACT_ID; + // The preorder document has no natural id source; the salted domain + // hash is already blinded and unique per (name, salt), so it doubles as + // the document entropy. + spec.entropy = salted_domain_hash; + spec.document_id = dpp::GenerateDocumentId(spec.data_contract_id, identity, + spec.document_type_name, spec.entropy); + spec.data.emplace("saltedDomainHash", Value::MakeBytes32(salted_domain_hash)); + return SignDocumentBatch(identity, spec, signature_public_key_id, signer); +} + +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 (label.empty() || normalized_label.empty() || parent_domain.empty()) { + return {std::nullopt, "empty DPNS label or parent domain"}; + } + if (NormalizeLabel(label) != normalized_label) { + return {std::nullopt, "normalized label does not match label"}; + } + DocumentSpec spec; + spec.identity_contract_nonce = identity_contract_nonce; + spec.document_type_name = "domain"; + spec.data_contract_id = DPNS_CONTRACT_ID; + // The preorder salt is drawn fresh per registration attempt, making it a + // suitable deterministic document entropy for the paired domain create. + spec.entropy = preorder_salt; + spec.document_id = dpp::GenerateDocumentId(spec.data_contract_id, identity, + spec.document_type_name, spec.entropy); + spec.data.emplace("label", Value::Text(label)); + spec.data.emplace("normalizedLabel", Value::Text(normalized_label)); + spec.data.emplace("parentDomainName", Value::Text(parent_domain)); + spec.data.emplace("normalizedParentDomainName", Value::Text(NormalizeLabel(parent_domain))); + spec.data.emplace("preorderSalt", Value::MakeBytes32(preorder_salt)); + spec.data.emplace("records", Value::MakeMap({{Value::Text("identity"), Value::Id(identity)}})); + spec.data.emplace("subdomainRules", Value::MakeMap({{Value::Text("allowSubdomains"), Value::Boolean(false)}})); + // Contested names must prefund the masternode vote resolution; rs-dpp + // fills this from the contested unique index of the DPNS domain type + // (DocumentTypeV0Methods::prefunded_voting_balance_for_document_v0). + if (IsContestedLabel(normalized_label)) { + spec.prefunded_voting_balance = {CONTESTED_INDEX_NAME, CONTESTED_DOCUMENT_FUND_CREDITS}; + } + return SignDocumentBatch(identity, spec, signature_public_key_id, signer); +} + +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 (!profile.avatar_hash.empty() && profile.avatar_hash.size() != 32) { + return {std::nullopt, "avatar hash must be 32 bytes"}; + } + if (!profile.avatar_fingerprint.empty() && profile.avatar_fingerprint.size() != 8) { + return {std::nullopt, "avatar fingerprint must be 8 bytes"}; + } + DocumentSpec spec; + spec.identity_contract_nonce = identity_contract_nonce; + spec.document_type_name = "profile"; + spec.data_contract_id = DASHPAY_CONTRACT_ID; + if (existing_document_id) { + if (revision < 2) return {std::nullopt, "profile replace requires revision > 1"}; + spec.is_replace = true; + spec.document_id = *existing_document_id; + spec.revision = revision; + } else { + if (revision != 1) return {std::nullopt, "profile create requires revision 1"}; + spec.entropy = DeriveEntropy(identity, spec.document_type_name, identity_contract_nonce); + spec.document_id = dpp::GenerateDocumentId(spec.data_contract_id, identity, + spec.document_type_name, spec.entropy); + } + // All profile fields are optional in the DashPay contract; $createdAt / + // $updatedAt are assigned by the chain from block time and never appear + // in the transition. + if (!profile.display_name.empty()) spec.data.emplace("displayName", Value::Text(profile.display_name)); + if (!profile.public_message.empty()) spec.data.emplace("publicMessage", Value::Text(profile.public_message)); + if (!profile.avatar_url.empty()) spec.data.emplace("avatarUrl", Value::Text(profile.avatar_url)); + if (!profile.avatar_hash.empty()) { + std::array hash; + std::copy(profile.avatar_hash.begin(), profile.avatar_hash.end(), hash.begin()); + spec.data.emplace("avatarHash", Value::MakeBytes32(hash)); + } + if (!profile.avatar_fingerprint.empty()) { + spec.data.emplace("avatarFingerprint", Value::MakeBytes(profile.avatar_fingerprint)); + } + return SignDocumentBatch(identity, spec, signature_public_key_id, signer); +} + +Result BuildContactRequest( + const Identifier& identity, + uint64_t identity_contract_nonce, + const ContactRequest& request, + uint32_t signature_public_key_id, + const Signer& signer) +{ + if (request.encrypted_public_key.size() != 96) { + return {std::nullopt, "encrypted public key must be 96 bytes"}; + } + if (!request.encrypted_account_label.empty() && + (request.encrypted_account_label.size() < 48 || request.encrypted_account_label.size() > 80)) { + return {std::nullopt, "encrypted account label must be 48-80 bytes"}; + } + DocumentSpec spec; + spec.identity_contract_nonce = identity_contract_nonce; + spec.document_type_name = "contactRequest"; + spec.data_contract_id = DASHPAY_CONTRACT_ID; + spec.entropy = DeriveEntropy(identity, spec.document_type_name, identity_contract_nonce); + spec.document_id = dpp::GenerateDocumentId(spec.data_contract_id, identity, + spec.document_type_name, spec.entropy); + // DashPay contract v1: $createdAt and $createdAtCoreBlockHeight are + // chain-assigned system fields, so ContactRequest::created_at and + // ::core_height_created_at do not serialize into the transition + // (packages/dashpay-contract/schema/v1/dashpay.schema.json). + spec.data.emplace("toUserId", Value::Id(request.to_user_id)); + spec.data.emplace("encryptedPublicKey", Value::MakeBytes(request.encrypted_public_key)); + spec.data.emplace("senderKeyIndex", Value::U32(request.sender_key_index)); + spec.data.emplace("recipientKeyIndex", Value::U32(request.recipient_key_index)); + spec.data.emplace("accountReference", Value::U32(request.account_reference)); + if (!request.encrypted_account_label.empty()) { + spec.data.emplace("encryptedAccountLabel", Value::MakeBytes(request.encrypted_account_label)); + } + return SignDocumentBatch(identity, spec, signature_public_key_id, signer); +} + +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..6b8db8a15176 --- /dev/null +++ b/src/platform/drive/queries.cpp @@ -0,0 +1,744 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include + +#include +#include + +namespace platform::drive { +namespace { + +using platform::grove::Element; +using platform::grove::GroveQuery; +using platform::grove::GroveVerifyResult; +using platform::grove::PathQuery; +using platform::grove::ProvedPathKeyValue; +using platform::grove::VerifyOptions; +using platform::merk::QueryItem; + +// Drive tree layout constants (see queries.h). +constexpr uint8_t ROOT_IDENTITIES = 32; +constexpr uint8_t ROOT_BALANCES = 96; +constexpr uint8_t ROOT_UNIQUE_PKH_TO_IDENTITIES = 24; +constexpr uint8_t ROOT_DATA_CONTRACT_DOCUMENTS = 64; +constexpr uint8_t ID_TREE_REVISION = 192; +constexpr uint8_t ID_TREE_NONCE = 64; +constexpr uint8_t ID_TREE_KEYS = 128; + +// Contested-resource vote tree constants +// (rs-drive src/drive/votes/paths.rs). +constexpr uint8_t ROOT_VOTES = 112; +constexpr uint8_t CONTESTED_RESOURCE_TREE_KEY = 'c'; +constexpr uint8_t ACTIVE_POLLS_TREE_KEY = 'p'; +constexpr uint8_t CONTESTED_DOCUMENT_INDEXES_TREE_KEY = 1; +constexpr uint8_t VOTING_STORAGE_TREE_KEY = 1; +//! [0;32] = stored info item, [0;31]+1 = abstain sum tree, [0;31]+2 = lock +//! sum tree. +Bytes SpecialVoteKey(uint8_t last) +{ + Bytes key(32, 0); + key.back() = last; + return key; +} + +bool RunQuery(Span proof, const PathQuery& query, GroveVerifyResult& out, + std::string& error); + +Bytes ByteSeg(uint8_t b) { return Bytes{b}; } + +Bytes IdBytes(const Identifier& id) { return Bytes(id.begin(), id.end()); } + +std::vector IdentityPath(const Identifier& id) { return {ByteSeg(ROOT_IDENTITIES), IdBytes(id)}; } + +PathQuery SingleKeyQuery(std::vector path, Bytes key) +{ + PathQuery pq; + pq.path = std::move(path); + pq.query.items.push_back(QueryItem::Key(std::move(key))); + pq.query.left_to_right = true; + pq.limit = 1; + return pq; +} + +PathQuery RangeFullQuery(std::vector path) +{ + PathQuery pq; + pq.path = std::move(path); + pq.query.items.push_back(QueryItem::RangeFull()); + pq.query.left_to_right = true; + return pq; +} + +Bytes StringBytes(const std::string& value) { return Bytes(value.begin(), value.end()); } + +GroveQuery::SubqueryBranch PathBranch(std::vector path, std::shared_ptr query = {}) +{ + GroveQuery::SubqueryBranch branch; + branch.subquery_path = std::move(path); + branch.subquery = std::move(query); + return branch; +} + +std::shared_ptr FullRangeQuery() +{ + auto query = std::make_shared(); + query->items.push_back(QueryItem::RangeFull()); + return query; +} + +std::shared_ptr FullRangeQueryWithDocumentIds() +{ + auto query = FullRangeQuery(); + query->default_subquery_branch = PathBranch({ByteSeg(0)}, FullRangeQuery()); + return query; +} + +std::vector DocumentTypePath(const Identifier& contract, const std::string& type) +{ + return {ByteSeg(ROOT_DATA_CONTRACT_DOCUMENTS), IdBytes(contract), ByteSeg(1), StringBytes(type)}; +} + +PathQuery UniqueIndexQuery(const Identifier& contract, const std::string& type, + const std::string& field, Bytes key, uint16_t limit) +{ + PathQuery query; + query.path = DocumentTypePath(contract, type); + query.path.push_back(StringBytes(field)); + query.query.items.push_back(QueryItem::Key(std::move(key))); + query.query.default_subquery_branch = PathBranch({ByteSeg(0)}); + query.limit = limit; + return query; +} + +bool ExtractDocuments(Span proof, const PathQuery& query, + std::vector& documents_out, Hash256& root_out, + std::string& error) +{ + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + documents_out.clear(); + for (const ProvedPathKeyValue& item : result.results) { + Element element; + if (!platform::grove::DecodeElement(item.value, element, error)) return false; + if (element.type != Element::Type::ITEM) { + error = "document query returned a non-item element"; + return false; + } + documents_out.push_back(std::move(element.item_value)); + } + return true; +} + +bool PathsEqual(const std::vector& a, const std::vector& b) { return a == b; } + +bool RunQuery(Span proof, const PathQuery& query, GroveVerifyResult& out, + std::string& error) +{ + VerifyOptions options; // defaults: raw results, include empty trees + if (!platform::grove::VerifyQuery(proof, query, options, out, error)) return false; + // Every Drive query the GUI issues goes through here. Require the strict + // V1 GroveDBProof envelope: current Platform (protocol >= 12, which maps + // to grovedb GROVE_V3) only emits V1; the lenient V0 format predates it + // and does not bind the serialized element bytes of a non-empty tree + // returned without a subquery, so accepting it would let a malicious + // evonode (or on-path attacker, TLS being unauthenticated by design) + // downgrade the proof and forge those bytes while preserving the + // quorum-signed root hash. + if (out.envelope_version < 1) { + error = "rejected lenient V0 GroveDB proof envelope (strict V1 required)"; + return false; + } + return true; +} + +//! Reads an 8-byte big-endian item value (revision / nonce). +bool DecodeU64BEItem(const Element& element, uint64_t& out, std::string& error) +{ + if (element.type != Element::Type::ITEM) { + error = "expected an item element"; + return false; + } + if (element.item_value.size() != 8) { + error = "expected an 8-byte value"; + return false; + } + uint64_t v = 0; + for (uint8_t byte : element.item_value) v = (v << 8) | byte; + out = v; + return true; +} + +} // namespace + +bool VerifyIdentityBalance(Span proof, const Identifier& identity_id, + std::optional& balance_out, Hash256& root_out, + std::string& error) +{ + const PathQuery query{SingleKeyQuery({ByteSeg(ROOT_BALANCES)}, IdBytes(identity_id))}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + if (result.results.empty()) { + balance_out = std::nullopt; + return true; + } + if (result.results.size() != 1) { + error = "expected at most one balance result"; + return false; + } + const ProvedPathKeyValue& r{result.results.front()}; + if (!PathsEqual(r.path, {ByteSeg(ROOT_BALANCES)}) || r.key != IdBytes(identity_id)) { + error = "balance result was not for the requested identity"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + if (element.type != Element::Type::SUM_ITEM) { + error = "balance element is not a sum item"; + return false; + } + if (element.sum_value < 0) { + error = "balance can't be negative"; + return false; + } + balance_out = static_cast(element.sum_value); + return true; +} + +bool VerifyIdentityRevision(Span proof, const Identifier& identity_id, + std::optional& revision_out, Hash256& root_out, + std::string& error) +{ + const PathQuery query{SingleKeyQuery(IdentityPath(identity_id), ByteSeg(ID_TREE_REVISION))}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + if (result.results.empty()) { + revision_out = std::nullopt; + return true; + } + const ProvedPathKeyValue& r{result.results.front()}; + if (!PathsEqual(r.path, IdentityPath(identity_id)) || r.key != ByteSeg(ID_TREE_REVISION)) { + error = "revision result was not for the requested identity"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + uint64_t value = 0; + if (!DecodeU64BEItem(element, value, error)) return false; + revision_out = value; + return true; +} + +bool VerifyIdentityNonce(Span proof, const Identifier& identity_id, + std::optional& nonce_out, Hash256& root_out, std::string& error) +{ + const PathQuery query{SingleKeyQuery(IdentityPath(identity_id), ByteSeg(ID_TREE_NONCE))}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + if (result.results.empty()) { + nonce_out = std::nullopt; + return true; + } + const ProvedPathKeyValue& r{result.results.front()}; + if (!PathsEqual(r.path, IdentityPath(identity_id)) || r.key != ByteSeg(ID_TREE_NONCE)) { + error = "nonce result was not for the requested identity"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + uint64_t value = 0; + if (!DecodeU64BEItem(element, value, error)) return false; + nonce_out = value; + return true; +} + +bool VerifyIdentityContractNonce(Span proof, const Identifier& identity_id, + const Identifier& contract_id, std::optional& nonce_out, + Hash256& root_out, std::string& error) +{ + std::vector path{ByteSeg(ROOT_IDENTITIES), IdBytes(identity_id), ByteSeg(32), IdBytes(contract_id)}; + const PathQuery query{SingleKeyQuery(path, ByteSeg(0))}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + if (result.results.empty()) { nonce_out = std::nullopt; return true; } + const ProvedPathKeyValue& r{result.results.front()}; + if (!PathsEqual(r.path, path) || r.key != ByteSeg(0)) { + error = "contract nonce result was not for the requested identity and contract"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + uint64_t value{0}; + if (!DecodeU64BEItem(element, value, error)) return false; + nonce_out = value; + return true; +} + +bool VerifyIdentityKeys(Span proof, const Identifier& identity_id, + std::optional>& keys_out, Hash256& root_out, + std::string& error) +{ + const std::vector keys_path{ByteSeg(ROOT_IDENTITIES), IdBytes(identity_id), + ByteSeg(ID_TREE_KEYS)}; + const PathQuery query{RangeFullQuery(keys_path)}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + std::vector keys; + for (const ProvedPathKeyValue& r : result.results) { + if (!PathsEqual(r.path, keys_path)) { + error = "identity key result on an unexpected path"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + if (element.type != Element::Type::ITEM) { + error = "identity key leaf is not an item"; + return false; + } + std::optional key{ + platform::dpp::DecodeIdentityPublicKey(Span(element.item_value), error)}; + if (!key.has_value()) return false; + keys.push_back(std::move(*key)); + } + keys_out = std::move(keys); + return true; +} + +bool VerifyIdentityIdByPublicKeyHash(Span proof, + const std::array& public_key_hash, + std::optional& identity_out, Hash256& root_out, + std::string& error) +{ + const Bytes hash_key(public_key_hash.begin(), public_key_hash.end()); + const PathQuery query{SingleKeyQuery({ByteSeg(ROOT_UNIQUE_PKH_TO_IDENTITIES)}, hash_key)}; + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + if (result.results.empty()) { + identity_out = std::nullopt; // proven absent + return true; + } + const ProvedPathKeyValue& r{result.results.front()}; + if (!PathsEqual(r.path, {ByteSeg(ROOT_UNIQUE_PKH_TO_IDENTITIES)}) || r.key != hash_key) { + error = "public key hash result on an unexpected path"; + return false; + } + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + if (element.type != Element::Type::ITEM || element.item_value.size() != 32) { + error = "public key hash leaf is not a 32-byte identity id"; + return false; + } + Identifier id{}; + std::copy(element.item_value.begin(), element.item_value.end(), id.begin()); + identity_out = id; + return true; +} + +bool VerifyFullIdentity(Span balance_proof, Span revision_proof, + Span keys_proof, const Identifier& identity_id, + std::optional& identity_out, Hash256& root_out, std::string& error) +{ + std::optional balance; + Hash256 balance_root{}; + if (!VerifyIdentityBalance(balance_proof, identity_id, balance, balance_root, error)) return false; + + std::optional revision; + Hash256 revision_root{}; + if (!VerifyIdentityRevision(revision_proof, identity_id, revision, revision_root, error)) return false; + + std::optional> keys; + Hash256 keys_root{}; + if (!VerifyIdentityKeys(keys_proof, identity_id, keys, keys_root, error)) return false; + + // All three proofs must commit to the same platform state root. + if (balance_root != revision_root || balance_root != keys_root) { + error = "identity sub-proofs commit to different state roots"; + return false; + } + root_out = balance_root; + + const bool has_keys{keys.has_value() && !keys->empty()}; + if (!balance.has_value() && !revision.has_value() && !has_keys) { + identity_out = std::nullopt; // fully absent + return true; + } + if (!balance.has_value() || !revision.has_value() || !has_keys) { + error = "identity proof is incomplete"; + return false; + } + + Identity identity; + identity.id = identity_id; + identity.balance = *balance; + identity.revision = *revision; + identity.public_keys = std::move(*keys); + identity_out = std::move(identity); + return true; +} + +bool VerifyDpnsNameExact(Span proof, const std::string& normalized_label, + std::vector& documents_out, Hash256& root_out, std::string& error) +{ + PathQuery query; + query.path = DocumentTypePath(DPNS_CONTRACT_ID, "domain"); + query.path.push_back(StringBytes("normalizedParentDomainName")); + query.path.push_back(StringBytes("dash")); + query.path.push_back(StringBytes("normalizedLabel")); + query.query.items.push_back(QueryItem::Key(StringBytes(normalized_label))); + query.query.default_subquery_branch = PathBranch({ByteSeg(0)}); + query.limit = 1; + return ExtractDocuments(proof, query, documents_out, root_out, error); +} + +bool VerifyDpnsNamePrefix(Span proof, const std::string& normalized_prefix, + uint16_t limit, std::vector& documents_out, Hash256& root_out, + std::string& error) +{ + if (normalized_prefix.empty() || static_cast(normalized_prefix.back()) == 0xff) { + error = "invalid DPNS prefix"; + return false; + } + PathQuery query; + query.path = DocumentTypePath(DPNS_CONTRACT_ID, "domain"); + query.path.push_back(StringBytes("normalizedParentDomainName")); + query.path.push_back(StringBytes("dash")); + query.path.push_back(StringBytes("normalizedLabel")); + Bytes start{StringBytes(normalized_prefix)}; + Bytes range_end{start}; + ++range_end.back(); + query.query.items.push_back(QueryItem::Range(std::move(start), std::move(range_end))); + query.query.default_subquery_branch = PathBranch({ByteSeg(0)}); + query.limit = limit; + return ExtractDocuments(proof, query, documents_out, root_out, error); +} + +bool VerifyDpnsNamesByIdentity(Span proof, const Identifier& identity_id, + uint16_t limit, std::vector& documents_out, + Hash256& root_out, std::string& error) +{ + PathQuery query; + query.path = DocumentTypePath(DPNS_CONTRACT_ID, "domain"); + query.path.push_back(StringBytes("records.identity")); + query.query.items.push_back(QueryItem::Key(IdBytes(identity_id))); + query.query.default_subquery_branch = PathBranch({ByteSeg(0)}, FullRangeQuery()); + query.limit = limit; + return ExtractDocuments(proof, query, documents_out, root_out, error); +} + +bool VerifyDashPayProfileByOwner(Span proof, const Identifier& owner_id, + std::vector& documents_out, Hash256& root_out, + std::string& error) +{ + return ExtractDocuments(proof, + UniqueIndexQuery(DASHPAY_CONTRACT_ID, "profile", "$ownerId", + IdBytes(owner_id), 1), + documents_out, root_out, error); +} + +bool VerifyDashPayContactRequests(Span proof, const Identifier& identity_id, + bool to_identity, uint16_t limit, + std::vector& documents_out, Hash256& root_out, + std::string& error) +{ + PathQuery query; + query.path = DocumentTypePath(DASHPAY_CONTRACT_ID, "contactRequest"); + query.path.push_back(StringBytes(to_identity ? "toUserId" : "$ownerId")); + query.query.items.push_back(QueryItem::Key(IdBytes(identity_id))); + query.query.default_subquery_branch = + PathBranch({StringBytes("$createdAt")}, FullRangeQueryWithDocumentIds()); + query.limit = limit; + return ExtractDocuments(proof, query, documents_out, root_out, error); +} + +namespace { + +//! Decoded subset of dpp ContestedDocumentVotePollStoredInfo (bincode +//! standard()+big_endian(), rs-dpp +//! src/voting/vote_info_storage/contested_document_vote_poll_stored_info/): +//! the last finalized vote event and the poll status. +struct StoredVoteInfo { + enum class Status : uint8_t { NOT_STARTED, AWARDED, LOCKED, STARTED }; + struct VoteChoice { + enum class Kind : uint8_t { TOWARDS_IDENTITY, ABSTAIN, LOCK }; + Kind kind{Kind::ABSTAIN}; + Identifier identity{}; //!< TOWARDS_IDENTITY only + uint32_t votes{0}; //!< sum of the voters' strengths + }; + std::vector last_choices; //!< empty when never finalized + uint64_t last_finalization_time_ms{0}; + Status status{Status::NOT_STARTED}; + Identifier awarded_to{}; //!< AWARDED only +}; + +bool ReadIdentifier(BytesReader& reader, Identifier& out) +{ + return reader.ReadInto(Span{out.data(), out.size()}); +} + +//! BlockInfo { time_ms: u64, height: u64, core_height: u32, epoch: u16 }; +//! only the block time survives into `time_ms_out`. +bool ReadBlockInfo(BytesReader& reader, uint64_t& time_ms_out) +{ + uint64_t height, core_height, epoch; + return reader.ReadBincodeVarint(time_ms_out) && reader.ReadBincodeVarint(height) && + reader.ReadBincodeVarint(core_height) && reader.ReadBincodeVarint(epoch); +} + +//! FinalizedResourceVoteChoicesWithVoterInfo { resource_vote_choice: +//! ResourceVoteChoice, voters: Vec<(Identifier, u8)> }. The per-voter list is +//! collapsed to the summed strength. +bool ReadVoteChoice(BytesReader& reader, StoredVoteInfo::VoteChoice& out) +{ + uint64_t kind; + if (!reader.ReadBincodeVarint(kind)) return false; + switch (kind) { + case 0: + out.kind = StoredVoteInfo::VoteChoice::Kind::TOWARDS_IDENTITY; + if (!ReadIdentifier(reader, out.identity)) return false; + break; + case 1: + out.kind = StoredVoteInfo::VoteChoice::Kind::ABSTAIN; + break; + case 2: + out.kind = StoredVoteInfo::VoteChoice::Kind::LOCK; + break; + default: + return reader.SetError(strprintf("unknown resource vote choice discriminant %u", kind)); + } + uint64_t voter_count; + if (!reader.ReadBincodeVarint(voter_count)) return false; + if (voter_count > reader.Remaining()) return reader.SetError("voter count exceeds remaining data"); + uint64_t votes = 0; + for (uint64_t i = 0; i < voter_count; ++i) { + Identifier voter; + uint8_t strength; + if (!ReadIdentifier(reader, voter) || !reader.ReadU8(strength)) return false; + votes += strength; + } + if (votes > std::numeric_limits::max()) return reader.SetError("vote tally overflows u32"); + out.votes = static_cast(votes); + return true; +} + +bool DecodeStoredVoteInfo(Span bytes, StoredVoteInfo& out, std::string& error) +{ + BytesReader reader{bytes}; + out = StoredVoteInfo{}; + uint64_t version; + if (!reader.ReadBincodeVarint(version)) { + error = reader.GetError(); + return false; + } + if (version != 0) { + error = strprintf("unknown stored info version %u", version); + return false; + } + uint64_t event_count{0}; + bool ok{reader.ReadBincodeVarint(event_count)}; + if (ok && event_count > reader.Remaining()) ok = reader.SetError("event count exceeds remaining data"); + for (uint64_t i = 0; ok && i < event_count; ++i) { + uint64_t choice_count{0}; + ok = reader.ReadBincodeVarint(choice_count); + if (ok && choice_count > reader.Remaining()) ok = reader.SetError("choice count exceeds remaining data"); + std::vector choices; + for (uint64_t j = 0; ok && j < choice_count; ++j) { + StoredVoteInfo::VoteChoice choice; + ok = ReadVoteChoice(reader, choice); + if (ok) choices.push_back(std::move(choice)); + } + uint64_t start_time, finalization_time; + ok = ok && ReadBlockInfo(reader, start_time) && ReadBlockInfo(reader, finalization_time); + // ContestedDocumentVotePollWinnerInfo { NoWinner, WonByIdentity, Locked }. + // The per-event winner is parsed only to advance the reader; the + // authoritative outcome comes from the poll status field below. + uint64_t winner_kind{0}; + ok = ok && reader.ReadBincodeVarint(winner_kind); + if (ok && winner_kind == 1) { + Identifier winner{}; + ok = ReadIdentifier(reader, winner); + } + if (ok && winner_kind > 2) ok = reader.SetError(strprintf("unknown winner discriminant %u", winner_kind)); + if (ok) { + // Only the last event matters for the outcome. + out.last_choices = std::move(choices); + out.last_finalization_time_ms = finalization_time; + } + } + // ContestedDocumentVotePollStatus { NotStarted, Awarded(id), Locked, Started(BlockInfo) } + uint64_t status{0}; + if (ok) ok = reader.ReadBincodeVarint(status); + if (ok) { + switch (status) { + case 0: + out.status = StoredVoteInfo::Status::NOT_STARTED; + break; + case 1: + out.status = StoredVoteInfo::Status::AWARDED; + ok = ReadIdentifier(reader, out.awarded_to); + break; + case 2: + out.status = StoredVoteInfo::Status::LOCKED; + break; + case 3: { + out.status = StoredVoteInfo::Status::STARTED; + uint64_t start_time; + ok = ReadBlockInfo(reader, start_time); + break; + } + default: + ok = reader.SetError(strprintf("unknown vote poll status discriminant %u", status)); + } + } + // Trailing locked_count (u16); consumed so the "all bytes read" check + // below is meaningful. + if (ok) { + uint64_t locked_count{0}; + ok = reader.ReadBincodeVarint(locked_count); + } + if (!ok || reader.HasError()) { + error = strprintf("unable to decode contested vote stored info: %s", reader.GetError()); + return false; + } + if (!reader.IsEof()) { + error = "contested vote stored info has trailing bytes"; + return false; + } + return true; +} + +} // namespace + +bool VerifyContestedVoteState(Span proof, const Identifier& contract_id, + const std::string& document_type, + const std::vector& index_values, uint16_t count, + ContestedVoteState& out, Hash256& root_out, std::string& error) +{ + const Bytes stored_info_key{SpecialVoteKey(0)}; + const Bytes abstain_key{SpecialVoteKey(1)}; + const Bytes lock_key{SpecialVoteKey(2)}; + + // Mirror of ContestedDocumentVotePollDriveQuery::construct_path_query for + // result type VoteTally, allow_include_locked_and_abstaining_vote_tally = + // true, no start_at (rs-drive src/query/vote_poll_vote_state_query.rs): + // all keys of the contenders tree, tallies resolved through the [1] + // voting sum tree, the stored-info item returned in place. + PathQuery query; + query.path = {ByteSeg(ROOT_VOTES), ByteSeg(CONTESTED_RESOURCE_TREE_KEY), + ByteSeg(ACTIVE_POLLS_TREE_KEY), IdBytes(contract_id), StringBytes(document_type), + ByteSeg(CONTESTED_DOCUMENT_INDEXES_TREE_KEY)}; + query.path.insert(query.path.end(), index_values.begin(), index_values.end()); + query.query.items.push_back(QueryItem::RangeFull()); + query.query.default_subquery_branch = PathBranch({ByteSeg(VOTING_STORAGE_TREE_KEY)}); + const auto conditional = [](Bytes key, GroveQuery::SubqueryBranch branch) { + GroveQuery::ConditionalBranch out_branch; + out_branch.item = QueryItem::Key(std::move(key)); + out_branch.branch = std::move(branch); + return out_branch; + }; + query.query.conditional_subquery_branches.push_back( + conditional(lock_key, PathBranch({ByteSeg(VOTING_STORAGE_TREE_KEY)}))); + query.query.conditional_subquery_branches.push_back( + conditional(abstain_key, PathBranch({ByteSeg(VOTING_STORAGE_TREE_KEY)}))); + query.query.conditional_subquery_branches.push_back( + conditional(stored_info_key, GroveQuery::SubqueryBranch{})); + query.limit = static_cast(std::min(uint32_t{count} + 3, 65535)); + + GroveVerifyResult result; + if (!RunQuery(proof, query, result, error)) return false; + root_out = result.root_hash; + + out = ContestedVoteState{}; + out.contest_found = !result.results.empty(); + + for (const ProvedPathKeyValue& r : result.results) { + Element element; + if (!platform::grove::DecodeElement(Span(r.value), element, error)) return false; + if (r.path.size() < query.path.size() || r.path.empty()) { + error = "contested vote result on an unexpected path"; + return false; + } + const Bytes& path_tail{r.path.back()}; + + if (element.type == Element::Type::SUM_TREE) { + // A tally: the enclosing tree's key names the contender (or the + // abstain/lock buckets); the sum tree itself sits at key [1]. + if (r.path.size() != query.path.size() + 1 || r.key != ByteSeg(VOTING_STORAGE_TREE_KEY) || + !std::equal(query.path.begin(), query.path.end(), r.path.begin())) { + error = "contested vote tally on an unexpected path"; + return false; + } + if (element.sum_value < 0 || element.sum_value > std::numeric_limits::max()) { + error = strprintf("vote tally out of range: %d", element.sum_value); + return false; + } + const auto tally{static_cast(element.sum_value)}; + if (path_tail == lock_key) { + out.lock_votes = tally; + } else if (path_tail == abstain_key) { + out.abstain_votes = tally; + } else if (path_tail.size() == 32) { + Identifier id{}; + std::copy(path_tail.begin(), path_tail.end(), id.begin()); + out.contenders.emplace_back(id, tally); + } else { + error = "contested vote tally under a malformed contender key"; + return false; + } + } else if (element.type == Element::Type::ITEM && r.key == stored_info_key && + r.path == query.path) { + StoredVoteInfo info; + if (!DecodeStoredVoteInfo(Span(element.item_value), info, error)) return false; + if (info.status != StoredVoteInfo::Status::AWARDED && + info.status != StoredVoteInfo::Status::LOCKED) { + continue; // active or reset poll: live tallies are authoritative + } + // Finished poll: the stored info replaces the live tallies + // (rs-drive verify_vote_poll_vote_state_proof_v0). + out.finished = true; + out.finished_at_time_ms = info.last_finalization_time_ms; + out.locked = info.status == StoredVoteInfo::Status::LOCKED; + if (info.status == StoredVoteInfo::Status::AWARDED) out.winner = info.awarded_to; + out.contenders.clear(); + uint32_t lock_total{0}, abstain_total{0}; + for (const StoredVoteInfo::VoteChoice& choice : info.last_choices) { + switch (choice.kind) { + case StoredVoteInfo::VoteChoice::Kind::TOWARDS_IDENTITY: + out.contenders.emplace_back(choice.identity, choice.votes); + break; + case StoredVoteInfo::VoteChoice::Kind::ABSTAIN: + abstain_total += choice.votes; + break; + case StoredVoteInfo::VoteChoice::Kind::LOCK: + lock_total += choice.votes; + break; + } + } + out.abstain_votes = abstain_total; + out.lock_votes = lock_total; + } else { + error = "unexpected element in contested vote state result"; + return false; + } + } + 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..2df7d5b4291a --- /dev/null +++ b/src/platform/drive/queries.h @@ -0,0 +1,150 @@ +// 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 +#include + +//! Per-query GroveDB path logic for the DAPI queries the dash-qt Platform GUI +//! makes. Each function verifies a real Drive proof with the layered GroveDB +//! verifier (platform/proof/grovedb.h), matches the returned (path, key, +//! element) tuples against the Drive tree layout for that query, decodes the +//! leaf bytes with the DPP decoders (platform/dpp/), and returns typed +//! results. std::nullopt means the object was cryptographically proven absent. +//! +//! Drive tree layout (dashpay/platform tag v4.0.0, protocol version 12): +//! - RootTree children (packages/rs-drive/src/drive/mod.rs enum RootTree): +//! Balances = 96 (sum tree), Identities = 32, +//! UniquePublicKeyHashesToIdentities = 24. +//! - Per-identity sub-structure (packages/rs-drive/src/drive/identity/mod.rs +//! enum IdentityRootStructure), under path [[32], identity_id]: +//! IdentityTreeRevision = 192 (8-byte big-endian item), +//! IdentityTreeNonce = 64 (8-byte big-endian item), +//! IdentityTreeKeys = 128 (sub-tree of serialized IdentityPublicKeys +//! keyed by varint KeyID). +//! - balance leaf: sum item in balance_path [[96]] keyed by identity id +//! (packages/rs-drive/src/drive/balances/mod.rs balance_path_vec, +//! verify/identity/verify_identity_balance_for_identity_id/v0). +//! +//! Sources for the match logic (packages/rs-drive/src/verify/identity/): +//! - verify_identity_balance_for_identity_id/v0/mod.rs +//! - verify_identity_nonce/v0/mod.rs +//! - verify_identity_keys_by_identity_id/v0/mod.rs +//! - verify_full_identity_by_identity_id/v0/mod.rs +//! - verify_identity_id_by_unique_public_key_hash/v0/mod.rs +namespace platform::drive { + +using platform::Identifier; +using platform::Identity; +using platform::IdentityPublicKey; +using platform::merk::Hash256; + +//! getIdentityBalance: proof over balance_path [[96]] keyed by identity id. +//! `balance_out` is std::nullopt when the identity has no balance entry. +bool VerifyIdentityBalance(Span proof, const Identifier& identity_id, + std::optional& balance_out, Hash256& root_out, + std::string& error); + +//! getIdentity revision: proof over [[32], id] keyed by [192]. +bool VerifyIdentityRevision(Span proof, const Identifier& identity_id, + std::optional& revision_out, Hash256& root_out, + std::string& error); + +//! getIdentityNonce: proof over [[32], id] keyed by [64]. +bool VerifyIdentityNonce(Span proof, const Identifier& identity_id, + std::optional& nonce_out, Hash256& root_out, std::string& error); +bool VerifyIdentityContractNonce(Span proof, const Identifier& identity_id, + const Identifier& contract_id, std::optional& nonce_out, + Hash256& root_out, std::string& error); + +//! getIdentityKeys (all keys): proof over the identity key tree +//! [[32], id, [128]] (range-full). Each leaf is a platform-serialized +//! IdentityPublicKey. `keys_out` is std::nullopt only if the key tree itself +//! is absent; an existing-but-empty key tree yields an empty vector. +bool VerifyIdentityKeys(Span proof, const Identifier& identity_id, + std::optional>& keys_out, Hash256& root_out, + std::string& error); + +//! getIdentityByPublicKeyHash (id only): proof over +//! UniquePublicKeyHashesToIdentities [[24]] keyed by the 20-byte hash. The +//! leaf is the 32-byte identity id. `identity_out` is std::nullopt when the +//! hash is proven absent (no identity registered that key hash). +bool VerifyIdentityIdByPublicKeyHash(Span proof, + const std::array& public_key_hash, + std::optional& identity_out, Hash256& root_out, + std::string& error); + +//! getIdentity (full): the GUI issues three simple proofs — balance, revision, +//! and all-keys — each independently proof-verified, then assembles the +//! identity. This avoids replicating Drive's PathQuery::merge while proving +//! every field. All three proofs must commit to the same GroveDB root. +//! `identity_out` is std::nullopt when the identity is proven fully absent +//! (no balance, no revision, no keys). +bool VerifyFullIdentity(Span balance_proof, Span revision_proof, + Span keys_proof, const Identifier& identity_id, + std::optional& identity_out, Hash256& root_out, + std::string& error); + +//! Proof-backed document query shapes used by the DashPay GUI. These mirror +//! DriveDocumentQuery::construct_path_query for the pinned DPNS/DashPay v1 +//! system-contract indexes and return the serialized document items resolved +//! through GroveDB index references. +bool VerifyDpnsNameExact(Span proof, const std::string& normalized_label, + std::vector& documents_out, Hash256& root_out, std::string& error); +bool VerifyDpnsNamePrefix(Span proof, const std::string& normalized_prefix, + uint16_t limit, std::vector& documents_out, Hash256& root_out, + std::string& error); +bool VerifyDpnsNamesByIdentity(Span proof, const Identifier& identity_id, + uint16_t limit, std::vector& documents_out, + Hash256& root_out, std::string& error); +bool VerifyDashPayProfileByOwner(Span proof, const Identifier& owner_id, + std::vector& documents_out, Hash256& root_out, + std::string& error); +bool VerifyDashPayContactRequests(Span proof, const Identifier& identity_id, + bool to_identity, uint16_t limit, + std::vector& documents_out, Hash256& root_out, + std::string& error); + +//! Decoded contested-resource vote state +//! (getContestedResourceVoteState, result type VoteTally with locked and +//! abstaining tallies). Mirrors rs-drive +//! src/verify/voting/verify_vote_poll_vote_state_proof/v0/mod.rs. +struct ContestedVoteState { + //! False when the contenders tree 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 (VoteTally + +//! allow_include_locked_and_abstaining_vote_tally): proof over the +//! contenders tree [[112], [0x63], [0x70], contract, doc_type, [1], +//! index values...] (rs-drive src/drive/votes/paths.rs). `index_values` are +//! the tree-key-encoded index values (raw utf8 for DPNS strings); `count` +//! must match the server-side query limit (drive-abci defaults to 100). +bool VerifyContestedVoteState(Span proof, const Identifier& contract_id, + const std::string& document_type, + const std::vector& index_values, uint16_t count, + ContestedVoteState& out, Hash256& root_out, std::string& error); + +} // namespace platform::drive + +#endif // BITCOIN_PLATFORM_DRIVE_QUERIES_H diff --git a/src/platform/drive/quorumsig.cpp b/src/platform/drive/quorumsig.cpp new file mode 100644 index 000000000000..f21842f62d69 --- /dev/null +++ b/src/platform/drive/quorumsig.cpp @@ -0,0 +1,186 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include + +namespace platform::drive { +namespace { + +Hash256 Sha256(Span data) +{ + Hash256 out{}; + CSHA256().Write(data.data(), data.size()).Finalize(out.data()); + return out; +} + +Hash256 DoubleSha256(Span data) +{ + return Sha256(Span(Sha256(data))); +} + +void AppendU32LE(Bytes& out, uint32_t v) +{ + for (int i = 0; i < 4; ++i) out.push_back(static_cast(v >> (8 * i))); +} + +void AppendU64LE(Bytes& out, uint64_t v) +{ + for (int i = 0; i < 8; ++i) out.push_back(static_cast(v >> (8 * i))); +} + +void AppendI32LE(Bytes& out, int32_t v) { AppendU32LE(out, static_cast(v)); } +void AppendI64LE(Bytes& out, int64_t v) { AppendU64LE(out, static_cast(v)); } + +//! protobuf base-128 varint (unsigned LEB128). +void AppendProtoVarint(Bytes& out, uint64_t v) { WriteLEB128(out, v); } + +Hash256 Reversed(const Hash256& in) +{ + Hash256 out{}; + for (size_t i = 0; i < in.size(); ++i) out[i] = in[in.size() - 1 - i]; + return out; +} + +} // namespace + +Hash256 ComputeRequestId(uint64_t height, int32_t round) +{ + static const char kPrefix[] = "dpbvote"; // 7 bytes, no NUL + Bytes buf(kPrefix, kPrefix + 7); + AppendI64LE(buf, static_cast(height)); + AppendI32LE(buf, round); + return Sha256(Span(buf)); +} + +Bytes EncodeStateIdSignBytes(const Hash256& app_hash, const BlockContext& ctx, uint64_t height) +{ + // Field body in ascending tag order; proto3 omits default (zero/empty) + // scalar/bytes fields (prost behaviour, matching tenderdash's generated + // StateId). + Bytes body; + if (ctx.protocol_version != 0) { + body.push_back(0x09); // field 1, wire type 1 (64-bit) + AppendU64LE(body, ctx.protocol_version); + } + if (height != 0) { + body.push_back(0x11); // field 2, wire type 1 + AppendU64LE(body, height); + } + // app_hash is a 32-byte value here, never the empty default -> always emitted. + { + body.push_back(0x1a); // field 3, wire type 2 (length-delimited) + AppendProtoVarint(body, app_hash.size()); + body.insert(body.end(), app_hash.begin(), app_hash.end()); + } + if (ctx.core_chain_locked_height != 0) { + body.push_back(0x25); // field 4, wire type 5 (32-bit) + AppendU32LE(body, ctx.core_chain_locked_height); + } + if (ctx.time_ms != 0) { + body.push_back(0x29); // field 5, wire type 1 + AppendU64LE(body, ctx.time_ms); + } + // encode_length_delimited: varint message length prefix + body. + Bytes out; + AppendProtoVarint(out, body.size()); + out.insert(out.end(), body.begin(), body.end()); + return out; +} + +Bytes EncodeCanonicalVoteSignBytes(int32_t vote_type, uint64_t height, int32_t round, + const Hash256& block_id_hash, const Hash256& state_id_hash, + const std::string& chain_id) +{ + Bytes buf; + AppendI32LE(buf, vote_type); + AppendI64LE(buf, static_cast(height)); + AppendI64LE(buf, static_cast(round)); + buf.insert(buf.end(), block_id_hash.begin(), block_id_hash.end()); + buf.insert(buf.end(), state_id_hash.begin(), state_id_hash.end()); + buf.insert(buf.end(), chain_id.begin(), chain_id.end()); + return buf; +} + +Hash256 ComputeSignHash(uint8_t quorum_type, const Hash256& quorum_hash, const Hash256& request_id, + const Hash256& vote_hash) +{ + const Hash256 qh{Reversed(quorum_hash)}; + const Hash256 rid{Reversed(request_id)}; + const Hash256 vh{Reversed(vote_hash)}; + Bytes buf; + buf.push_back(quorum_type); + buf.insert(buf.end(), qh.begin(), qh.end()); + buf.insert(buf.end(), rid.begin(), rid.end()); + buf.insert(buf.end(), vh.begin(), vh.end()); + return DoubleSha256(Span(buf)); +} + +// SignedMsgType::Precommit +static constexpr int32_t kSignedMsgTypePrecommit = 2; + +SignDigestIntermediates ComputeSignDigest(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx) +{ + SignDigestIntermediates out; + out.request_id = ComputeRequestId(ctx.height, envelope.round); + out.state_id_bytes = EncodeStateIdSignBytes(app_hash, ctx, ctx.height); + out.state_id_hash = Sha256(Span(out.state_id_bytes)); + out.canonical_vote_bytes = EncodeCanonicalVoteSignBytes( + kSignedMsgTypePrecommit, ctx.height, envelope.round, envelope.block_id_hash, + out.state_id_hash, ctx.chain_id); + out.vote_hash = Sha256(Span(out.canonical_vote_bytes)); + out.sign_digest = + ComputeSignHash(envelope.quorum_type, envelope.quorum_hash, out.request_id, out.vote_hash); + return out; +} + +bool VerifyQuorumSig(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx, const std::vector& quorum_pubkey, + std::string& error, SignDigestIntermediates* out_digest) +{ + if (quorum_pubkey.size() != 48) { + error = "quorum public key must be 48 bytes (basic BLS scheme)"; + return false; + } + // An all-zero signature is never valid; reject it explicitly to match + // rs-drive-proof-verifier::verify_signature_digest. + if (std::ranges::all_of(envelope.signature, [](uint8_t c) { return c == 0; })) { + error = "empty signature"; + return false; + } + + const SignDigestIntermediates inter{ComputeSignDigest(app_hash, envelope, ctx)}; + if (out_digest != nullptr) *out_digest = inter; + + CBLSPublicKey pubkey; + pubkey.SetBytes(Span(quorum_pubkey), /*specificLegacyScheme=*/false); + if (!pubkey.IsValid()) { + error = "invalid quorum public key"; + return false; + } + + CBLSSignature sig; + sig.SetBytes(Span(envelope.signature), /*specificLegacyScheme=*/false); + if (!sig.IsValid()) { + error = "invalid signature encoding"; + return false; + } + + uint256 digest; + std::memcpy(digest.begin(), inter.sign_digest.data(), inter.sign_digest.size()); + if (!sig.VerifyInsecure(pubkey, digest, /*specificLegacyScheme=*/false)) { + error = "quorum signature verification failed"; + return false; + } + return true; +} + +} // namespace platform::drive diff --git a/src/platform/drive/quorumsig.h b/src/platform/drive/quorumsig.h new file mode 100644 index 000000000000..cc091ebf8981 --- /dev/null +++ b/src/platform/drive/quorumsig.h @@ -0,0 +1,104 @@ +// 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_QUORUMSIG_H +#define BITCOIN_PLATFORM_DRIVE_QUORUMSIG_H + +#include +#include + +#include +#include +#include +#include +#include + +//! Tenderdash quorum threshold-signature verification: binds a GroveDB root +//! hash (the "app hash" a DAPI proof commits to) to a Platform block signed by +//! an LLMQ quorum. Pure C++ port of: +//! - dashpay/platform rs-drive-proof-verifier v4.0.0 src/verify.rs +//! (verify_tenderdash_proof, verify_signature_digest); +//! - dashpay/rs-tenderdash-abci v1.5.1 abci/src/signatures.rs +//! (sign_request_id, StateId::sign_bytes, CanonicalVote::sign_bytes, +//! sign_hash); +//! - tenderdash v1.5.3 proto/tendermint/types/types.proto (StateId field +//! tags), proto/tendermint/types/canonical.proto (CanonicalVote layout). +//! +//! The BLS check uses the BASIC (non-legacy, IETF) scheme: 48-byte G1 public +//! key, 96-byte G2 signature, message = the 32-byte sign digest. +namespace platform::drive { + +using platform::merk::Hash256; + +//! The fields of a DAPI Proof protobuf envelope (dapi-grpc +//! platform/v0/platform.proto message Proof) that the signature check needs. +//! grovedb_proof is verified separately (see queries.h); only its resulting +//! root hash enters here as `app_hash`. +struct ProofEnvelope { + uint8_t quorum_type{0}; + Hash256 quorum_hash{}; + Hash256 block_id_hash{}; + int32_t round{0}; + std::array signature{}; +}; + +//! Block context taken from the DAPI ResponseMetadata (height, core-locked +//! height, time, protocol version) plus the Tenderdash chain id. +struct BlockContext { + uint64_t height{0}; + uint32_t core_chain_locked_height{0}; + uint64_t time_ms{0}; + uint64_t protocol_version{0}; + std::string chain_id; +}; + +//! Intermediate digests, exposed so each layer can be checked independently +//! against the Rust-generated vectors. +struct SignDigestIntermediates { + Hash256 request_id{}; + Bytes state_id_bytes; + Hash256 state_id_hash{}; + Bytes canonical_vote_bytes; + Hash256 vote_hash{}; + Hash256 sign_digest{}; +}; + +//! request_id = SHA256("dpbvote" || height:i64-LE || round:i32-LE). +Hash256 ComputeRequestId(uint64_t height, int32_t round); + +//! StateId protobuf sign bytes: prost length-delimited encoding of +//! StateId{app_version=1(fixed64), height=2(fixed64), app_hash=3(bytes), +//! core_chain_locked_height=4(fixed32), time=5(fixed64)}, proto3 default +//! (zero/empty) fields omitted, fields in ascending tag order, message +//! length-prefixed with a protobuf varint. +Bytes EncodeStateIdSignBytes(const Hash256& app_hash, const BlockContext& ctx, uint64_t height); + +//! CanonicalVote sign bytes (a fixed layout, NOT protobuf): +//! type:i32-LE(=2 Precommit) | height:i64-LE | round:i64-LE | block_id(32) | +//! state_id_hash(32) | chain_id. +Bytes EncodeCanonicalVoteSignBytes(int32_t vote_type, uint64_t height, int32_t round, + const Hash256& block_id_hash, const Hash256& state_id_hash, + const std::string& chain_id); + +//! sign_digest = SHA256(SHA256(quorum_type:u8 | reverse(quorum_hash) | +//! reverse(request_id) | reverse(vote_hash))). +Hash256 ComputeSignHash(uint8_t quorum_type, const Hash256& quorum_hash, const Hash256& request_id, + const Hash256& vote_hash); + +//! Computes every intermediate up to and including the final sign digest. +SignDigestIntermediates ComputeSignDigest(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx); + +//! Verifies the quorum threshold signature over the sign digest that binds +//! `app_hash` to the block described by `ctx`/`envelope`. `quorum_pubkey` +//! is the raw 48-byte basic-scheme public key of the signing quorum. Returns +//! false (with `error` set) on a malformed key/signature or a failed check. +//! On success `out_digest`, if provided, receives the intermediates. +bool VerifyQuorumSig(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx, const std::vector& quorum_pubkey, + std::string& error, SignDigestIntermediates* out_digest = nullptr); + +} // namespace platform::drive + +#endif // BITCOIN_PLATFORM_DRIVE_QUORUMSIG_H diff --git a/src/platform/drive/verify.cpp b/src/platform/drive/verify.cpp new file mode 100644 index 000000000000..35a845a68a28 --- /dev/null +++ b/src/platform/drive/verify.cpp @@ -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. + +#include + +namespace platform::drive { + +bool VerifyRootBinding(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx, const QuorumKeyLookup& lookup, std::string& error) +{ + std::optional> quorum_key{lookup(envelope.quorum_type, envelope.quorum_hash)}; + if (!quorum_key.has_value()) { + error = "quorum public key not available for the signing quorum"; + return false; + } + return VerifyQuorumSig(app_hash, envelope, ctx, *quorum_key, error); +} + +bool VerifyAndDecodeIdentityBalance(Span grovedb_proof, const ProofEnvelope& envelope, + const BlockContext& ctx, const Identifier& identity_id, + const QuorumKeyLookup& lookup, + std::optional& balance_out, std::string& error) +{ + Hash256 root{}; + if (!VerifyIdentityBalance(grovedb_proof, identity_id, balance_out, root, error)) return false; + return VerifyRootBinding(root, envelope, ctx, lookup, error); +} + +bool VerifyAndDecodeIdentityNonce(Span grovedb_proof, const ProofEnvelope& envelope, + const BlockContext& ctx, const Identifier& identity_id, + const QuorumKeyLookup& lookup, std::optional& nonce_out, + std::string& error) +{ + Hash256 root{}; + if (!VerifyIdentityNonce(grovedb_proof, identity_id, nonce_out, root, error)) return false; + return VerifyRootBinding(root, envelope, ctx, lookup, error); +} + +bool VerifyAndDecodeIdentityIdByPublicKeyHash(Span grovedb_proof, + const ProofEnvelope& envelope, const BlockContext& ctx, + const std::array& public_key_hash, + const QuorumKeyLookup& lookup, + std::optional& identity_out, + std::string& error) +{ + Hash256 root{}; + if (!VerifyIdentityIdByPublicKeyHash(grovedb_proof, public_key_hash, identity_out, root, error)) { + return false; + } + return VerifyRootBinding(root, envelope, ctx, lookup, error); +} + +bool VerifyAndDecodeFullIdentity(Span balance_proof, + Span revision_proof, Span keys_proof, + const ProofEnvelope& envelope, const BlockContext& ctx, + const Identifier& identity_id, const QuorumKeyLookup& lookup, + std::optional& identity_out, std::string& error) +{ + Hash256 root{}; + if (!VerifyFullIdentity(balance_proof, revision_proof, keys_proof, identity_id, identity_out, + root, error)) { + return false; + } + return VerifyRootBinding(root, envelope, ctx, lookup, error); +} + +} // namespace platform::drive diff --git a/src/platform/drive/verify.h b/src/platform/drive/verify.h new file mode 100644 index 000000000000..95112d31d746 --- /dev/null +++ b/src/platform/drive/verify.h @@ -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. + +#ifndef BITCOIN_PLATFORM_DRIVE_VERIFY_H +#define BITCOIN_PLATFORM_DRIVE_VERIFY_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +//! Top-level "verify a DAPI response" layer: verifies the GroveDB proof to a +//! root hash, then verifies the Tenderdash quorum threshold signature that +//! binds that root to a signed Platform block, and finally decodes the typed +//! result. This is the only entry point the GUI should call for proved data. +namespace platform::drive { + +//! Resolves a quorum's basic-scheme (48-byte) BLS public key from its type and +//! hash. The client wires this to interfaces::Node::LLMQ::getPlatformQuorums. +//! Returns std::nullopt when the quorum is unknown (which fails verification). +using QuorumKeyLookup = + std::function>(uint8_t quorum_type, const Hash256& quorum_hash)>; + +//! Verifies the quorum-signature binding of an already-computed state root. +//! Looks the quorum key up via `lookup`, then checks the threshold signature. +bool VerifyRootBinding(const Hash256& app_hash, const ProofEnvelope& envelope, + const BlockContext& ctx, const QuorumKeyLookup& lookup, std::string& error); + +//! getIdentityBalance end to end: GroveDB proof -> root -> quorum sig -> value. +bool VerifyAndDecodeIdentityBalance(Span grovedb_proof, const ProofEnvelope& envelope, + const BlockContext& ctx, const Identifier& identity_id, + const QuorumKeyLookup& lookup, + std::optional& balance_out, std::string& error); + +//! getIdentityNonce end to end. +bool VerifyAndDecodeIdentityNonce(Span grovedb_proof, const ProofEnvelope& envelope, + const BlockContext& ctx, const Identifier& identity_id, + const QuorumKeyLookup& lookup, std::optional& nonce_out, + std::string& error); + +//! getIdentityByPublicKeyHash end to end (present -> id, absent -> nullopt). +bool VerifyAndDecodeIdentityIdByPublicKeyHash(Span grovedb_proof, + const ProofEnvelope& envelope, const BlockContext& ctx, + const std::array& public_key_hash, + const QuorumKeyLookup& lookup, + std::optional& identity_out, + std::string& error); + +//! getIdentity (full) end to end. The three sub-proofs share one signed root, +//! so a single envelope binds all of them. +bool VerifyAndDecodeFullIdentity(Span balance_proof, + Span revision_proof, Span keys_proof, + const ProofEnvelope& envelope, const BlockContext& ctx, + const Identifier& identity_id, const QuorumKeyLookup& lookup, + std::optional& identity_out, std::string& error); + +} // namespace platform::drive + +#endif // BITCOIN_PLATFORM_DRIVE_VERIFY_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..4a2bb1a87e11 --- /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; + //! 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; + + //! 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/proof/grovedb.cpp b/src/platform/proof/grovedb.cpp new file mode 100644 index 000000000000..fd72dfe57339 --- /dev/null +++ b/src/platform/proof/grovedb.cpp @@ -0,0 +1,676 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include + +namespace platform::grove { + +namespace { + +//! grovedb/src/operations/proof/mod.rs MAX_PROOF_DEPTH: limit on layer +//! nesting depth and on the number of children per layer. +constexpr size_t MAX_PROOF_DEPTH{128}; +//! grovedb decodes envelopes with a 256 MiB bincode limit +//! (grovedb/src/operations/proof/mod.rs decode_grovedb_proof_canonical). +constexpr size_t MAX_PROOF_BYTES{256 * 1024 * 1024}; +//! Sanity cap on decoded path segment counts (no grovedb equivalent; real +//! reference paths are a handful of segments deep). +constexpr size_t MAX_PATH_SEGMENTS{1024}; + +} // namespace + +bool ReferencePath::Resolve(const std::vector& current_path, const Bytes& key, + std::vector& out, std::string& error) const +{ + // grovedb-element/src/reference_path/mod.rs path_from_reference_path_type + out.clear(); + switch (type) { + case Type::ABSOLUTE_PATH: + out = path; + return true; + case Type::UPSTREAM_ROOT_HEIGHT: { + if (height > current_path.size()) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out.assign(current_path.begin(), current_path.begin() + height); + out.insert(out.end(), path.begin(), path.end()); + return true; + } + case Type::UPSTREAM_ROOT_HEIGHT_WITH_PARENT_PATH_ADDITION: { + if (height > current_path.size() || current_path.empty()) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out.assign(current_path.begin(), current_path.begin() + height); + out.insert(out.end(), path.begin(), path.end()); + out.push_back(current_path.back()); + return true; + } + case Type::UPSTREAM_FROM_ELEMENT_HEIGHT: { + if (height > current_path.size()) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out.assign(current_path.begin(), current_path.end() - height); + out.insert(out.end(), path.begin(), path.end()); + return true; + } + case Type::COUSIN: { + if (current_path.empty() || path.size() != 1) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out.assign(current_path.begin(), current_path.end() - 1); + out.push_back(path[0]); + out.push_back(key); + return true; + } + case Type::REMOVED_COUSIN: { + if (current_path.empty()) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out.assign(current_path.begin(), current_path.end() - 1); + out.insert(out.end(), path.begin(), path.end()); + out.push_back(key); + return true; + } + case Type::SIBLING: { + if (path.size() != 1) { + error = "reference stored path cannot satisfy reference constraints"; + return false; + } + out = current_path; + out.push_back(path[0]); + return true; + } + } + error = "unknown reference path type"; + return false; +} + +namespace { + +bool ReadPathSegments(BytesReader& reader, std::vector& out) +{ + uint64_t count; + if (!reader.ReadBincodeVarint(count)) return false; + if (count > MAX_PATH_SEGMENTS) return reader.SetError(strprintf("path has too many segments (%u)", count)); + out.clear(); + out.reserve(static_cast(count)); + for (uint64_t i = 0; i < count; ++i) { + Bytes segment; + if (!reader.ReadBincodeByteVec(segment, MAX_PROOF_BYTES)) return false; + out.push_back(std::move(segment)); + } + return true; +} + +//! grovedb-element/src/reference_path/mod.rs ReferencePathType, bincode +//! standard()+big_endian(): varint discriminant in declaration order. +bool DecodeReferencePath(BytesReader& reader, ReferencePath& out) +{ + uint64_t discriminant; + if (!reader.ReadBincodeVarint(discriminant)) return false; + out = ReferencePath{}; + switch (discriminant) { + case 0: + out.type = ReferencePath::Type::ABSOLUTE_PATH; + return ReadPathSegments(reader, out.path); + case 1: + case 2: + case 3: + out.type = discriminant == 1 ? ReferencePath::Type::UPSTREAM_ROOT_HEIGHT + : discriminant == 2 + ? ReferencePath::Type::UPSTREAM_ROOT_HEIGHT_WITH_PARENT_PATH_ADDITION + : ReferencePath::Type::UPSTREAM_FROM_ELEMENT_HEIGHT; + return reader.ReadU8(out.height) && ReadPathSegments(reader, out.path); + case 4: + case 6: { + out.type = discriminant == 4 ? ReferencePath::Type::COUSIN : ReferencePath::Type::SIBLING; + Bytes key; + if (!reader.ReadBincodeByteVec(key, MAX_PROOF_BYTES)) return false; + out.path.push_back(std::move(key)); + return true; + } + case 5: + out.type = ReferencePath::Type::REMOVED_COUSIN; + return ReadPathSegments(reader, out.path); + default: + return reader.SetError(strprintf("unknown reference path type discriminant %u", discriminant)); + } +} + +bool ReadOptionalByteVec(BytesReader& reader, std::optional& out) +{ + bool has_value; + if (!reader.ReadBincodeOptionTag(has_value)) return false; + if (!has_value) { + out.reset(); + return true; + } + Bytes value; + if (!reader.ReadBincodeByteVec(value, MAX_PROOF_BYTES)) return false; + out = std::move(value); + return true; +} + +const char* UnsupportedElementName(uint64_t discriminant) +{ + switch (discriminant) { + case 5: return "BigSumTree"; + case 6: return "CountTree"; + case 7: return "CountSumTree"; + case 8: return "ProvableCountTree"; + case 9: return "ItemWithSumItem"; + case 10: return "ProvableCountSumTree"; + case 11: return "CommitmentTree"; + case 12: return "MmrTree"; + case 13: return "BulkAppendTree"; + case 14: return "DenseAppendOnlyFixedSizeTree"; + case 15: return "NonCounted"; + case 16: return "NotSummed"; + case 17: return "NotCountedOrSummed"; + case 18: return "ReferenceWithSumItem"; + case 19: return "ProvableSumTree"; + case 20: return "ProvableCountProvableSumTree"; + default: return nullptr; + } +} + +} // namespace + +bool DecodeElement(Span bytes, Element& out, std::string& error) +{ + // grovedb-element/src/element/serialize.rs Element::deserialize with the + // bincode standard()+big_endian()+no_limit configuration. Enum + // discriminants and collection lengths are bincode varints; Option is a + // 0/1 tag byte; integer payloads are zigzag varints. + BytesReader reader{bytes}; + out = Element{}; + uint64_t discriminant; + bool ok{reader.ReadBincodeVarint(discriminant)}; + if (ok) { + switch (discriminant) { + case 0: + out.type = Element::Type::ITEM; + ok = reader.ReadBincodeByteVec(out.item_value, MAX_PROOF_BYTES) && + ReadOptionalByteVec(reader, out.flags); + break; + case 1: { + out.type = Element::Type::REFERENCE; + ok = DecodeReferencePath(reader, out.reference); + if (ok) { + bool has_hops; + ok = reader.ReadBincodeOptionTag(has_hops); + if (ok && has_hops) { + uint8_t hops; + ok = reader.ReadU8(hops); + out.max_hops = hops; + } + } + ok = ok && ReadOptionalByteVec(reader, out.flags); + break; + } + case 2: { + out.type = Element::Type::TREE; + ok = ReadOptionalByteVec(reader, out.root_key) && ReadOptionalByteVec(reader, out.flags); + break; + } + case 3: + out.type = Element::Type::SUM_ITEM; + ok = reader.ReadBincodeVarintSigned(out.sum_value) && ReadOptionalByteVec(reader, out.flags); + break; + case 4: + out.type = Element::Type::SUM_TREE; + ok = ReadOptionalByteVec(reader, out.root_key) && + reader.ReadBincodeVarintSigned(out.sum_value) && ReadOptionalByteVec(reader, out.flags); + break; + default: { + const char* name{UnsupportedElementName(discriminant)}; + if (name != nullptr) { + error = strprintf("unsupported element type %s", name); + } else { + error = strprintf("unknown element discriminant %u", discriminant); + } + return false; + } + } + } + if (!ok || reader.HasError()) { + error = strprintf("unable to deserialize element: %s", reader.GetError()); + return false; + } + if (!reader.IsEof()) { + error = strprintf("element deserialization did not consume all bytes: %u left", reader.Remaining()); + return false; + } + return true; +} + +namespace { + +// --------------------------------------------------------------------------- +// GroveDBProof envelope decoding +// (grovedb/src/operations/proof/mod.rs, bincode standard()+big_endian()) +// --------------------------------------------------------------------------- + +struct LayerProofNode { + Bytes merk_proof; + std::map lower_layers; +}; + +struct EnvelopeProof { + //! 0 = GroveDBProofV0 (MerkOnlyLayerProof + embedded ProveOptions, + //! lenient merk proof version), 1 = GroveDBProofV1 (LayerProof with + //! ProofBytes, strict merk proof version, default ProveOptions). + uint16_t version{1}; + //! ProveOptions::decrease_limit_on_empty_sub_query_result. Embedded in + //! the proof for V0 (prover controlled); the default (true) for V1. + bool decrease_limit_on_empty_sub_query_result{true}; + LayerProofNode root_layer; +}; + +bool DecodeLayerProof(BytesReader& reader, bool v1, size_t depth, LayerProofNode& out) +{ + // Mirrors MerkOnlyLayerProof::decode_with_depth (V0) and + // LayerProof::decode_with_depth (V1). + if (depth > MAX_PROOF_DEPTH) { + return reader.SetError("proof layer nesting depth exceeded maximum"); + } + if (v1) { + // ProofBytes enum: only the Merk variant is verifiable here. + uint64_t proof_type; + if (!reader.ReadBincodeVarint(proof_type)) return false; + switch (proof_type) { + case 0: + break; // Merk + case 1: + return reader.SetError("unsupported proof bytes type MMR"); + case 2: + return reader.SetError("unsupported proof bytes type BulkAppendTree"); + case 3: + return reader.SetError("unsupported proof bytes type DenseTree"); + case 4: + return reader.SetError("unsupported proof bytes type CommitmentTree"); + default: + return reader.SetError(strprintf("unknown proof bytes discriminant %u", proof_type)); + } + } + if (!reader.ReadBincodeByteVec(out.merk_proof, MAX_PROOF_BYTES)) return false; + uint64_t child_count; + if (!reader.ReadBincodeVarint(child_count)) return false; + if (child_count > MAX_PROOF_DEPTH) return reader.SetError("proof layer has too many children"); + for (uint64_t i = 0; i < child_count; ++i) { + Bytes key; + if (!reader.ReadBincodeByteVec(key, MAX_PROOF_BYTES)) return false; + LayerProofNode child; + if (!DecodeLayerProof(reader, v1, depth + 1, child)) return false; + out.lower_layers[std::move(key)] = std::move(child); + } + return true; +} + +bool DecodeEnvelope(Span proof, EnvelopeProof& out, std::string& error) +{ + // grovedb/src/operations/proof/mod.rs decode_grovedb_proof_canonical: + // bincode standard()+big_endian(), trailing bytes rejected. + BytesReader reader{proof}; + uint64_t version; + if (!reader.ReadBincodeVarint(version)) { + error = reader.GetError(); + return false; + } + out = EnvelopeProof{}; + switch (version) { + case 0: { + out.version = 0; + if (!DecodeLayerProof(reader, /*v1=*/false, 0, out.root_layer)) { + error = reader.GetError(); + return false; + } + // Embedded ProveOptions { decrease_limit_on_empty_sub_query_result: + // bool }. Prover controlled; see the V0 security note upstream. + uint8_t flag; + if (!reader.ReadU8(flag) || flag > 1) { + error = reader.HasError() ? reader.GetError() : "invalid ProveOptions bool encoding"; + return false; + } + out.decrease_limit_on_empty_sub_query_result = flag == 1; + break; + } + case 1: { + out.version = 1; + // V1 does not embed ProveOptions; the verifier uses the default. + if (!DecodeLayerProof(reader, /*v1=*/true, 0, out.root_layer)) { + error = reader.GetError(); + return false; + } + break; + } + default: + error = strprintf("unknown GroveDBProof version discriminant %u", version); + return false; + } + if (!reader.IsEof()) { + error = strprintf("proof has %u trailing bytes after the encoded envelope", reader.Remaining()); + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// PathQuery::query_items_at_path (grovedb/src/query/mod.rs) +// --------------------------------------------------------------------------- + +//! Mirror of grovedb/src/query/mod.rs SinglePathSubquery, resolved for one +//! specific path. +struct LevelQuery { + std::vector items; + bool left_to_right{true}; + enum class SubqueryKind : uint8_t { NONE, ALWAYS, CONDITIONAL } subquery_kind{SubqueryKind::NONE}; + const std::vector* conditional{nullptr}; + std::optional in_path; + + //! SinglePathSubquery::has_subquery_or_matching_in_path_on_key. + bool HasSubqueryOrMatchingInPathOnKey(const Bytes& key) const + { + switch (subquery_kind) { + case SubqueryKind::ALWAYS: + return true; + case SubqueryKind::CONDITIONAL: + if (conditional != nullptr) { + for (const auto& branch : *conditional) { + if (branch.item.Contains(key)) return true; + } + } + break; + case SubqueryKind::NONE: + break; + } + return in_path.has_value() && *in_path == key; + } +}; + +LevelQuery LevelFromQuery(const GroveQuery& query) +{ + // SinglePathSubquery::from_query + LevelQuery out; + out.items = query.items; + out.left_to_right = query.left_to_right; + if (!query.default_subquery_branch.Empty()) { + out.subquery_kind = LevelQuery::SubqueryKind::ALWAYS; + } else if (!query.conditional_subquery_branches.empty()) { + out.subquery_kind = LevelQuery::SubqueryKind::CONDITIONAL; + out.conditional = &query.conditional_subquery_branches; + } + return out; +} + +LevelQuery LevelFromKeyInPath(const Bytes& key, bool subquery_is_last_path_item, + bool subquery_has_inner_subquery) +{ + // SinglePathSubquery::from_key_when_in_path + LevelQuery out; + out.items.push_back(merk::QueryItem::Key(key)); + if (!(subquery_is_last_path_item && !subquery_has_inner_subquery)) { + out.in_path = key; + } + return out; +} + +using PathSpan = Span; + +bool PathsMatchPrefix(PathSpan path, const std::vector& prefix_source, size_t count) +{ + for (size_t i = 0; i < count; ++i) { + if (path[i] != prefix_source[i]) return false; + } + return true; +} + +//! recursive_query_items in PathQuery::query_items_at_path. `path` is the +//! path relative to the layer this query applies to. Returns false when the +//! query has nothing to say about this path (Rust None). +bool RecursiveQueryItems(const GroveQuery& query, PathSpan path, LevelQuery& out) +{ + if (path.empty()) { + out = LevelFromQuery(query); + return true; + } + + const Bytes& key{path.front()}; + const PathSpan rest{path.subspan(1)}; + + const auto resolve_branch = [&](const GroveQuery::SubqueryBranch& branch) -> bool { + // Shared logic between conditional and default subquery branches. + if (branch.subquery_path.has_value()) { + const std::vector& subquery_path{*branch.subquery_path}; + if (rest.size() <= subquery_path.size()) { + if (PathsMatchPrefix(rest, subquery_path, rest.size())) { + if (rest.size() == subquery_path.size()) { + if (branch.subquery) { + out = LevelFromQuery(*branch.subquery); + return true; + } + return false; + } + const bool last_path_item{path.size() == subquery_path.size()}; + out = LevelFromKeyInPath(subquery_path[rest.size()], last_path_item, + branch.subquery != nullptr); + return true; + } + } else if (PathsMatchPrefix(rest, subquery_path, subquery_path.size()) && branch.subquery) { + return RecursiveQueryItems(*branch.subquery, rest.subspan(subquery_path.size()), out); + } + } else if (branch.subquery) { + return RecursiveQueryItems(*branch.subquery, rest, out); + } + return false; + }; + + for (const auto& conditional : query.conditional_subquery_branches) { + if (conditional.item.Contains(key)) { + // Once a conditional item matches the key the outcome is final. + return resolve_branch(conditional.branch); + } + } + + return resolve_branch(query.default_subquery_branch); +} + +//! PathQuery::query_items_at_path. `path` is absolute. Returns false when +//! the path is not covered by the query. +bool QueryItemsAtPath(const PathQuery& query, PathSpan path, LevelQuery& out) +{ + const size_t query_path_len{query.path.size()}; + if (path.size() < query_path_len) { + if (!PathsMatchPrefix(path, query.path, path.size())) return false; + out = LevelFromKeyInPath(query.path[path.size()], /*subquery_is_last_path_item=*/false, + /*subquery_has_inner_subquery=*/true); + return true; + } + if (!PathsMatchPrefix(path, query.path, query_path_len)) return false; + if (path.size() == query_path_len) { + out = LevelFromQuery(query.query); + return true; + } + return RecursiveQueryItems(query.query, path.subspan(query_path_len), out); +} + +// --------------------------------------------------------------------------- +// Recursive layer verification +// (grovedb/src/operations/proof/verify.rs verify_layer_proof{,_v1}) +// --------------------------------------------------------------------------- + +struct VerifyContext { + const PathQuery& query; + const VerifyOptions& options; + const EnvelopeProof& envelope; + std::optional limit_left; + std::vector results; +}; + +bool VerifyLayer(VerifyContext& ctx, const LayerProofNode& layer, std::vector& current_path, + size_t depth, Hash256& out_root_hash, std::string& error) +{ + if (depth > MAX_PROOF_DEPTH) { + error = "proof verification exceeded maximum depth limit"; + return false; + } + + LevelQuery level; + if (!QueryItemsAtPath(ctx.query, PathSpan{current_path}, level)) { + error = "proof layer path is not covered by the path query"; + return false; + } + + // V0 envelopes verify with the lenient merk proof version (0); V1 with + // the strict version (1). + merk::VerifyResult merk_result; + if (!merk::ExecuteProof(layer.merk_proof, level.items, ctx.limit_left, level.left_to_right, + ctx.envelope.version, merk_result, error)) { + return false; + } + + std::vector verified_keys; + + if (merk_result.result_set.empty()) { + if (ctx.envelope.decrease_limit_on_empty_sub_query_result && ctx.limit_left.has_value() && + *ctx.limit_left > 0) { + --*ctx.limit_left; + } + } else { + for (const merk::ProvedKeyValue& proved : merk_result.result_set) { + const Bytes& key{proved.key}; + const Bytes& value_bytes{proved.value}; + Element element; + if (!DecodeElement(value_bytes, element, error)) return false; + verified_keys.push_back(key); + + const auto lower_it{layer.lower_layers.find(key)}; + if (lower_it != layer.lower_layers.end()) { + if (!element.IsTree() || element.IsEmptyTree()) { + error = "proof has lower layer for a non-tree element"; + return false; + } + current_path.push_back(key); + LevelQuery deeper; + if (!QueryItemsAtPath(ctx.query, PathSpan{current_path}, deeper)) { + // The query targets the tree element itself, not its + // contents. + ctx.results.push_back( + ProvedPathKeyValue{current_path, key, value_bytes, proved.value_hash}); + if (ctx.limit_left.has_value() && *ctx.limit_left > 0) --*ctx.limit_left; + current_path.pop_back(); + if (ctx.limit_left == std::optional{0}) break; + } else { + Hash256 lower_hash; + if (!VerifyLayer(ctx, lower_it->second, current_path, depth + 1, lower_hash, error)) { + return false; + } + current_path.pop_back(); + // Subtree link check: the parent's committed value_hash + // must equal combine_hash(H(serialized element bytes), + // child layer root hash). + const Hash256 combined{merk::CombineHash(merk::ValueHash(value_bytes), lower_hash)}; + if (proved.value_hash != combined) { + error = strprintf("mismatch in lower layer hash, expected %s, got %s", + HexStr(proved.value_hash), HexStr(combined)); + return false; + } + if (ctx.limit_left == std::optional{0}) break; + } + } else if (element.IsItem() || + (!level.HasSubqueryOrMatchingInPathOnKey(key) && + (ctx.options.include_empty_trees_in_result || + !(element.type == Element::Type::TREE && element.IsEmptyTree())))) { + if (element.IsTree() && element.IsEmptyTree()) { + // Empty trees carry no lower layer; their committed + // value_hash must be combine_hash(H(value), NULL_HASH). + // Without this an attacker could swap tree types inside + // KVValueHash nodes. + const Hash256 expected{merk::CombineHash(merk::ValueHash(value_bytes), merk::NULL_HASH)}; + if (proved.value_hash != expected) { + error = strprintf("empty tree value hash mismatch at key %s", HexStr(key)); + return false; + } + } + // V1 strict rule: a non-empty merk tree without a subquery + // must come from a KVValueHashFeatureTypeWithChildHash node + // so the merk verifier confirmed combine_hash(H(value), + // child_hash) == value_hash. + if (ctx.envelope.version >= 1 && element.IsTree() && !element.IsEmptyTree() && + !proved.child_hash_verified) { + error = strprintf("non-empty tree at key %s without subquery must use a " + "child-hash-verified proof node", + HexStr(key)); + return false; + } + ctx.results.push_back(ProvedPathKeyValue{current_path, key, value_bytes, proved.value_hash}); + if (ctx.limit_left.has_value() && *ctx.limit_left > 0) --*ctx.limit_left; + if (ctx.limit_left == std::optional{0}) break; + } else if (element.IsTree() && !element.IsEmptyTree() && + level.HasSubqueryOrMatchingInPathOnKey(key)) { + error = strprintf("proof is missing lower layer for non-empty tree at key %s", HexStr(key)); + return false; + } + } + } + + if (ctx.options.verify_proof_succinctness) { + for (const auto& [key, unused_child] : layer.lower_layers) { + if (std::find(verified_keys.begin(), verified_keys.end(), key) == verified_keys.end()) { + error = strprintf("proof contains extra lower layer for key %s not required by query", + HexStr(key)); + return false; + } + } + } + + out_root_hash = merk_result.root_hash; + return true; +} + +} // namespace + +bool VerifyQuery(Span proof, const PathQuery& query, const VerifyOptions& options, + GroveVerifyResult& out, std::string& error) +{ + out = GroveVerifyResult{}; + EnvelopeProof envelope; + if (!DecodeEnvelope(proof, envelope, error)) return false; + + VerifyContext ctx{query, options, envelope, query.limit, {}}; + std::vector current_path; + Hash256 root_hash; + if (!VerifyLayer(ctx, envelope.root_layer, current_path, 0, root_hash, error)) return false; + + out.root_hash = root_hash; + out.results = std::move(ctx.results); + out.envelope_version = envelope.version; + return true; +} + +bool VerifyQueryWithRootHash(Span proof, const PathQuery& query, + const VerifyOptions& options, const Hash256& expected_root_hash, + GroveVerifyResult& out, std::string& error) +{ + if (!VerifyQuery(proof, query, options, out, error)) return false; + if (out.root_hash != expected_root_hash) { + error = strprintf("proof did not match expected root hash: expected %s, got %s", + HexStr(expected_root_hash), HexStr(out.root_hash)); + return false; + } + return true; +} + +} // namespace platform::grove diff --git a/src/platform/proof/grovedb.h b/src/platform/proof/grovedb.h new file mode 100644 index 000000000000..b66adf56b7fe --- /dev/null +++ b/src/platform/proof/grovedb.h @@ -0,0 +1,171 @@ +// 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_PROOF_GROVEDB_H +#define BITCOIN_PLATFORM_PROOF_GROVEDB_H + +#include +#include + +#include +#include +#include +#include +#include +#include + +//! Layered GroveDB proof verification, a pure C++ port of the "verify" +//! feature slice of dashpay/grovedb tag v5.0.0: +//! - grovedb/src/operations/proof/mod.rs (GroveDBProof envelope, bincode +//! standard()+big_endian() config, V0/V1 layer proofs) +//! - grovedb/src/operations/proof/verify.rs (recursive layer verification) +//! - grovedb-element/src/element/mod.rs + serialize.rs (Element decoding) +//! - grovedb-element/src/reference_path/mod.rs (ReferencePathType) +//! - grovedb/src/query/mod.rs (PathQuery::query_items_at_path) +namespace platform::grove { + +using merk::Hash256; + +//! ReferencePathType (grovedb-element/src/reference_path/mod.rs). Variant +//! order matches the Rust enum declaration (bincode discriminants 0..6). +struct ReferencePath { + enum class Type : uint8_t { + ABSOLUTE_PATH = 0, + UPSTREAM_ROOT_HEIGHT = 1, + UPSTREAM_ROOT_HEIGHT_WITH_PARENT_PATH_ADDITION = 2, + UPSTREAM_FROM_ELEMENT_HEIGHT = 3, + COUSIN = 4, + REMOVED_COUSIN = 5, + SIBLING = 6, + }; + Type type{Type::ABSOLUTE_PATH}; + //! First tuple field of the upstream variants. + uint8_t height{0}; + //! Path segments; for COUSIN / SIBLING the single key lives in path[0]. + std::vector path; + + //! Resolves the reference to an absolute path, given the path of the + //! tree that holds the reference and the reference's own key. Mirrors + //! grovedb-element/src/reference_path/mod.rs + //! path_from_reference_path_type. Returns false on invalid input (e.g. + //! upstream height exceeding the current path length). + bool Resolve(const std::vector& current_path, const Bytes& key, + std::vector& out, std::string& error) const; +}; + +//! GroveDB Element (grovedb-element/src/element/mod.rs). Only the variants +//! the platform GUI needs are decoded fully (Item, Reference, Tree, SumItem, +//! SumTree); the remaining variants are recognized and rejected with clear +//! errors. Bincode discriminants follow the Rust declaration order. +struct Element { + enum class Type : uint8_t { + ITEM = 0, + REFERENCE = 1, + TREE = 2, + SUM_ITEM = 3, + SUM_TREE = 4, + // Recognized but unsupported: BigSumTree=5, CountTree=6, + // CountSumTree=7, ProvableCountTree=8, ItemWithSumItem=9, + // ProvableCountSumTree=10, CommitmentTree=11, MmrTree=12, + // BulkAppendTree=13, DenseAppendOnlyFixedSizeTree=14, + // NonCounted=15, NotSummed=16, NotCountedOrSummed=17, + // ReferenceWithSumItem=18, ProvableSumTree=19, + // ProvableCountProvableSumTree=20. + }; + Type type{Type::ITEM}; + Bytes item_value; //!< ITEM payload + int64_t sum_value{0}; //!< SUM_ITEM value / SUM_TREE total + std::optional root_key; //!< TREE / SUM_TREE root key + ReferencePath reference; //!< REFERENCE target + std::optional max_hops; //!< REFERENCE MaxReferenceHop + std::optional flags; //!< element flags (all variants) + + bool IsTree() const { return type == Type::TREE || type == Type::SUM_TREE; } + bool IsEmptyTree() const { return IsTree() && !root_key.has_value(); } + bool IsItem() const { return type == Type::ITEM || type == Type::SUM_ITEM; } +}; + +//! Decodes a serialized Element (bincode standard()+big_endian()+no_limit, +//! grovedb-element/src/element/serialize.rs Element::deserialize). The whole +//! input must be consumed. Returns false with error set on failure or when +//! the element variant is not supported by this verifier. +bool DecodeElement(Span bytes, Element& out, std::string& error); + +//! Query with optional default subquery branch +//! (grovedb-query/src/query.rs Query + subquery_branch.rs SubqueryBranch). +//! Conditional subquery branches are supported through `conditional`. +struct GroveQuery { + struct SubqueryBranch { + //! Optional path of intermediate keys leading to the subquery. + std::optional> subquery_path; + std::shared_ptr subquery; + + bool Empty() const { return !subquery_path.has_value() && !subquery; } + }; + struct ConditionalBranch { + merk::QueryItem item; + SubqueryBranch branch; + }; + + std::vector items; + bool left_to_right{true}; + SubqueryBranch default_subquery_branch; + std::vector conditional_subquery_branches; +}; + +//! PathQuery (grovedb/src/query/mod.rs PathQuery + SizedQuery). Offsets are +//! not supported by the proof system and are therefore not modeled. +struct PathQuery { + std::vector path; + GroveQuery query; + std::optional limit; +}; + +//! One verified result entry (grovedb/src/operations/proof/util.rs +//! ProvedPathKeyValue): the path of the subtree that holds the entry, the +//! key, and the serialized element bytes. +struct ProvedPathKeyValue { + std::vector path; + Bytes key; + Bytes value; + Hash256 value_hash{}; +}; + +struct GroveVerifyResult { + Hash256 root_hash{}; + std::vector results; + //! Decoded GroveDBProof envelope version (0 = lenient V0, 1 = strict V1). + //! Network verification requires V1; V0 is retained only for the decoder + //! unit tests (see platform_proof_tests). + uint16_t envelope_version{1}; +}; + +//! Verification options (merk/src/proofs/query/verify.rs VerifyOptions +//! subset; absence_proofs_for_non_existing_searched_keys is handled by the +//! caller since raw results already prove absences cryptographically). +struct VerifyOptions { + //! Reject proofs with lower layers the query did not require. + bool verify_proof_succinctness{false}; + //! Include empty tree elements in the result set. + bool include_empty_trees_in_result{true}; +}; + +//! Verifies a full layered GroveDB proof (V0 or V1 envelope) against a path +//! query, mirroring GroveDb::verify_query_raw / +//! grovedb/src/operations/proof/verify.rs. On success out.root_hash is the +//! GroveDB root hash the proof commits to and out.results contains the +//! proven (path, key, element bytes) entries in proof order. Any layer whose +//! parent link hash does not match, any incomplete query answer and any +//! malformed encoding is a hard error. +bool VerifyQuery(Span proof, const PathQuery& query, const VerifyOptions& options, + GroveVerifyResult& out, std::string& error); + +//! VerifyQuery + comparison against an expected root hash. +bool VerifyQueryWithRootHash(Span proof, const PathQuery& query, + const VerifyOptions& options, const Hash256& expected_root_hash, + GroveVerifyResult& out, std::string& error); + +} // namespace platform::grove + +#endif // BITCOIN_PLATFORM_PROOF_GROVEDB_H diff --git a/src/platform/proof/merk.cpp b/src/platform/proof/merk.cpp new file mode 100644 index 000000000000..25cb79c712d4 --- /dev/null +++ b/src/platform/proof/merk.cpp @@ -0,0 +1,783 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include + +namespace platform::merk { + +namespace { + +//! merk/src/proofs/tree.rs MAX_PROOF_OPS. +constexpr size_t MAX_PROOF_OPS{50'000}; +//! merk/src/proofs/tree.rs MAX_PROOF_STACK_DEPTH. +constexpr size_t MAX_PROOF_STACK_DEPTH{10'000}; +//! merk/src/proofs/tree.rs MAX_PROOF_TREE_HEIGHT. +constexpr size_t MAX_PROOF_TREE_HEIGHT{92}; +//! grovedb-query/src/proofs/encoding.rs MAX_VALUE_LEN (64 MiB). +constexpr uint32_t MAX_VALUE_LEN{64 * 1024 * 1024}; +//! GroveDB enforces a 255 byte maximum key length, so u8 key lengths in the +//! proof encoding are exact (grovedb-query/src/proofs/encoding.rs). +constexpr size_t MAX_KEY_LEN{255}; + +class Blake3 +{ +public: + Blake3() { blake3_hasher_init(&m_hasher); } + Blake3& Update(Span data) + { + blake3_hasher_update(&m_hasher, data.data(), data.size()); + return *this; + } + Hash256 Finalize() + { + Hash256 out; + blake3_hasher_finalize(&m_hasher, out.data(), out.size()); + return out; + } + +private: + blake3_hasher m_hasher; +}; + +Bytes LEB128(uint64_t value) +{ + Bytes out; + WriteLEB128(out, value); + return out; +} + +} // namespace + +Hash256 ValueHash(Span value) +{ + // merk/src/tree/hash.rs value_hash + return Blake3{}.Update(LEB128(value.size())).Update(value).Finalize(); +} + +Hash256 KvHash(Span key, Span value) +{ + // merk/src/tree/hash.rs kv_hash + const Hash256 vh{ValueHash(value)}; + return Blake3{}.Update(LEB128(key.size())).Update(key).Update(vh).Finalize(); +} + +Hash256 KvDigestToKvHash(Span key, const Hash256& value_hash) +{ + // merk/src/tree/hash.rs kv_digest_to_kv_hash + return Blake3{}.Update(LEB128(key.size())).Update(key).Update(value_hash).Finalize(); +} + +Hash256 NodeHash(const Hash256& kv, const Hash256& left, const Hash256& right) +{ + // merk/src/tree/hash.rs node_hash + return Blake3{}.Update(kv).Update(left).Update(right).Finalize(); +} + +Hash256 CombineHash(const Hash256& a, const Hash256& b) +{ + // merk/src/tree/hash.rs combine_hash + return Blake3{}.Update(a).Update(b).Finalize(); +} + +Hash256 NodeHashWithSum(const Hash256& kv, const Hash256& left, const Hash256& right, int64_t sum) +{ + // merk/src/tree/hash.rs node_hash_with_sum: i64 sum appended big-endian. + std::array sum_be; + const auto usum = static_cast(sum); + for (int i = 0; i < 8; ++i) { + sum_be[i] = static_cast(usum >> (8 * (7 - i))); + } + return Blake3{}.Update(kv).Update(left).Update(right).Update(sum_be).Finalize(); +} + +bool Node::IsKeyBearing() const +{ + switch (variant) { + case NodeVariant::KV: + case NodeVariant::KV_VALUE_HASH: + case NodeVariant::KV_DIGEST: + case NodeVariant::KV_REF_VALUE_HASH: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH: + return true; + case NodeVariant::HASH: + case NodeVariant::KV_HASH: + return false; + } + return false; +} + +namespace { + +//! Decodes a TreeFeatureType (grovedb-query/src/proofs/tree_feature_type.rs +//! impl Decode). Tag byte then, for SummedMerkNode, an LEB128 zigzag i64 +//! (integer-encoding crate write_varint). Only Basic (0) and Summed (1) are +//! supported. +bool DecodeTreeFeature(BytesReader& reader, TreeFeature& out) +{ + uint8_t tag; + if (!reader.ReadU8(tag)) return false; + switch (tag) { + case 0: + out = TreeFeature{}; + return true; + case 1: + out.summed = true; + return reader.ReadLEB128Signed(out.sum); + case 2: + return reader.SetError("unsupported tree feature type BigSummedMerkNode"); + case 3: + return reader.SetError("unsupported tree feature type CountedMerkNode"); + case 4: + return reader.SetError("unsupported tree feature type CountedSummedMerkNode"); + case 5: + return reader.SetError("unsupported tree feature type ProvableCountedMerkNode"); + case 6: + return reader.SetError("unsupported tree feature type ProvableCountedSummedMerkNode"); + case 7: + return reader.SetError("unsupported tree feature type ProvableSummedMerkNode"); + case 8: + return reader.SetError("unsupported tree feature type ProvableCountedAndProvableSummedMerkNode"); + default: + return reader.SetError(strprintf("unknown tree feature type tag %u", tag)); + } +} + +bool ReadKey(BytesReader& reader, Bytes& key) +{ + uint8_t key_len; + if (!reader.ReadU8(key_len)) return false; + static_assert(MAX_KEY_LEN == 255, "u8 key length covers the whole key range"); + return reader.ReadBytes(key_len, key); +} + +//! large=false: u16 big-endian value length; large=true: u32 big-endian +//! value length that must not fit in the small encoding (canonical form) and +//! must respect MAX_VALUE_LEN. +bool ReadValue(BytesReader& reader, bool large, Bytes& value) +{ + if (!large) { + uint16_t len; + if (!reader.ReadU16BE(len)) return false; + return reader.ReadBytes(len, value); + } + uint32_t len; + if (!reader.ReadU32BE(len)) return false; + if (len < 65536) return reader.SetError("non-canonical large value length in proof op"); + if (len > MAX_VALUE_LEN) return reader.SetError(strprintf("proof value length %u exceeds maximum %u", len, MAX_VALUE_LEN)); + return reader.ReadBytes(len, value); +} + +bool ReadHash(BytesReader& reader, Hash256& out) +{ + return reader.ReadInto(Span{out.data(), out.size()}); +} + +} // namespace + +bool DecodeOp(BytesReader& reader, Op& op) +{ + // grovedb-query/src/proofs/encoding.rs impl Decode for Op. + uint8_t opcode; + if (!reader.ReadU8(opcode)) return false; + + op.node = Node{}; + const auto push = [&](OpType type, NodeVariant variant) { + op.type = type; + op.node.variant = variant; + }; + + switch (opcode) { + case 0x01: + case 0x08: + push(opcode == 0x01 ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::HASH); + return ReadHash(reader, op.node.hash); + case 0x02: + case 0x09: + push(opcode == 0x02 ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV_HASH); + return ReadHash(reader, op.node.hash); + case 0x03: + case 0x0a: + case 0x20: + case 0x28: { + const bool large{opcode == 0x20 || opcode == 0x28}; + push((opcode == 0x03 || opcode == 0x20) ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV); + return ReadKey(reader, op.node.key) && ReadValue(reader, large, op.node.value); + } + case 0x04: + case 0x0b: + case 0x21: + case 0x29: { + const bool large{opcode == 0x21 || opcode == 0x29}; + push((opcode == 0x04 || opcode == 0x21) ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV_VALUE_HASH); + return ReadKey(reader, op.node.key) && ReadValue(reader, large, op.node.value) && + ReadHash(reader, op.node.value_hash); + } + case 0x05: + case 0x0c: + push(opcode == 0x05 ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV_DIGEST); + return ReadKey(reader, op.node.key) && ReadHash(reader, op.node.value_hash); + case 0x06: + case 0x0d: + case 0x22: + case 0x2a: { + const bool large{opcode == 0x22 || opcode == 0x2a}; + push((opcode == 0x06 || opcode == 0x22) ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV_REF_VALUE_HASH); + return ReadKey(reader, op.node.key) && ReadValue(reader, large, op.node.value) && + ReadHash(reader, op.node.value_hash); + } + case 0x07: + case 0x0e: + case 0x23: + case 0x2b: { + const bool large{opcode == 0x23 || opcode == 0x2b}; + push((opcode == 0x07 || opcode == 0x23) ? OpType::PUSH : OpType::PUSH_INVERTED, NodeVariant::KV_VALUE_HASH_FEATURE_TYPE); + return ReadKey(reader, op.node.key) && ReadValue(reader, large, op.node.value) && + ReadHash(reader, op.node.value_hash) && DecodeTreeFeature(reader, op.node.feature); + } + case 0x1c: + case 0x1d: + case 0x2e: + case 0x2f: { + const bool large{opcode == 0x2e || opcode == 0x2f}; + push((opcode == 0x1c || opcode == 0x2e) ? OpType::PUSH : OpType::PUSH_INVERTED, + NodeVariant::KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH); + return ReadKey(reader, op.node.key) && ReadValue(reader, large, op.node.value) && + ReadHash(reader, op.node.value_hash) && DecodeTreeFeature(reader, op.node.feature) && + ReadHash(reader, op.node.child_hash); + } + case 0x10: + op.type = OpType::PARENT; + return true; + case 0x11: + op.type = OpType::CHILD; + return true; + case 0x12: + op.type = OpType::PARENT_INVERTED; + return true; + case 0x13: + op.type = OpType::CHILD_INVERTED; + return true; + default: + break; + } + + // Recognized but unsupported op families; reject with explicit errors so + // callers can tell "malformed" from "unsupported tree type". + if ((opcode >= 0x14 && opcode <= 0x1b) || opcode == 0x1e || opcode == 0x1f || + opcode == 0x24 || opcode == 0x25 || opcode == 0x2c || opcode == 0x2d) { + return reader.SetError(strprintf("unsupported count-tree proof op 0x%02x", opcode)); + } + if (opcode >= 0x30 && opcode <= 0x3d) { + return reader.SetError(strprintf("unsupported provable-sum-tree proof op 0x%02x", opcode)); + } + if (opcode >= 0x40 && opcode <= 0x4d) { + return reader.SetError(strprintf("unsupported provable-count-and-sum-tree proof op 0x%02x", opcode)); + } + return reader.SetError(strprintf("unknown proof op 0x%02x", opcode)); +} + +QueryItem QueryItem::Key(Bytes key) +{ + QueryItem item; + item.type = Type::KEY; + item.key = std::move(key); + return item; +} + +QueryItem QueryItem::Range(Bytes start, Bytes end) +{ + QueryItem item; + item.type = Type::RANGE; + item.start = std::move(start); + item.end = std::move(end); + return item; +} + +QueryItem QueryItem::RangeInclusive(Bytes start, Bytes end) +{ + QueryItem item; + item.type = Type::RANGE_INCLUSIVE; + item.start = std::move(start); + item.end = std::move(end); + return item; +} + +QueryItem QueryItem::RangeFull() +{ + QueryItem item; + item.type = Type::RANGE_FULL; + return item; +} + +std::pair>, bool> QueryItem::LowerBound() const +{ + // grovedb-query/src/query_item/mod.rs lower_bound + switch (type) { + case Type::KEY: + return {Span{key}, false}; + case Type::RANGE: + case Type::RANGE_INCLUSIVE: + return {Span{start}, false}; + case Type::RANGE_FULL: + return {std::nullopt, false}; + } + return {std::nullopt, false}; +} + +std::pair>, bool> QueryItem::UpperBound() const +{ + // grovedb-query/src/query_item/mod.rs upper_bound + switch (type) { + case Type::KEY: + return {Span{key}, true}; + case Type::RANGE: + return {Span{end}, false}; + case Type::RANGE_INCLUSIVE: + return {Span{end}, true}; + case Type::RANGE_FULL: + return {std::nullopt, true}; + } + return {std::nullopt, true}; +} + +namespace { + +int CompareBytes(Span a, Span b) +{ + const size_t common{std::min(a.size(), b.size())}; + const int r{common == 0 ? 0 : std::memcmp(a.data(), b.data(), common)}; + if (r != 0) return r; + if (a.size() == b.size()) return 0; + return a.size() < b.size() ? -1 : 1; +} + +} // namespace + +bool QueryItem::Contains(Span candidate) const +{ + // grovedb-query/src/query_item/mod.rs contains + const auto [lower, lower_non_inclusive] = LowerBound(); + const auto [upper, upper_inclusive] = UpperBound(); + const bool lower_ok{LowerUnbounded() || + (lower && CompareBytes(candidate, *lower) > 0) || + (lower && CompareBytes(candidate, *lower) == 0 && !lower_non_inclusive)}; + const bool upper_ok{UpperUnbounded() || + (upper && CompareBytes(candidate, *upper) < 0) || + (upper && CompareBytes(candidate, *upper) == 0 && upper_inclusive)}; + return lower_ok && upper_ok; +} + +namespace { + +//! Peeks at the element type discriminant of serialized element bytes and +//! reports whether the type has a "simple" value hash (H(value) with no +//! child-hash combination), mirroring +//! grovedb-element/src/element_type.rs from_serialized_value + +//! has_simple_value_hash. Simple types: Item (0), SumItem (3), +//! ItemWithSumItem (9), including under a NonCounted (15) wrapper. +bool ElementHasSimpleValueHash(Span value, bool& simple, std::string& error) +{ + if (value.empty()) { + error = "cannot get element type from empty value"; + return false; + } + uint8_t base{value[0]}; + if (base == 15 || base == 16 || base == 17) { + // Wrapper discriminants (NonCounted / NotSummed / NotCountedOrSummed) + if (value.size() < 2) { + error = "element wrapper has no inner discriminant byte"; + return false; + } + const uint8_t inner{value[1]}; + if (base == 15) { + if (!(inner <= 14 || inner == 18 || inner == 19 || inner == 20)) { + error = strprintf("invalid NonCounted inner element discriminant %u", inner); + return false; + } + } else { + if (!(inner == 4 || inner == 5 || inner == 7 || inner == 10 || inner == 19 || inner == 20)) { + error = strprintf("invalid NotSummed/NotCountedOrSummed inner element discriminant %u", inner); + return false; + } + } + base = inner; + } else if (base > 20) { + error = strprintf("unknown element discriminant %u", base); + return false; + } + simple = base == 0 /* Item */ || base == 3 /* SumItem */ || base == 9 /* ItemWithSumItem */; + return true; +} + +//! A reconstructed proof tree node. Proofs are executed with the equivalent +//! of merk/src/proofs/tree.rs execute(ops, /*collapse=*/true, visit): child +//! subtrees are collapsed to their hash (and, matching the Rust +//! Tree::into_hash, their height resets to 1). +struct StackTree { + Node node; + bool has_left{false}; + bool has_right{false}; + Hash256 left_hash{}; + Hash256 right_hash{}; + size_t height{1}; + size_t left_child_height{0}; + size_t right_child_height{0}; + + const Hash256& ChildHash(bool left) const + { + static constexpr Hash256 null_hash{}; + if (left) return has_left ? left_hash : null_hash; + return has_right ? right_hash : null_hash; + } + + //! merk/src/proofs/tree.rs Tree::hash for the supported node variants. + Hash256 TreeHash() const + { + switch (node.variant) { + case NodeVariant::HASH: + return node.hash; + case NodeVariant::KV_HASH: + return NodeHash(node.hash, ChildHash(true), ChildHash(false)); + case NodeVariant::KV: + return NodeHash(KvHash(node.key, node.value), ChildHash(true), ChildHash(false)); + case NodeVariant::KV_VALUE_HASH: + case NodeVariant::KV_DIGEST: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH: + // Basic and Summed feature types hash like plain nodes; the sum + // is not part of the node hash for non-provable sum trees. The + // child_hash of the WithChildHash variant is GroveDB-level + // metadata and does not participate in the merk hash. + return NodeHash(KvDigestToKvHash(node.key, node.value_hash), ChildHash(true), ChildHash(false)); + case NodeVariant::KV_REF_VALUE_HASH: { + // value_hash field holds H(serialized reference element); the + // node's kv hash commits to + // combine_hash(value_hash, H(referenced value)). + const Hash256 combined{CombineHash(node.value_hash, ValueHash(node.value))}; + return NodeHash(KvDigestToKvHash(node.key, combined), ChildHash(true), ChildHash(false)); + } + } + return NULL_HASH; + } +}; + +//! Validates that query items are sorted ascending and non-overlapping (the +//! invariant grovedb's Query::insert_item maintains). +bool ValidateQueryItems(const std::vector& items, std::string& error) +{ + for (size_t i = 1; i < items.size(); ++i) { + const auto [prev_upper, prev_upper_inclusive] = items[i - 1].UpperBound(); + const auto [next_lower, next_lower_non_inclusive] = items[i].LowerBound(); + if (!prev_upper || !next_lower) { + error = "unbounded query items cannot be combined with other items"; + return false; + } + const int cmp{CompareBytes(*prev_upper, *next_lower)}; + if (cmp > 0 || (cmp == 0 && prev_upper_inclusive && !next_lower_non_inclusive)) { + error = "query items must be sorted ascending and non-overlapping"; + return false; + } + } + return true; +} + +} // namespace + +bool ExecuteProof(Span proof, const std::vector& items, + std::optional limit, bool left_to_right, uint16_t proof_version, + VerifyResult& out, std::string& error) +{ + // Mirrors merk/src/proofs/query/verify.rs execute_proof combined with + // merk/src/proofs/tree.rs execute (collapse = true). + out = VerifyResult{}; + if (!ValidateQueryItems(items, error)) return false; + + BytesReader reader{proof}; + std::vector stack; + stack.reserve(32); + std::optional last_key; + size_t op_count{0}; + + // Query walk state (verify.rs execute_proof) + size_t query_index{0}; // index into items in traversal direction + const auto query_peek = [&]() -> const QueryItem* { + if (query_index >= items.size()) return nullptr; + return left_to_right ? &items[query_index] : &items[items.size() - 1 - query_index]; + }; + bool in_range{false}; + std::optional current_limit{limit}; + // Last pushed node: nullopt until the first push; then whether it was a + // key-bearing variant (KV / KVDigest / KVValueHash / KVRefValueHash / + // feature-type variants) as opposed to Hash / KVHash. + std::optional last_push_key_bearing; + + const auto execute_node = [&](const Bytes& key, const Bytes* value, const Hash256& value_hash, + bool child_hash_verified) -> bool { + while (const QueryItem* query_item = query_peek()) { + const auto [lower_bound, start_non_inclusive] = query_item->LowerBound(); + const auto [upper_bound, end_inclusive] = query_item->UpperBound(); + + // Terminate if we encounter a node before the current query item. + const bool terminate = left_to_right + ? (!query_item->LowerUnbounded() && + (CompareBytes(*lower_bound, key) > 0 || + (start_non_inclusive && CompareBytes(*lower_bound, key) == 0))) + : (!query_item->UpperUnbounded() && + (CompareBytes(*upper_bound, key) < 0 || + (!end_inclusive && CompareBytes(*upper_bound, key) == 0))); + if (terminate) break; + + if (!in_range) { + // This is the first data we encounter for this query item: + // the bound facing the traversal direction must be proven, + // either by an exact match or by a preceding key-bearing + // node (or this being the first node in the tree). + const auto bound = left_to_right ? lower_bound : upper_bound; + const bool exact_match{bound && CompareBytes(key, *bound) == 0}; + if (!exact_match && last_push_key_bearing.has_value() && !*last_push_key_bearing) { + error = left_to_right ? "cannot verify lower bound of queried range" + : "cannot verify upper bound of queried range"; + return false; + } + } + + if (left_to_right) { + if (upper_bound && CompareBytes(key, *upper_bound) >= 0) { + // At or past the upper bound (or exact single-key match): + // advance to the next query item. + ++query_index; + in_range = false; + } else { + in_range = true; + } + } else if (lower_bound && CompareBytes(key, *lower_bound) <= 0) { + ++query_index; + in_range = false; + } else { + in_range = true; + } + + if (query_item->Contains(key)) { + if (value == nullptr) { + error = "proof is missing data for query"; + return false; + } + if (current_limit.has_value()) { + if (*current_limit == 0) { + error = "proof returns more data than the query limit"; + return false; + } + --*current_limit; + if (*current_limit == 0) in_range = false; + } + out.result_set.push_back(ProvedKeyValue{key, *value, value_hash, child_hash_verified}); + break; // continue to next push + } + // otherwise: continue with the next query item for the same key + } + return true; + }; + + const auto visit_node = [&](const Node& node) -> bool { + switch (node.variant) { + case NodeVariant::KV: + return execute_node(node.key, &node.value, ValueHash(node.value), false); + case NodeVariant::KV_VALUE_HASH: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE: + case NodeVariant::KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH: { + // Strict (V1+) mode: these node types exist for elements whose + // value_hash is a combine_hash (trees and references). Reject + // item elements to prevent KV -> KVValueHash forgery. + if (proof_version >= 1) { + bool simple{false}; + std::string element_error; + if (!ElementHasSimpleValueHash(node.value, simple, element_error)) { + error = strprintf("cannot determine element type in proof node: %s", element_error); + return false; + } + if (simple) { + error = "value-hash proof node must not contain an item element"; + return false; + } + } + if (node.variant == NodeVariant::KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH) { + // Verify value integrity: combine_hash(H(value), child_hash) + // must equal the node's value_hash + // (merk/src/proofs/query/verify.rs). + const Hash256 computed{CombineHash(ValueHash(node.value), node.child_hash)}; + if (computed != node.value_hash) { + error = "value/child hash mismatch in proof node"; + return false; + } + return execute_node(node.key, &node.value, node.value_hash, true); + } + return execute_node(node.key, &node.value, node.value_hash, false); + } + case NodeVariant::KV_DIGEST: + return execute_node(node.key, nullptr, node.value_hash, false); + case NodeVariant::KV_REF_VALUE_HASH: + return execute_node(node.key, &node.value, node.value_hash, false); + case NodeVariant::HASH: + case NodeVariant::KV_HASH: + if (in_range) { + error = "proof is missing data for query range (abridged node inside range)"; + return false; + } + return true; + } + error = "unhandled proof node variant"; + return false; + }; + + // merk/src/proofs/tree.rs execute: op replay stack machine. + const auto attach = [&](StackTree& parent, bool left, const StackTree& child) -> bool { + if ((left && parent.has_left) || (!left && parent.has_right)) { + error = "proof op tried to attach to an already occupied child slot"; + return false; + } + // Collapse the child to its hash; matching Rust Tree::into_hash the + // collapsed child's height is 1. + const Hash256 child_hash{child.TreeHash()}; + constexpr size_t collapsed_child_height{1}; + const size_t new_height{std::max(parent.height, collapsed_child_height + 1)}; + if (new_height > MAX_PROOF_TREE_HEIGHT) { + error = "proof tree exceeds maximum height"; + return false; + } + parent.height = new_height; + if (left) { + parent.has_left = true; + parent.left_hash = child_hash; + parent.left_child_height = collapsed_child_height; + } else { + parent.has_right = true; + parent.right_hash = child_hash; + parent.right_child_height = collapsed_child_height; + } + return true; + }; + + const auto pop = [&](StackTree& out_tree) -> bool { + if (stack.empty()) { + error = "proof op stack underflow"; + return false; + } + out_tree = std::move(stack.back()); + stack.pop_back(); + return true; + }; + + while (!reader.IsEof()) { + if (++op_count > MAX_PROOF_OPS) { + error = "proof exceeds maximum operation count"; + return false; + } + Op op; + if (!DecodeOp(reader, op)) { + error = reader.GetError(); + return false; + } + switch (op.type) { + case OpType::PARENT: + case OpType::CHILD_INVERTED: { + StackTree parent, child; + if (!pop(parent) || !pop(child)) return false; + if (op.type == OpType::CHILD_INVERTED) std::swap(parent, child); + if (!attach(parent, /*left=*/true, child)) return false; + stack.push_back(std::move(parent)); + break; + } + case OpType::CHILD: + case OpType::PARENT_INVERTED: { + // CHILD pops the child from the top of the stack, then the + // parent; PARENT_INVERTED pops the parent first. Both attach the + // child on the right (grovedb-query/src/proofs/mod.rs). + StackTree parent, child; + if (op.type == OpType::CHILD) { + if (!pop(child) || !pop(parent)) return false; + } else { + if (!pop(parent) || !pop(child)) return false; + } + if (!attach(parent, /*left=*/false, child)) return false; + stack.push_back(std::move(parent)); + break; + } + case OpType::PUSH: + case OpType::PUSH_INVERTED: { + if (op.node.IsKeyBearing() && last_key.has_value()) { + const int cmp{CompareBytes(op.node.key, *last_key)}; + if (op.type == OpType::PUSH && cmp <= 0) { + error = "incorrect key ordering in proof"; + return false; + } + if (op.type == OpType::PUSH_INVERTED && cmp >= 0) { + error = "incorrect key ordering in inverted proof"; + return false; + } + } + if (op.node.IsKeyBearing()) last_key = op.node.key; + if (!visit_node(op.node)) return false; + last_push_key_bearing = op.node.IsKeyBearing(); + StackTree tree; + tree.node = std::move(op.node); + stack.push_back(std::move(tree)); + if (stack.size() > MAX_PROOF_STACK_DEPTH) { + error = "proof exceeds maximum stack depth"; + return false; + } + break; + } + } + } + + if (stack.size() != 1) { + error = "expected proof to result in exactly one stack item"; + return false; + } + const StackTree root{std::move(stack.back())}; + stack.pop_back(); + + // Root-level AVL balance check (merk/src/proofs/tree.rs execute). + const size_t max_child{std::max(root.left_child_height, root.right_child_height)}; + const size_t min_child{std::min(root.left_child_height, root.right_child_height)}; + if (max_child - min_child > 1) { + error = "expected proof to result in a valid avl tree"; + return false; + } + + // Remaining query items must be proven absent against the tree edge. + if (query_peek() != nullptr && !(current_limit.has_value() && *current_limit == 0)) { + if (!last_push_key_bearing.has_value() || !*last_push_key_bearing) { + error = "proof is missing data for query"; + return false; + } + } + + out.root_hash = root.TreeHash(); + out.limit_left = current_limit; + return true; +} + +bool VerifyProof(Span proof, const std::vector& items, + std::optional limit, bool left_to_right, const Hash256& expected_hash, + VerifyResult& out, std::string& error) +{ + if (!ExecuteProof(proof, items, limit, left_to_right, PROOF_VERSION_LATEST, out, error)) return false; + if (out.root_hash != expected_hash) { + error = strprintf("proof did not match expected hash: expected %s, got %s", + HexStr(expected_hash), HexStr(out.root_hash)); + return false; + } + return true; +} + +} // namespace platform::merk diff --git a/src/platform/proof/merk.h b/src/platform/proof/merk.h new file mode 100644 index 000000000000..c7a7e64d08eb --- /dev/null +++ b/src/platform/proof/merk.h @@ -0,0 +1,177 @@ +// 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_PROOF_MERK_H +#define BITCOIN_PLATFORM_PROOF_MERK_H + +#include + +#include +#include +#include +#include +#include + +//! Merk (Merkle AVL tree) proof verification, a pure C++ port of the "verify" +//! feature slice of dashpay/grovedb tag v5.0.0: +//! - merk/src/tree/hash.rs (blake3 node hashing) +//! - grovedb-query/src/proofs/mod.rs (Op / Node variants) +//! - grovedb-query/src/proofs/encoding.rs (op stream decoding) +//! - grovedb-query/src/proofs/tree_feature_type.rs +//! - merk/src/proofs/tree.rs (op replay stack machine) +//! - merk/src/proofs/query/verify.rs (query matching + absence proofs) +namespace platform::merk { + +using Hash256 = std::array; + +//! merk/src/tree/hash.rs NULL_HASH: 32 zero bytes. +inline constexpr Hash256 NULL_HASH{}; + +//! blake3(varint(len(value)) || value); varint is LEB128 +//! (merk/src/tree/hash.rs value_hash). +Hash256 ValueHash(Span value); +//! blake3(varint(len(key)) || key || value_hash(value)) +//! (merk/src/tree/hash.rs kv_hash). +Hash256 KvHash(Span key, Span value); +//! blake3(varint(len(key)) || key || value_hash) +//! (merk/src/tree/hash.rs kv_digest_to_kv_hash). +Hash256 KvDigestToKvHash(Span key, const Hash256& value_hash); +//! blake3(kv || left || right) (merk/src/tree/hash.rs node_hash). +Hash256 NodeHash(const Hash256& kv, const Hash256& left, const Hash256& right); +//! blake3(a || b) (merk/src/tree/hash.rs combine_hash). +Hash256 CombineHash(const Hash256& a, const Hash256& b); +//! blake3(kv || left || right || sum_be8) +//! (merk/src/tree/hash.rs node_hash_with_sum; only used by ProvableSumTree +//! proofs, which this verifier rejects, but kept for completeness). +Hash256 NodeHashWithSum(const Hash256& kv, const Hash256& left, const Hash256& right, int64_t sum); + +//! TreeFeatureType (grovedb-query/src/proofs/tree_feature_type.rs). +//! Only BasicMerkNode and SummedMerkNode are supported; the count / big-sum / +//! provable variants are rejected at decode time. +struct TreeFeature { + bool summed{false}; + int64_t sum{0}; +}; + +//! Node variants (grovedb-query/src/proofs/mod.rs enum Node). Only the +//! variants emitted for plain trees and (non-provable) sum trees are +//! supported; the count / MMR / dense / commitment / provable-aggregate op +//! families are rejected with explicit errors. +enum class NodeVariant : uint8_t { + HASH, + KV_HASH, + KV, + KV_VALUE_HASH, + KV_DIGEST, + KV_REF_VALUE_HASH, + KV_VALUE_HASH_FEATURE_TYPE, + KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH, +}; + +struct Node { + NodeVariant variant{NodeVariant::HASH}; + Bytes key; + Bytes value; + Hash256 hash{}; //!< HASH / KV_HASH payload + Hash256 value_hash{}; //!< KV_VALUE_HASH / KV_DIGEST / KV_REF_VALUE_HASH / feature type variants + TreeFeature feature{}; + Hash256 child_hash{}; //!< KV_VALUE_HASH_FEATURE_TYPE_WITH_CHILD_HASH only + + bool IsKeyBearing() const; +}; + +//! Proof operators (grovedb-query/src/proofs/mod.rs enum Op). +enum class OpType : uint8_t { + PUSH, + PUSH_INVERTED, + PARENT, + CHILD, + PARENT_INVERTED, + CHILD_INVERTED, +}; + +struct Op { + OpType type{OpType::PUSH}; + Node node; +}; + +//! Decodes one proof operator from the reader +//! (grovedb-query/src/proofs/encoding.rs impl Decode for Op). Key length is +//! a u8; value lengths are fixed-width big-endian u16 (small ops) or u32 +//! (large ops). Returns false with reader error set on failure. +bool DecodeOp(BytesReader& reader, Op& op); + +//! Query item shapes needed by the platform GUI +//! (grovedb-query/src/query_item/mod.rs enum QueryItem subset). +struct QueryItem { + enum class Type : uint8_t { + KEY, + RANGE, //!< start..end (end exclusive) + RANGE_INCLUSIVE, //!< start..=end + RANGE_FULL, + }; + Type type{Type::KEY}; + Bytes key; //!< KEY + Bytes start; //!< RANGE / RANGE_INCLUSIVE + Bytes end; //!< RANGE / RANGE_INCLUSIVE + + static QueryItem Key(Bytes key); + static QueryItem Range(Bytes start, Bytes end); + static QueryItem RangeInclusive(Bytes start, Bytes end); + static QueryItem RangeFull(); + + //! (bound, is_exclusive); nullopt bound means unbounded. + std::pair>, bool> LowerBound() const; + //! (bound, is_inclusive); nullopt bound means unbounded. + std::pair>, bool> UpperBound() const; + bool LowerUnbounded() const { return type == Type::RANGE_FULL; } + bool UpperUnbounded() const { return type == Type::RANGE_FULL; } + bool Contains(Span key) const; +}; + +//! One proven result entry (merk/src/proofs/query/verify.rs +//! ProvedKeyOptionalValue). +struct ProvedKeyValue { + Bytes key; + Bytes value; + Hash256 value_hash{}; + //! True only for KVValueHashFeatureTypeWithChildHash nodes where + //! combine_hash(H(value), child_hash) == value_hash was checked. + bool child_hash_verified{false}; +}; + +struct VerifyResult { + Hash256 root_hash{}; + std::vector result_set; + std::optional limit_left; +}; + +//! Strict proof version constant (merk/src/proofs/query/verify.rs +//! PROOF_VERSION_LATEST). Version 0 permits item elements in KVValueHash +//! nodes (legacy V0 envelopes); version 1 rejects them. +inline constexpr uint16_t PROOF_VERSION_LATEST{1}; + +//! Executes and verifies a merk proof against a query, mirroring +//! merk/src/proofs/query/verify.rs execute_proof. `items` must be sorted +//! ascending and non-overlapping (grovedb's Query::insert_item invariant); +//! this is validated and violations are errors. +//! +//! On success `out.root_hash` is the reconstructed merk root hash and +//! `out.result_set` contains exactly the entries that answer the query. +//! Absent keys are proven absent via boundary nodes; a proof that omits data +//! required by the query fails verification (security critical: a node must +//! not be able to silently drop matching results). +bool ExecuteProof(Span proof, const std::vector& items, + std::optional limit, bool left_to_right, uint16_t proof_version, + VerifyResult& out, std::string& error); + +//! ExecuteProof + comparison of the reconstructed root hash against +//! expected_hash (merk/src/proofs/query/verify.rs verify_proof). +bool VerifyProof(Span proof, const std::vector& items, + std::optional limit, bool left_to_right, const Hash256& expected_hash, + VerifyResult& out, std::string& error); + +} // namespace platform::merk + +#endif // BITCOIN_PLATFORM_PROOF_MERK_H diff --git a/src/platform/serialize.cpp b/src/platform/serialize.cpp new file mode 100644 index 000000000000..16d2d364b8c6 --- /dev/null +++ b/src/platform/serialize.cpp @@ -0,0 +1,172 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +namespace platform { + +void WriteLEB128(Bytes& out, uint64_t value) +{ + while (true) { + uint8_t byte = value & 0x7f; + value >>= 7; + if (value != 0) byte |= 0x80; + out.push_back(byte); + if (value == 0) return; + } +} + +bool BytesReader::SetError(const std::string& error) +{ + if (m_error.empty()) m_error = error; + return false; +} + +bool BytesReader::ReadBytes(size_t len, Bytes& out) +{ + if (HasError()) return false; + if (Remaining() < len) return SetError(strprintf("unexpected end of data: wanted %u bytes, have %u", len, Remaining())); + out.assign(m_data.begin() + m_pos, m_data.begin() + m_pos + len); + m_pos += len; + return true; +} + +bool BytesReader::ReadInto(Span out) +{ + if (HasError()) return false; + if (Remaining() < out.size()) return SetError(strprintf("unexpected end of data: wanted %u bytes, have %u", out.size(), Remaining())); + std::copy(m_data.begin() + m_pos, m_data.begin() + m_pos + out.size(), out.begin()); + m_pos += out.size(); + return true; +} + +bool BytesReader::ReadU8(uint8_t& out) +{ + if (HasError()) return false; + if (Remaining() < 1) return SetError("unexpected end of data reading u8"); + out = m_data[m_pos++]; + return true; +} + +bool BytesReader::ReadU16BE(uint16_t& out) +{ + if (HasError()) return false; + if (Remaining() < 2) return SetError("unexpected end of data reading u16"); + out = (uint16_t{m_data[m_pos]} << 8) | uint16_t{m_data[m_pos + 1]}; + m_pos += 2; + return true; +} + +bool BytesReader::ReadU32BE(uint32_t& out) +{ + if (HasError()) return false; + if (Remaining() < 4) return SetError("unexpected end of data reading u32"); + out = 0; + for (int i = 0; i < 4; ++i) { + out = (out << 8) | m_data[m_pos + i]; + } + m_pos += 4; + return true; +} + +bool BytesReader::ReadU64BE(uint64_t& out) +{ + if (HasError()) return false; + if (Remaining() < 8) return SetError("unexpected end of data reading u64"); + out = 0; + for (int i = 0; i < 8; ++i) { + out = (out << 8) | m_data[m_pos + i]; + } + m_pos += 8; + return true; +} + +bool BytesReader::ReadI64BE(int64_t& out) +{ + uint64_t value; + if (!ReadU64BE(value)) return false; + out = static_cast(value); + return true; +} + +bool BytesReader::ReadLEB128(uint64_t& out) +{ + if (HasError()) return false; + out = 0; + for (int shift = 0; shift < 64; shift += 7) { + uint8_t byte; + if (!ReadU8(byte)) return false; + if (shift == 63 && (byte & 0x7e) != 0) return SetError("LEB128 varint overflows 64 bits"); + out |= uint64_t{static_cast(byte & 0x7f)} << shift; + if ((byte & 0x80) == 0) return true; + } + return SetError("LEB128 varint too long"); +} + +bool BytesReader::ReadLEB128Signed(int64_t& out) +{ + uint64_t zigzag; + if (!ReadLEB128(zigzag)) return false; + out = static_cast((zigzag >> 1) ^ (~(zigzag & 1) + 1)); + return true; +} + +bool BytesReader::ReadBincodeVarint(uint64_t& out) +{ + if (HasError()) return false; + uint8_t first; + if (!ReadU8(first)) return false; + if (first < 251) { + out = first; + return true; + } + switch (first) { + case 251: { + uint16_t v; + if (!ReadU16BE(v)) return false; + out = v; + return true; + } + case 252: { + uint32_t v; + if (!ReadU32BE(v)) return false; + out = v; + return true; + } + case 253: + return ReadU64BE(out); + default: + return SetError(strprintf("unsupported bincode varint marker %u", first)); + } +} + +bool BytesReader::ReadBincodeVarintSigned(int64_t& out) +{ + uint64_t zigzag; + if (!ReadBincodeVarint(zigzag)) return false; + out = static_cast((zigzag >> 1) ^ (~(zigzag & 1) + 1)); + return true; +} + +bool BytesReader::ReadBincodeOptionTag(bool& has_value) +{ + uint8_t tag; + if (!ReadU8(tag)) return false; + if (tag > 1) return SetError(strprintf("invalid bincode Option tag %u", tag)); + has_value = tag == 1; + return true; +} + +bool BytesReader::ReadBincodeByteVec(Bytes& out, size_t max_len) +{ + uint64_t len; + if (!ReadBincodeVarint(len)) return false; + if (len > max_len) return SetError(strprintf("byte vector length %u exceeds limit %u", len, max_len)); + if (len > Remaining()) return SetError(strprintf("byte vector length %u exceeds remaining data %u", len, Remaining())); + return ReadBytes(static_cast(len), out); +} + +} // namespace platform diff --git a/src/platform/serialize.h b/src/platform/serialize.h new file mode 100644 index 000000000000..c8e2dffb52c5 --- /dev/null +++ b/src/platform/serialize.h @@ -0,0 +1,77 @@ +// 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_SERIALIZE_H +#define BITCOIN_PLATFORM_SERIALIZE_H + +#include + +#include +#include +#include + +namespace platform { + +using Bytes = std::vector; + +//! Appends an unsigned LEB128 varint (integer-encoding crate compatible) to +//! out. Used inside blake3 hash preimages, mirroring +//! grovedb merk/src/tree/hash.rs (integer_encoding::VarInt::encode_var). +void WriteLEB128(Bytes& out, uint64_t value); + +//! Safe forward-only cursor over a byte span. All Read* methods return false +//! on truncation or malformed input and record a human readable error; once +//! an error is set every further read fails. No exceptions are thrown. +class BytesReader +{ +public: + explicit BytesReader(Span data) : m_data(data) {} + + size_t Remaining() const { return m_data.size() - m_pos; } + bool IsEof() const { return m_pos == m_data.size(); } + bool HasError() const { return !m_error.empty(); } + const std::string& GetError() const { return m_error; } + //! Records an error (first error wins) and returns false. + bool SetError(const std::string& error); + + //! Reads len raw bytes into out (overwriting it). + bool ReadBytes(size_t len, Bytes& out); + //! Reads exactly out.size() bytes into a fixed-size buffer. + bool ReadInto(Span out); + bool ReadU8(uint8_t& out); + //! Fixed-width big-endian reads ("ed" crate style, used by merk proof ops). + bool ReadU16BE(uint16_t& out); + bool ReadU32BE(uint32_t& out); + bool ReadU64BE(uint64_t& out); + bool ReadI64BE(int64_t& out); + + //! Unsigned LEB128 varint (integer-encoding crate). Rejects encodings + //! longer than 10 bytes or overflowing 64 bits. + bool ReadLEB128(uint64_t& out); + //! Signed LEB128 varint with zigzag (integer-encoding crate signed ints). + bool ReadLEB128Signed(int64_t& out); + + //! bincode-v2 varint in the big-endian configuration + //! (bincode::config::standard().with_big_endian()): values < 251 are a + //! single byte; marker 251 is followed by a big-endian u16, 252 by a + //! big-endian u32, 253 by a big-endian u64 (marker 254/u128 rejected). + //! Used for collection lengths and enum discriminants in grovedb + //! envelopes and elements. + bool ReadBincodeVarint(uint64_t& out); + //! bincode-v2 signed varint: zigzag then ReadBincodeVarint. + bool ReadBincodeVarintSigned(int64_t& out); + //! bincode Option tag: one byte, 0 (nullopt) or 1 (value follows). + bool ReadBincodeOptionTag(bool& has_value); + //! bincode Vec: varint length + raw bytes. max_len guards allocation. + bool ReadBincodeByteVec(Bytes& out, size_t max_len); + +private: + Span m_data; + size_t m_pos{0}; + std::string m_error; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_SERIALIZE_H diff --git a/src/platform/statetransitions.h b/src/platform/statetransitions.h new file mode 100644 index 000000000000..ebef3cfa57f5 --- /dev/null +++ b/src/platform/statetransitions.h @@ -0,0 +1,140 @@ +// 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. +Result BuildDpnsPreorder( + const Identifier& identity, + uint64_t identity_contract_nonce, + const std::array& salted_domain_hash, + 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..3428a0748990 --- /dev/null +++ b/src/platform/transport/client.cpp @@ -0,0 +1,751 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace 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; +} + +//! Extract a DAPI response's v0 body, its Proof (result field 2) and its +//! ResponseMetadata (field 3). +struct ParsedResponse { + std::optional> v0; + std::optional> non_proof; // result field 1 (value/documents) + std::optional> proof; // result field 2 + std::optional> metadata; // field 3 +}; + +ParsedResponse ParseResponse(Span msg) +{ + ParsedResponse r; + r.v0 = pb::GetLenField(msg, 1); + if (!r.v0) return r; + r.non_proof = pb::GetLenField(*r.v0, 1); + r.proof = pb::GetLenField(*r.v0, 2); + r.metadata = pb::GetLenField(*r.v0, 3); + return r; +} + +//! Decode a DAPI Proof message into a drive::ProofEnvelope + its grovedb bytes. +bool DecodeProof(Span proof_msg, drive::ProofEnvelope& env, + std::vector& grovedb_out) +{ + auto gdb = pb::GetLenField(proof_msg, 1); + auto qh = pb::GetLenField(proof_msg, 2); + auto sig = pb::GetLenField(proof_msg, 3); + auto round = pb::GetVarintField(proof_msg, 4); + auto bid = pb::GetLenField(proof_msg, 5); + auto qt = pb::GetVarintField(proof_msg, 6); + if (!gdb || !qh || !sig || !bid) return false; + if (qh->size() != 32 || bid->size() != 32 || sig->size() != 96) return false; + grovedb_out.assign(gdb->begin(), gdb->end()); + std::copy(qh->begin(), qh->end(), env.quorum_hash.begin()); + std::copy(bid->begin(), bid->end(), env.block_id_hash.begin()); + std::copy(sig->begin(), sig->end(), env.signature.begin()); + env.round = round ? static_cast(*round) : 0; + env.quorum_type = qt ? static_cast(*qt) : 0; + return true; +} + +drive::BlockContext DecodeMetadata(std::optional> md, const std::string& chain_id) +{ + drive::BlockContext ctx; + ctx.chain_id = chain_id; + if (!md) return ctx; + if (auto h = pb::GetVarintField(*md, 1)) ctx.height = *h; + if (auto c = pb::GetVarintField(*md, 2)) ctx.core_chain_locked_height = static_cast(*c); + if (auto t = pb::GetVarintField(*md, 4)) ctx.time_ms = *t; + if (auto p = pb::GetVarintField(*md, 5)) ctx.protocol_version = *p; + return ctx; +} + +class GrpcWebClient final : public PlatformClient +{ +public: + explicit GrpcWebClient(Params params) : m_params(std::move(params)) + { + m_worker = std::thread([this] { Run(); }); + } + ~GrpcWebClient() override { shutdown(); } + + void shutdown() override + { + { + std::lock_guard lk(m_mtx); + if (m_stop) return; + m_stop = true; + } + m_cv.notify_all(); + if (m_worker.joinable()) m_worker.join(); + } + + void updateEndpoints(std::vector endpoints) override + { + std::lock_guard lk(m_mtx); + m_endpoints = std::move(endpoints); + m_ep_index = 0; + } + void updateQuorumKeys(uint8_t llmq_type, std::vector keys) override + { + std::lock_guard lk(m_mtx); + m_quorum_type = llmq_type; + m_quorum_keys = std::move(keys); + } + 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 && m_queue.empty()) 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) + { + return transport::GrpcWebUnary(ep.service.ToStringAddr(), ep.service.GetPort(), + std::string(SVC) + method, req, CALL_TIMEOUT_MS); + } + + drive::QuorumKeyLookup MakeLookup() + { + std::lock_guard lk(m_mtx); + auto keys = m_quorum_keys; + return [keys](uint8_t, const drive::Hash256& qh) -> std::optional> { + for (const auto& k : keys) { + if (k.matchesProofHash(qh)) return k.pubkey; + } + return std::nullopt; + }; + } + + //! Verifies the quorum-signed root binding and then enforces freshness + //! (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 BindAndCheckFresh(const drive::Hash256& root, const drive::ProofEnvelope& env, + const drive::BlockContext& ctx, const drive::QuorumKeyLookup& lookup, + const std::string& endpoint_key, std::string& err) + { + if (!drive::VerifyRootBinding(root, env, ctx, lookup, err)) return false; + std::lock_guard lk(m_mtx); + return m_freshness.Accept(endpoint_key, ctx.height, ctx.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) { + 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"; + } + std::string terr, endpoint_key; + auto r = Call(method, VersionWrap(std::move(v0)).data(), 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; } + auto p = ParseResponse(r.message); + if (!p.proof) { out.error = method + ": no proof in response"; cb(out); return; } + drive::ProofEnvelope env; + std::vector proof; + if (!DecodeProof(*p.proof, env, proof)) { out.error = method + ": bad proof envelope"; cb(out); return; } + drive::Hash256 root; + std::optional nonce; + std::string err; + const bool verified = contract + ? drive::VerifyIdentityContractNonce(proof, id, *contract, nonce, root, err) + : drive::VerifyIdentityNonce(proof, id, nonce, root, err); + if (!verified || !BindAndCheckFresh(root, env, + DecodeMetadata(p.metadata, m_params.tenderdash_chain_id), MakeLookup(), endpoint_key, err)) { + out.error = method + ": proof verification failed: " + err; cb(out); return; + } + out.value = nonce.value_or(0); + cb(out); + } + + void DoGetIdentity(const Identifier& id, const Callback>& cb) + { + // Proof-verified full identity: three simple proved sub-queries that + // must all commit to the same GroveDB state root (the invariant + // drive::VerifyFullIdentity enforces). Because the three are separate + // DAPI requests, they are pinned to a single endpoint per attempt so + // honest nodes at slightly different platform heights cannot produce + // mismatched roots; a failed attempt retries the whole operation + // against another endpoint. + auto lookup = MakeLookup(); + Result> out; + + size_t start{0}; + const std::vector endpoints{SnapshotEndpoints(start)}; + if (endpoints.empty()) { out.error = "no evonode endpoints available"; cb(out); return; } + + struct ProvedResponse { + std::vector proof; + drive::ProofEnvelope envelope; + drive::BlockContext context; + }; + + bool succeeded{false}; + transport::RetryAcrossEndpoints(endpoints, start, MAX_OP_ATTEMPTS, + [&](const Endpoint& ep, size_t) -> transport::AttemptStatus { + const std::string key{EndpointKey(ep)}; + ProvedResponse balance, revision, keys; + + // getIdentityBalance / getIdentityBalanceAndRevision take + // {id=1, prove=2}; getIdentityKeys takes {id=1, request_type=2 + // (an AllKeys sub-message), prove=5} — see platform.proto. All + // three go to the pinned endpoint `ep`. + auto fetch = [&](const std::string& method, bool keys_request, ProvedResponse& proved) -> bool { + pb::Writer v0; + v0.Bytes(1, std::vector(id.begin(), id.end())); + if (keys_request) { + pb::Writer all_keys; // AllKeys {} + pb::Writer request_type; // KeyRequestType { all_keys = 1 } + request_type.Message(1, all_keys.take()); + v0.Message(2, request_type.take()); + v0.Bool(5, true); // prove + } else { + v0.Bool(2, true); // prove + } + auto r = CallOn(ep, method, VersionWrap(std::move(v0)).data()); + if (!r.transport_ok || r.grpc_status != 0) { out.error = r.transport_ok ? r.grpc_message : r.transport_error; return false; } + auto p = ParseResponse(r.message); + if (!p.proof) { out.error = method + ": no proof in response"; return false; } + if (!DecodeProof(*p.proof, proved.envelope, proved.proof)) { out.error = method + ": bad proof envelope"; return false; } + proved.context = DecodeMetadata(p.metadata, m_params.tenderdash_chain_id); + return true; + }; + + if (!fetch("getIdentityBalance", false, balance) || + !fetch("getIdentityBalanceAndRevision", false, revision) || + !fetch("getIdentityKeys", true, keys)) { + return transport::AttemptStatus::Retry; + } + + std::optional balance_value, revision_value; + std::optional> key_values; + drive::Hash256 balance_root, revision_root, keys_root; + std::string err; + const bool proofs_ok = + drive::VerifyIdentityBalance(balance.proof, id, balance_value, balance_root, err) && + BindAndCheckFresh(balance_root, balance.envelope, balance.context, lookup, key, err) && + drive::VerifyIdentityRevision(revision.proof, id, revision_value, revision_root, err) && + BindAndCheckFresh(revision_root, revision.envelope, revision.context, lookup, key, err) && + drive::VerifyIdentityKeys(keys.proof, id, key_values, keys_root, err) && + BindAndCheckFresh(keys_root, keys.envelope, keys.context, lookup, key, err); + if (!proofs_ok) { + out.error = "identity proof verification failed: " + err; + return transport::AttemptStatus::Retry; + } + if (balance_root != revision_root || balance_root != keys_root) { + // Block boundary landed mid-operation; another endpoint (or + // a later poll) will answer at a single height. + out.error = "identity sub-proofs commit to different state roots"; + return transport::AttemptStatus::Retry; + } + + if (!balance_value && !revision_value && !key_values) { + out.value = std::optional{}; // proven fully absent + } else if (!balance_value || !revision_value || !key_values) { + out.error = "identity proof components were inconsistent"; + return transport::AttemptStatus::Retry; + } else { + Identity identity; + identity.id = id; + identity.balance = *balance_value; + identity.revision = *revision_value; + identity.public_keys = std::move(*key_values); + out.value = std::move(identity); + } + 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) + { + pb::Writer v0; + v0.Bytes(1, std::vector(h.begin(), h.end())); + v0.Bool(2, true); + std::string terr, endpoint_key; + auto r = Call("getIdentityByPublicKeyHash", VersionWrap(std::move(v0)).data(), 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; } + auto p = ParseResponse(r.message); + if (!p.proof) { out.error = "no proof"; cb(out); return; } + drive::ProofEnvelope env; std::vector gp; + if (!DecodeProof(*p.proof, env, gp)) { out.error = "bad proof"; cb(out); return; } + auto ctx = DecodeMetadata(p.metadata, m_params.tenderdash_chain_id); + std::optional id_out; std::string err; + drive::Hash256 root; + if (!drive::VerifyIdentityIdByPublicKeyHash(gp, h, id_out, root, err) || + !BindAndCheckFresh(root, env, ctx, MakeLookup(), endpoint_key, err)) { + out.error = err; cb(out); return; + } + if (!id_out) { out.value = std::optional{}; cb(out); return; } + // Follow the id to the full identity. + DoGetIdentity(*id_out, cb); + } + + using DocumentVerifier = std::function, std::vector&, + drive::Hash256&, std::string&)>; + + // Document queries (DPNS domain / DashPay profile & contactRequest). The + // server returns only a GroveDB proof; the requested Drive index path is + // reconstructed locally, including cryptographic absence, and the root is + // bound 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, + const DocumentVerifier& verifier, 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 + std::string terr, endpoint_key; + auto r = Call("getDocuments", VersionWrap(std::move(v0)).data(), 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; + } + auto p = ParseResponse(r.message); + if (!p.proof) { err = "getDocuments response did not contain a proof"; return docs; } + drive::ProofEnvelope env; + std::vector grovedb_proof; + if (!DecodeProof(*p.proof, env, grovedb_proof)) { err = "bad document proof envelope"; return docs; } + drive::Hash256 root; + if (!verifier(grovedb_proof, docs, root, err)) { + LogPrintf("Platform getDocuments(%s) proof verification failed: %s\n", doc_type, err); + return {}; + } + const auto ctx = DecodeMetadata(p.metadata, m_params.tenderdash_chain_id); + if (!BindAndCheckFresh(root, env, ctx, MakeLookup(), endpoint_key, err)) { + LogPrintf("Platform getDocuments(%s) root binding failed: %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 + std::string terr, endpoint_key; + auto r = Call("getContestedResourceVoteState", VersionWrap(std::move(v0)).data(), 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; + } + auto p = ParseResponse(r.message); + if (!p.proof) { out.error = "getContestedResourceVoteState: no proof in response"; cb(out); return; } + drive::ProofEnvelope env; + std::vector grovedb_proof; + if (!DecodeProof(*p.proof, env, grovedb_proof)) { out.error = "bad contested vote proof envelope"; cb(out); return; } + + // Index values as raw tree keys (strings encode to their utf8 bytes, + // DocumentPropertyType::encode_value_for_tree_keys). + const std::vector index_values{Bytes{'d', 'a', 's', 'h'}, + Bytes(normalized_label.begin(), normalized_label.end())}; + drive::ContestedVoteState state; + drive::Hash256 root; + std::string err; + if (!drive::VerifyContestedVoteState(grovedb_proof, DPNS_CONTRACT_ID, "domain", index_values, + CONTESTED_VOTE_COUNT, state, root, err) || + !BindAndCheckFresh(root, env, DecodeMetadata(p.metadata, m_params.tenderdash_chain_id), + MakeLookup(), endpoint_key, err)) { + out.error = "contested vote state proof verification failed: " + err; + cb(out); + return; + } + + 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, + [&normalized_label](Span proof, std::vector& out, + drive::Hash256& root, std::string& verify_error) { + return drive::VerifyDpnsNameExact(proof, normalized_label, out, root, verify_error); + }, 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, + [&prefix, limit](Span proof, std::vector& out, + drive::Hash256& root, std::string& verify_error) { + return drive::VerifyDpnsNamePrefix(proof, prefix, static_cast(limit), + out, root, verify_error); + }, err, DpnsPrefixOrderBy()); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector names; + for (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, + [&identity](Span proof, std::vector& out, + drive::Hash256& root, std::string& verify_error) { + return drive::VerifyDpnsNamesByIdentity(proof, identity, 100, out, root, verify_error); + }, err); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector names; + for (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, + [&owner_id](Span proof, std::vector& out, + drive::Hash256& root, std::string& verify_error) { + return drive::VerifyDashPayProfileByOwner(proof, owner_id, out, root, verify_error); + }, 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, + [&identity, to_me](Span proof, std::vector& docs_out, + drive::Hash256& root, std::string& verify_error) { + return drive::VerifyDashPayContactRequests(proof, identity, to_me, 100, + docs_out, root, verify_error); + }, err, order_by.take()); + Result> out; + if (!err.empty()) { out.error = err; cb(out); return; } + std::vector reqs; + for (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; + bool m_stop{false}; + std::vector m_endpoints; + size_t m_ep_index{0}; + uint8_t m_quorum_type{0}; + std::vector m_quorum_keys; + //! 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..cce26734605e --- /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. This is what keeps a multi-proof operation (e.g. +//! getIdentity's balance/revision/keys sub-queries) consistent: every +//! sub-request within one attempt targets the same node, so honest nodes at +//! slightly different platform heights cannot produce mismatched roots for a +//! single logical read. `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..a1146414b0c9 --- /dev/null +++ b/src/platform/transport/grpcweb.cpp @@ -0,0 +1,200 @@ +// 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 + +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; +} + +} // namespace + +GrpcCallResult GrpcWebUnary(const std::string& host, uint16_t port, const std::string& path, + const std::vector& request, int timeout_ms) +{ + GrpcCallResult result; + + auto conn = TlsConnection::Connect(host, port, timeout_ms, result.transport_error); + 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 += "Te: 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; + } + } + + // Split HTTP headers from body. + static const uint8_t sep[4] = {'\r', '\n', '\r', '\n'}; + auto it = std::search(resp.begin(), resp.end(), sep, sep + 4); + if (it == resp.end()) { + result.transport_error = "malformed HTTP response"; + return result; + } + const std::string headers(resp.begin(), it); + std::vector payload(it + 4, resp.end()); + + // HTTP status line. + { + const auto eol = headers.find("\r\n"); + const std::string status_line = headers.substr(0, eol); + // "HTTP/1.1 200 OK" + const auto sp = status_line.find(' '); + if (sp == std::string::npos || status_line.substr(sp + 1, 3) != "200") { + result.transport_error = "HTTP error: " + status_line; + // gRPC-Web may still deliver grpc-status in headers on non-200; but + // treat as transport error for simplicity. + return result; + } + } + + // Envoy may return the response chunked (Transfer-Encoding: chunked). Undo + // chunk framing if present. + if (ToLower(headers).find("transfer-encoding: chunked") != std::string::npos) { + std::vector dechunked; + size_t p = 0; + while (p < payload.size()) { + // read hex length line + size_t line_end = p; + while (line_end + 1 < payload.size() && !(payload[line_end] == '\r' && payload[line_end + 1] == '\n')) { + ++line_end; + } + if (line_end + 1 >= payload.size()) break; + std::string hexlen(payload.begin() + p, payload.begin() + line_end); + // A chunk-size line may carry optional ";"-delimited chunk + // extensions (RFC 9112 sec 7.1.1), e.g. "1a;ext=value"; only the + // leading hex is the size. Locale-independent hex parse via + // std::from_chars (std::stoul would consult the locale). + const size_t ext{hexlen.find(';')}; + if (ext != std::string::npos) hexlen.resize(ext); + size_t chunk_len = 0; + { + const char* first{hexlen.c_str()}; + 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) break; + } + p = line_end + 2; + if (chunk_len == 0) break; + if (p + chunk_len > payload.size()) break; + dechunked.insert(dechunked.end(), payload.begin() + p, payload.begin() + p + chunk_len); + p += chunk_len + 2; // skip trailing CRLF + } + payload.swap(dechunked); + } + + result.transport_ok = true; + + // Parse gRPC-Web frames: data frame(s) then a trailers frame (0x80). + result.grpc_status = 0; // default OK unless headers/trailers override + // Envoy is also allowed to return grpc-status/grpc-message as HTTP + // headers, notably for validation errors with an empty response body. + // Capture those first; a trailers frame below remains authoritative. + const std::string lower_headers{ToLower(headers)}; + const auto header_value = [&](const std::string& name) -> std::optional { + const std::string needle{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 end{headers.find("\r\n", start)}; + return headers.substr(start, end == std::string::npos ? std::string::npos : end - start); + }; + if (const auto status{header_value("grpc-status")}) result.grpc_status = LocaleIndependentAtoi(*status); + if (const auto message{header_value("grpc-message")}) result.grpc_message = urlDecode(*message); + size_t p = 0; + while (p + 5 <= payload.size()) { + const uint8_t flags = payload[p]; + const uint32_t len = (static_cast(payload[p + 1]) << 24) | + (static_cast(payload[p + 2]) << 16) | + (static_cast(payload[p + 3]) << 8) | + static_cast(payload[p + 4]); + p += 5; + if (p + len > payload.size()) break; + Span frame(payload.data() + p, len); + p += len; + if (flags & 0x80) { + // Trailers frame: "grpc-status: N\r\ngrpc-message: ...\r\n" + const std::string trailers(frame.begin(), frame.end()); + const auto lower = ToLower(trailers); + auto spos = lower.find("grpc-status:"); + if (spos != std::string::npos) { + result.grpc_status = LocaleIndependentAtoi(trailers.substr(spos + 12)); + } + auto mpos = lower.find("grpc-message:"); + if (mpos != std::string::npos) { + auto start = mpos + 13; + while (start < trailers.size() && trailers[start] == ' ') ++start; + auto end = trailers.find("\r\n", start); + result.grpc_message = trailers.substr(start, end == std::string::npos ? std::string::npos : end - start); + } + } else { + result.message.assign(frame.begin(), frame.end()); + } + } + + return result; +} + +} // namespace platform::transport diff --git a/src/platform/transport/grpcweb.h b/src/platform/transport/grpcweb.h new file mode 100644 index 000000000000..715f3119fd11 --- /dev/null +++ b/src/platform/transport/grpcweb.h @@ -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. + +#ifndef BITCOIN_PLATFORM_TRANSPORT_GRPCWEB_H +#define BITCOIN_PLATFORM_TRANSPORT_GRPCWEB_H + +#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 +}; + +//! 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); + +} // 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..67183cb7faee --- /dev/null +++ b/src/platform/transport/protobuf.cpp @@ -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. + +#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()); +} + +bool Reader::ReadVarint(uint64_t& out) +{ + out = 0; + int shift = 0; + while (m_pos < m_data.size()) { + const uint8_t b = m_data[m_pos++]; + if (shift < 64) out |= static_cast(b & 0x7f) << shift; + if (!(b & 0x80)) return true; + shift += 7; + if (shift > 70) break; + } + m_failed = true; + return false; +} + +bool Reader::Next(Field& out) +{ + if (m_failed || m_pos >= m_data.size()) return false; + uint64_t tag{0}; + if (!ReadVarint(tag)) return false; + out.number = static_cast(tag >> 3); + out.type = static_cast(tag & 0x7); + switch (out.type) { + case WireType::Varint: + return ReadVarint(out.varint); + case WireType::I64: { + // m_pos <= m_data.size() always, so size()-m_pos never underflows; + // the subtraction form also avoids any m_pos + N overflow. + if (m_data.size() - m_pos < 8) { m_failed = true; return false; } + out.varint = 0; + for (int i = 0; i < 8; ++i) out.varint |= static_cast(m_data[m_pos++]) << (8 * i); + return true; + } + case WireType::I32: { + if (m_data.size() - m_pos < 4) { m_failed = true; return false; } + out.varint = 0; + for (int i = 0; i < 4; ++i) out.varint |= static_cast(m_data[m_pos++]) << (8 * i); + return true; + } + case WireType::Len: { + uint64_t len{0}; + if (!ReadVarint(len)) return false; + // len is attacker-controlled and up to 2^64-1; compare against the + // remaining byte count without ever computing m_pos + len (which + // would wrap and yield an out-of-bounds subspan). + if (len > m_data.size() - m_pos) { m_failed = true; return false; } + out.bytes = m_data.subspan(m_pos, static_cast(len)); + m_pos += static_cast(len); + return true; + } + default: + m_failed = true; + return false; + } +} + +std::optional> GetLenField(Span msg, uint32_t field) +{ + Reader r{msg}; + Field f; + while (r.Next(f)) { + if (f.number == field && f.type == WireType::Len) return f.bytes; + } + return std::nullopt; +} + +std::optional GetVarintField(Span msg, uint32_t field) +{ + Reader r{msg}; + Field f; + while (r.Next(f)) { + if (f.number == field && f.type == WireType::Varint) return f.varint; + } + return std::nullopt; +} + +} // namespace platform::pb diff --git a/src/platform/transport/protobuf.h b/src/platform/transport/protobuf.h new file mode 100644 index 000000000000..bd5645f6d692 --- /dev/null +++ b/src/platform/transport/protobuf.h @@ -0,0 +1,80 @@ +// 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 +#include + +/** + * Minimal protobuf wire-format reader/writer — just enough to (de)serialize + * the handful of DAPI Platform messages the GUI uses. Only the wire types we + * need are supported: varint (0), 64-bit (1), length-delimited (2), 32-bit + * (5). 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; +}; + +//! One decoded field: tag + the raw payload interpreted lazily. +struct Field { + uint32_t number{0}; + WireType type{WireType::Varint}; + uint64_t varint{0}; //!< for Varint/I64/I32 + Span bytes; //!< for Len +}; + +class Reader +{ +public: + explicit Reader(Span data) : m_data(data) {} + + //! Read the next field. Returns false at end of input or on malformed + //! data (check failed()). + bool Next(Field& out); + bool failed() const { return m_failed; } + bool eof() const { return m_pos >= m_data.size(); } + +private: + bool ReadVarint(uint64_t& out); + Span m_data; + size_t m_pos{0}; + bool m_failed{false}; +}; + +//! Convenience: extract the length-delimited payload of a single field number +//! from a message (first occurrence), following an optional oneof-version +//! wrapper. Returns nullopt if absent/malformed. +std::optional> GetLenField(Span msg, uint32_t field); +std::optional GetVarintField(Span msg, uint32_t field); + +} // 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..d9fc9e0b4dac --- /dev/null +++ b/src/platform/transport/tls.cpp @@ -0,0 +1,135 @@ +// 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 + +namespace platform::transport { + +struct TlsConnection::Impl { + mbedtls_net_context net; + mbedtls_ssl_context ssl; + mbedtls_ssl_config conf; + mbedtls_ctr_drbg_context ctr_drbg; + mbedtls_entropy_context entropy; + bool net_open{false}; + + Impl() + { + mbedtls_net_init(&net); + mbedtls_ssl_init(&ssl); + mbedtls_ssl_config_init(&conf); + mbedtls_ctr_drbg_init(&ctr_drbg); + mbedtls_entropy_init(&entropy); + } + ~Impl() + { + if (net_open) mbedtls_ssl_close_notify(&ssl); + mbedtls_net_free(&net); + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&conf); + mbedtls_ctr_drbg_free(&ctr_drbg); + mbedtls_entropy_free(&entropy); + } +}; + +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) +{ + auto conn = std::unique_ptr(new TlsConnection()); + Impl& s = *conn->m_impl; + + 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 std::string port_str = ToString(port); + if (mbedtls_net_connect(&s.net, host.c_str(), port_str.c_str(), MBEDTLS_NET_PROTO_TCP) != 0) { + error = "tcp connect to " + host + ":" + port_str + " failed"; + return nullptr; + } + s.net_open = true; + // Blocking socket; per-read timeouts are enforced through + // mbedtls_net_recv_timeout (configured via ssl_conf_read_timeout). + mbedtls_net_set_block(&s.net); + + 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); + mbedtls_ssl_conf_read_timeout(&s.conf, timeout_ms > 0 ? static_cast(timeout_ms) : 0); + + if (mbedtls_ssl_setup(&s.ssl, &s.conf) != 0) { + error = "ssl setup failed"; + return nullptr; + } + mbedtls_ssl_set_hostname(&s.ssl, host.c_str()); + mbedtls_ssl_set_bio(&s.ssl, &s.net, mbedtls_net_send, mbedtls_net_recv, mbedtls_net_recv_timeout); + + int ret; + while ((ret = mbedtls_ssl_handshake(&s.ssl)) != 0) { + if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) { + 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()) { + 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) 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 (;;) { + 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) 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..732479398c43 --- /dev/null +++ b/src/platform/transport/tls.h @@ -0,0 +1,54 @@ +// 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 + +/** + * 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); + + //! 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..91ba99825e25 --- /dev/null +++ b/src/platform/types.h @@ -0,0 +1,113 @@ +// 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 { + +//! 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. +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}; +}; + +} // namespace platform + +#endif // BITCOIN_PLATFORM_TYPES_H diff --git a/src/pubkey.cpp b/src/pubkey.cpp index 8de5143dbb0b..6583fb9da837 100644 --- a/src/pubkey.cpp +++ b/src/pubkey.cpp @@ -258,6 +258,34 @@ bool CPubKey::Derive(CPubKey& pubkeyChild, ChainCode &ccChild, unsigned int nChi return true; } +bool CPubKey::Derive256(CPubKey& pubkeyChild, ChainCode& ccChild, Span nChild, const ChainCode& cc) const { + assert(IsValid()); + assert(nChild.size() == 32); + assert(size() == COMPRESSED_SIZE); + // DIP-14 compatibility mode: indexes below 2^32 derive exactly as BIP32. + // A hardened (high bit set) 32-bit index cannot be derived from a pubkey. + if (std::all_of(nChild.begin(), nChild.begin() + 28, [](unsigned char c) { return c == 0; })) { + uint32_t child32 = ReadBE32(nChild.data() + 28); + if (child32 >> 31) return false; + return Derive(pubkeyChild, ccChild, child32, cc); + } + unsigned char out[64]; + DIP14Hash(cc, nChild.data(), *begin(), begin() + 1, out); + memcpy(ccChild.begin(), out + 32, 32); + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_parse(secp256k1_context_static, &pubkey, vch, size())) { + return false; + } + if (!secp256k1_ec_pubkey_tweak_add(secp256k1_context_static, &pubkey, out)) { + return false; + } + unsigned char pub[COMPRESSED_SIZE]; + size_t publen = COMPRESSED_SIZE; + secp256k1_ec_pubkey_serialize(secp256k1_context_static, pub, &publen, &pubkey, SECP256K1_EC_COMPRESSED); + pubkeyChild.Set(pub, pub + publen); + return true; +} + EllSwiftPubKey::EllSwiftPubKey(Span ellswift) noexcept { assert(ellswift.size() == SIZE); diff --git a/src/pubkey.h b/src/pubkey.h index 990a33ccef99..a9304ffcd087 100644 --- a/src/pubkey.h +++ b/src/pubkey.h @@ -209,6 +209,11 @@ class CPubKey //! Derive BIP32 child pubkey. [[nodiscard]] bool Derive(CPubKey& pubkeyChild, ChainCode &ccChild, unsigned int nChild, const ChainCode& cc) const; + + //! Derive DIP-14 (non-hardened) child pubkey with a 256-bit index + //! (32 bytes, big-endian). Indexes below 2^32 fall back to BIP32 + //! derivation for compatibility; a hardened 32-bit index fails. + [[nodiscard]] bool Derive256(CPubKey& pubkeyChild, ChainCode& ccChild, Span nChild, const ChainCode& cc) const; }; /** An ElligatorSwift-encoded public key. */ diff --git a/src/qt/bitcoinaddressvalidator.cpp b/src/qt/bitcoinaddressvalidator.cpp index 003f1d100578..4639ed6cfff3 100644 --- a/src/qt/bitcoinaddressvalidator.cpp +++ b/src/qt/bitcoinaddressvalidator.cpp @@ -22,6 +22,33 @@ BitcoinAddressEntryValidator::BitcoinAddressEntryValidator(QObject *parent, bool { } +DashPayRecipientEntryValidator::DashPayRecipientEntryValidator(QObject* parent, bool allow_uri) : + QValidator(parent), + m_address_validator(nullptr, allow_uri) +{ +} + +QValidator::State DashPayRecipientEntryValidator::validate(QString& input, int& pos) const +{ + const State address_state = m_address_validator.validate(input, pos); + if (address_state != Invalid) return address_state; + + // DPNS labels accept ASCII letters, digits, and interior hyphens. Keep + // incomplete labels editable, but reject impossible candidates early. + if (input.size() > 63 || input.startsWith('-')) return Invalid; + for (const QChar ch : input) { + const ushort c = ch.unicode(); + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || c == '-')) { + return Invalid; + } + } + + if (input.size() < 3 || input.endsWith('-')) return Intermediate; + return Acceptable; +} + QValidator::State BitcoinAddressEntryValidator::validate(QString &input, int &pos) const { Q_UNUSED(pos); diff --git a/src/qt/bitcoinaddressvalidator.h b/src/qt/bitcoinaddressvalidator.h index 9ad573d71110..cf3a9589a347 100644 --- a/src/qt/bitcoinaddressvalidator.h +++ b/src/qt/bitcoinaddressvalidator.h @@ -23,6 +23,26 @@ class BitcoinAddressEntryValidator : public QValidator bool fAllowURI; }; +/** Recipient entry validator that also permits DashPay username candidates. + * + * This only controls which characters may be entered. A username is not a + * payment destination until it has been proof-resolved and replaced with a + * valid Dash address; BitcoinAddressCheckValidator remains responsible for + * that final check. + */ +class DashPayRecipientEntryValidator : public QValidator +{ + Q_OBJECT + +public: + explicit DashPayRecipientEntryValidator(QObject* parent, bool allow_uri = false); + + State validate(QString& input, int& pos) const override; + +private: + BitcoinAddressEntryValidator m_address_validator; +}; + /** Bitcoin address widget validator, checks for a valid bitcoin address. */ class BitcoinAddressCheckValidator : public QValidator diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 4c581111961a..548b7796946f 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -7,6 +7,9 @@ #include #include +#ifdef ENABLE_PLATFORM_GUI +#include +#endif #include #include #include @@ -762,6 +765,13 @@ void BitcoinGUI::createToolBars() coinJoinCoinsButton->setStatusTip(coinJoinCoinsAction->statusTip()); tabGroup->addButton(coinJoinCoinsButton); +#ifdef ENABLE_PLATFORM_GUI + platformButton = new QToolButton(this); + platformButton->setText(tr("&DashPay")); + platformButton->setStatusTip(tr("Usernames, profiles and contacts on Dash Platform")); + tabGroup->addButton(platformButton); +#endif + masternodeButton = new QToolButton(this); masternodeButton->setText(tr("&Masternodes")); masternodeButton->setStatusTip(tr("Browse masternodes")); @@ -779,6 +789,9 @@ void BitcoinGUI::createToolBars() connect(historyButton, &QToolButton::clicked, this, &BitcoinGUI::gotoHistoryPage); connect(governanceButton, &QToolButton::clicked, this, &BitcoinGUI::gotoGovernancePage); connect(masternodeButton, &QToolButton::clicked, this, &BitcoinGUI::gotoMasternodePage); +#ifdef ENABLE_PLATFORM_GUI + connect(platformButton, &QToolButton::clicked, this, &BitcoinGUI::gotoPlatformPage); +#endif // Give the selected tab button a bolder font. connect(tabGroup, qOverload(&QButtonGroup::buttonToggled), this, &BitcoinGUI::highlightTabButton); @@ -792,6 +805,9 @@ void BitcoinGUI::createToolBars() if (button == coinJoinCoinsButton) { m_coinjoin_action = action; } else if (button == governanceButton) { m_governance_action = action; } else if (button == masternodeButton) { m_masternode_action = action; } +#ifdef ENABLE_PLATFORM_GUI + else if (button == platformButton) { m_platform_action = action; } +#endif } overviewButton->setChecked(true); @@ -918,6 +934,9 @@ void BitcoinGUI::setClientModel(ClientModel *_clientModel, interfaces::BlockAndH connect(optionsModel, &OptionsModel::showGovernanceChanged, this, &BitcoinGUI::updateGovernanceVisibility); connect(optionsModel, &OptionsModel::showGovernanceClockChanged, this, &BitcoinGUI::updateGovernanceCycleIcon); connect(optionsModel, &OptionsModel::showMasternodesChanged, this, &BitcoinGUI::updateMasternodesVisibility); +#ifdef ENABLE_PLATFORM_GUI + connect(optionsModel, &OptionsModel::showPlatformChanged, this, &BitcoinGUI::updatePlatformVisibility); +#endif if (trayIcon) { // be aware of the tray icon disable state change reported by the OptionsModel object. @@ -955,6 +974,9 @@ void BitcoinGUI::setClientModel(ClientModel *_clientModel, interfaces::BlockAndH updateCoinJoinVisibility(); updateGovernanceVisibility(); updateMasternodesVisibility(); +#ifdef ENABLE_PLATFORM_GUI + updatePlatformVisibility(); +#endif } #ifdef ENABLE_WALLET @@ -1326,6 +1348,16 @@ void BitcoinGUI::gotoMasternodePage() } } +#ifdef ENABLE_PLATFORM_GUI +void BitcoinGUI::gotoPlatformPage() +{ + if (platformButton) { + platformButton->setChecked(true); + if (walletFrame) walletFrame->gotoPlatformPage(); + } +} +#endif + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsButton->setChecked(true); @@ -1562,6 +1594,32 @@ void BitcoinGUI::updateMasternodesVisibility() updateWidth(); } +#ifdef ENABLE_PLATFORM_GUI +void BitcoinGUI::updatePlatformVisibility() +{ + if (!clientModel || !clientModel->getOptionsModel()) return; + // Only offer the tab on networks where Platform is deployed (or explicitly + // configured), and only when the user opted in. + const bool fShow = clientModel->getOptionsModel()->getShowPlatformTab() && + platform::GetParams(Params().NetworkIDString()).has_value(); + + // Show/hide the underlying QAction, hiding the QToolButton itself doesn't + // work for the GUI part but is still needed for shortcuts to work properly. + if (m_platform_action) m_platform_action->setVisible(fShow); + if (platformButton) { +#ifdef ENABLE_WALLET + if (!fShow && platformButton->isChecked()) { + gotoOverviewPage(); + } +#endif // ENABLE_WALLET + platformButton->setVisible(fShow); + } + + GUIUtil::updateButtonGroupShortcuts(tabGroup); + updateWidth(); +} +#endif // ENABLE_PLATFORM_GUI + void BitcoinGUI::updateWidth() { if (walletFrame == nullptr) { diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 333ef538d412..4bc7c0034802 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -143,6 +143,9 @@ class BitcoinGUI : public QMainWindow QToolButton* historyButton = nullptr; QToolButton* masternodeButton = nullptr; QToolButton* governanceButton = nullptr; +#ifdef ENABLE_PLATFORM_GUI + QToolButton* platformButton = nullptr; +#endif QAction* appToolBarLogoAction = nullptr; QAction* quitAction = nullptr; QAction* sendCoinsAction = nullptr; @@ -154,6 +157,9 @@ class BitcoinGUI : public QMainWindow QAction* m_coinjoin_action = nullptr; QAction* m_governance_action = nullptr; QAction* m_masternode_action = nullptr; +#ifdef ENABLE_PLATFORM_GUI + QAction* m_platform_action = nullptr; +#endif QAction* m_load_psbt_action = nullptr; QAction* m_load_psbt_clipboard_action = nullptr; QAction* aboutAction = nullptr; @@ -354,6 +360,10 @@ public Q_SLOTS: void gotoHistoryPage(); /** Switch to masternode page */ void gotoMasternodePage(); +#ifdef ENABLE_PLATFORM_GUI + /** Switch to DashPay (Dash Platform) page */ + void gotoPlatformPage(); +#endif /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ @@ -415,6 +425,9 @@ public Q_SLOTS: void updateCoinJoinVisibility(); void updateGovernanceVisibility(); void updateMasternodesVisibility(); +#ifdef ENABLE_PLATFORM_GUI + void updatePlatformVisibility(); +#endif void updateWidth(); }; diff --git a/src/qt/forms/optionsdialog.ui b/src/qt/forms/optionsdialog.ui index dd31289875ed..67547734eb4e 100644 --- a/src/qt/forms/optionsdialog.ui +++ b/src/qt/forms/optionsdialog.ui @@ -1080,6 +1080,16 @@ https://explore.transifex.com/dash/dash/ + + + + Show additional tab for DashPay usernames, profiles and contacts on Dash Platform. + + + Show DashPay Tab + + + diff --git a/src/qt/forms/sendcoinsentry.ui b/src/qt/forms/sendcoinsentry.ui index eecf6491077d..9aefd0716cb0 100644 --- a/src/qt/forms/sendcoinsentry.ui +++ b/src/qt/forms/sendcoinsentry.ui @@ -54,6 +54,28 @@ + + + + false + + + Choose a DashPay contact to pay + + + + + + + 22 + 22 + + + + Alt+C + + + diff --git a/src/qt/optionsdialog.cpp b/src/qt/optionsdialog.cpp index 76d0e5934bae..89f9ef6b4fef 100644 --- a/src/qt/optionsdialog.cpp +++ b/src/qt/optionsdialog.cpp @@ -111,6 +111,9 @@ OptionsDialog::OptionsDialog(QWidget* parent, bool enableWallet) pageButtons = new QButtonGroup(this); pageButtons->addButton(ui->btnMain, pageButtons->buttons().size()); +#ifndef ENABLE_PLATFORM_GUI + ui->showPlatformTab->hide(); +#endif /* Remove Wallet/CoinJoin tabs and 3rd party-URL textbox in case of -disablewallet */ if (!m_enable_wallet) { ui->stackedWidgetOptions->removeWidget(ui->pageWallet); @@ -120,6 +123,7 @@ OptionsDialog::OptionsDialog(QWidget* parent, bool enableWallet) ui->thirdPartyTxUrlsLabel->setVisible(false); ui->thirdPartyTxUrls->setVisible(false); ui->showMasternodesTab->hide(); + ui->showPlatformTab->hide(); ui->showGovernanceTab->hide(); ui->showGovernanceCycleIcon->hide(); } else { @@ -394,6 +398,9 @@ void OptionsDialog::setMapper() /* Display */ mapper->addMapping(ui->showMasternodesTab, OptionsModel::ShowMasternodesTab); +#ifdef ENABLE_PLATFORM_GUI + mapper->addMapping(ui->showPlatformTab, OptionsModel::ShowPlatformTab); +#endif mapper->addMapping(ui->showGovernanceCycleIcon, OptionsModel::ShowGovernanceClock); mapper->addMapping(ui->showGovernanceTab, OptionsModel::ShowGovernanceTab); mapper->addMapping(ui->digits, OptionsModel::Digits); diff --git a/src/qt/optionsmodel.cpp b/src/qt/optionsmodel.cpp index 3ab17fc63eb8..9b6c0317defa 100644 --- a/src/qt/optionsmodel.cpp +++ b/src/qt/optionsmodel.cpp @@ -324,6 +324,10 @@ bool OptionsModel::Init(bilingual_str& error) settings.setValue("fShowMasternodesTab", false); m_enable_masternodes = settings.value("fShowMasternodesTab", false).toBool(); + if (!settings.contains("fShowPlatformTab")) + settings.setValue("fShowPlatformTab", false); + m_enable_platform = settings.value("fShowPlatformTab", false).toBool(); + if (!settings.contains("show_governance_clock")) settings.setValue("show_governance_clock", false); m_show_governance_clock = settings.value("show_governance_clock", false).toBool(); @@ -660,6 +664,8 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con return m_sub_fee_from_amount; case ShowMasternodesTab: return m_enable_masternodes; + case ShowPlatformTab: + return m_enable_platform; case ShowGovernanceClock: return m_show_governance_clock; case ShowGovernanceTab: @@ -861,6 +867,13 @@ bool OptionsModel::setOption(OptionID option, const QVariant& value, const std:: Q_EMIT showMasternodesChanged(); } break; + case ShowPlatformTab: + if (changed()) { + m_enable_platform = value.toBool(); + settings.setValue("fShowPlatformTab", m_enable_platform); + Q_EMIT showPlatformChanged(); + } + break; case SubFeeFromAmount: m_sub_fee_from_amount = value.toBool(); settings.setValue("SubFeeFromAmount", m_sub_fee_from_amount); diff --git a/src/qt/optionsmodel.h b/src/qt/optionsmodel.h index ac157c2e4fce..2625e936d271 100644 --- a/src/qt/optionsmodel.h +++ b/src/qt/optionsmodel.h @@ -85,6 +85,7 @@ class OptionsModel : public QAbstractListModel ExternalSignerPath, // QString SpendZeroConfChange, // bool ShowMasternodesTab, // bool + ShowPlatformTab, // bool ShowGovernanceClock, // bool ShowGovernanceTab, // bool CoinJoinEnabled, // bool @@ -138,6 +139,7 @@ class OptionsModel : public QAbstractListModel bool getEnablePSBTControls() const { return m_enable_psbt_controls; } bool getKeepChangeAddress() const { return fKeepChangeAddress; } bool getShowMasternodesTab() const { return m_enable_masternodes; } + bool getShowPlatformTab() const { return m_enable_platform; } bool getShowGovernanceClock() const { return m_show_governance_clock; } bool getShowGovernanceTab() const { return m_enable_governance; } bool getShowAdvancedCJUI() { return fShowAdvancedCJUI; } @@ -175,6 +177,7 @@ class OptionsModel : public QAbstractListModel bool m_mask_values; bool fKeepChangeAddress; bool m_enable_masternodes; + bool m_enable_platform; bool m_enable_governance; bool m_show_governance_clock; bool fShowAdvancedCJUI; @@ -202,6 +205,7 @@ class OptionsModel : public QAbstractListModel void showGovernanceClockChanged(); void showGovernanceChanged(); void showMasternodesChanged(); + void showPlatformChanged(); void showTrayIconChanged(bool); void fontForMoneyChanged(const QFont&); void dustProtectionChanged(); diff --git a/src/qt/platform/contactflow.cpp b/src/qt/platform/contactflow.cpp new file mode 100644 index 000000000000..062c1bfe8919 --- /dev/null +++ b/src/qt/platform/contactflow.cpp @@ -0,0 +1,291 @@ +// 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 + +using interfaces::Wallet; +using PlatformKeyType = interfaces::Wallet::PlatformKeyType; + +namespace { +//! DashPay contactRequest encryptedPublicKey is exactly IV(16) || AES-256-CBC +//! ciphertext of the 65-byte serialized contact xpub, fixed at 96 bytes by +//! the DashPay v1 schema. +constexpr size_t ENCRYPTED_XPUB_SIZE{96}; +} // namespace + +ContactFlow::ContactFlow(PlatformService& service, QObject* parent) : + QObject(parent), + m_service(service) +{ +} + +bool ContactFlow::encryptXpub(const platform::IdentityPublicKey& their_key, + const std::vector& our_xpub, std::vector& out) const +{ + Wallet& wallet{m_service.walletModel().wallet()}; + CPubKey counterparty{their_key.data.begin(), their_key.data.end()}; + if (!counterparty.IsValid()) return false; + + SecureVector secret; + // Our identity authentication key 0 (sender_key_index) performs the ECDH. + if (!wallet.platformECDHSecret(/*identity_index=*/0, /*key_index=*/0, counterparty, secret) || + secret.size() != AES256_KEYSIZE) { + return false; + } + + unsigned char iv[AES_BLOCKSIZE]; + GetStrongRandBytes(Span{iv}); + + // AES-256-CBC with PKCS7 padding over the serialized xpub. + AES256CBCEncrypt enc(secret.data(), iv, /*padIn=*/true); + std::vector cipher(our_xpub.size() + AES_BLOCKSIZE); + const int written = enc.Encrypt(our_xpub.data(), our_xpub.size(), cipher.data()); + if (written <= 0) return false; + cipher.resize(written); + + out.clear(); + out.insert(out.end(), iv, iv + AES_BLOCKSIZE); + out.insert(out.end(), cipher.begin(), cipher.end()); + return out.size() == ENCRYPTED_XPUB_SIZE; +} + +bool ContactFlow::decryptXpub(uint32_t our_key_id, const std::vector& their_pubkey, + const std::vector& encrypted, std::vector& out) const +{ + if (encrypted.size() != ENCRYPTED_XPUB_SIZE) return false; + Wallet& wallet{m_service.walletModel().wallet()}; + CPubKey counterparty{their_pubkey.begin(), their_pubkey.end()}; + if (!counterparty.IsValid()) return false; + + SecureVector secret; + if (!wallet.platformECDHSecret(/*identity_index=*/0, our_key_id, counterparty, secret) || + secret.size() != AES256_KEYSIZE) { + return false; + } + + unsigned char iv[AES_BLOCKSIZE]; + std::copy(encrypted.begin(), encrypted.begin() + AES_BLOCKSIZE, iv); + AES256CBCDecrypt dec(secret.data(), iv, /*padIn=*/true); + std::vector plain(encrypted.size()); + const int written = dec.Decrypt(encrypted.data() + AES_BLOCKSIZE, encrypted.size() - AES_BLOCKSIZE, plain.data()); + if (written <= 0) return false; + plain.resize(written); + out = std::move(plain); + return true; +} + +void ContactFlow::sendRequest(const platform::Identifier& to_identity, uint32_t recipient_key_id, + const platform::IdentityPublicKey& recipient_key) +{ + auto my_id{m_service.myIdentityId()}; + if (!my_id) { + Q_EMIT requestFailed(QString::fromStdString(HexStr(to_identity)), tr("register a username first")); + return; + } + + Wallet& wallet{m_service.walletModel().wallet()}; + + // Our DIP-15 receiving xpub for this friendship (userA = me, userB = them). + CPubKey xpubkey; + uint256 chaincode; + uint256 my_id_hash{uint256(std::vector(my_id->begin(), my_id->end()))}; + uint256 their_id_hash{uint256(std::vector(to_identity.begin(), to_identity.end()))}; + if (!wallet.getFriendshipXpub(/*account=*/0, my_id_hash, their_id_hash, xpubkey, chaincode)) { + Q_EMIT requestFailed(QString::fromStdString(HexStr(to_identity)), tr("unable to derive friendship key (unlock the wallet)")); + return; + } + + // Contact-format serialized xpub: 33-byte pubkey || 32-byte chain code + // (dashj serializeContactPub — no BIP32 metadata that would leak the path). + std::vector our_xpub; + our_xpub.insert(our_xpub.end(), xpubkey.begin(), xpubkey.end()); + our_xpub.insert(our_xpub.end(), chaincode.begin(), chaincode.end()); + + std::vector encrypted; + if (!encryptXpub(recipient_key, our_xpub, encrypted)) { + Q_EMIT requestFailed(QString::fromStdString(HexStr(to_identity)), tr("failed to encrypt the contact request")); + return; + } + + platform::ContactRequest doc; + doc.owner_id = *my_id; + doc.to_user_id = to_identity; + doc.encrypted_public_key = std::move(encrypted); + doc.sender_key_index = 0; + doc.recipient_key_index = recipient_key_id; + doc.account_reference = 0; // account 0 for this version + + const auto id_hex{QString::fromStdString(HexStr(to_identity))}; + QPointer self{this}; + m_service.client().getIdentityContractNonce(*my_id, platform::DASHPAY_CONTRACT_ID, + [self, doc, id_hex](platform::Result nonce_res) { + if (!self) return; + self->m_service.post([self, doc, id_hex, nonce_res = std::move(nonce_res)] { + if (!self) return; + if (!nonce_res.ok()) { + Q_EMIT self->requestFailed(id_hex, tr("could not fetch identity nonce")); + return; + } + Wallet& w{self->m_service.walletModel().wallet()}; + const auto signer = [&w](const uint256& digest, std::vector& sig) { + return w.signPlatformDigest(PlatformKeyType::IdentityAuth, 0, 1, digest, sig); + }; + auto built{platform::st::BuildContactRequest(doc.owner_id, *nonce_res.value + 1, doc, + /*signature_public_key_id=*/1, signer)}; + if (!built.ok()) { + Q_EMIT self->requestFailed(id_hex, QString::fromStdString(built.error)); + return; + } + QPointer inner{self}; + self->m_service.client().broadcastStateTransition(built.value->bytes, + [inner, id_hex](platform::Result res) { + if (!inner) return; + inner->m_service.post([inner, id_hex, res = std::move(res)] { + if (!inner) return; + if (res.ok() && (res.value->accepted || + res.value->error.find("already") != std::string::npos)) { + platform::Identifier to{}; + const auto bytes{ParseHex(id_hex.toStdString())}; + if (bytes.size() != to.size()) { + Q_EMIT inner->requestFailed(id_hex, tr("invalid contact identity")); + return; + } + std::copy(bytes.begin(), bytes.end(), to.begin()); + inner->confirmRequest(to, /*attempts_left=*/12); + } else { + Q_EMIT inner->requestFailed(id_hex, res.ok() ? QString::fromStdString(res.value->error) + : tr("broadcast failed")); + } + }); + }); + }); + }); +} + +void ContactFlow::accept(const platform::ContactRequest& incoming) +{ + const QString id_hex{QString::fromStdString(HexStr(incoming.owner_id))}; + QPointer self{this}; + m_service.client().getIdentity(incoming.owner_id, + [self, incoming, id_hex](platform::Result> result) { + if (!self) return; + self->m_service.post([self, incoming, id_hex, result = std::move(result)] { + if (!self) return; + if (!result.ok() || !result.value->has_value()) { + Q_EMIT self->requestFailed(id_hex, tr("could not verify the sender identity")); + return; + } + const auto& keys{(**result.value).public_keys}; + const auto it{std::find_if(keys.begin(), keys.end(), [&incoming](const auto& key) { + return key.id == incoming.sender_key_index && !key.disabled_at && + key.type == platform::IdentityPublicKey::Type::ECDSA_SECP256K1; + })}; + if (it == keys.end()) { + Q_EMIT self->requestFailed(id_hex, tr("the sender encryption key is unavailable")); + return; + } + std::vector their_xpub; + if (!self->decryptXpub(incoming.recipient_key_index, it->data, + incoming.encrypted_public_key, their_xpub)) { + Q_EMIT self->requestFailed(id_hex, tr("could not decrypt the contact request")); + return; + } + QString import_error; + if (!self->importKeychains(incoming.owner_id, their_xpub, import_error)) { + Q_EMIT self->requestFailed(id_hex, import_error); + return; + } + self->m_service.writeRecord("contact/key/" + id_hex.toStdString(), their_xpub); + self->m_service.writeRecord("contact/in/" + id_hex.toStdString(), + std::vector(incoming.document_id.begin(), incoming.document_id.end())); + self->sendRequest(incoming.owner_id, it->id, *it); + }); + }); +} + +bool ContactFlow::importKeychains(const platform::Identifier& their_identity, + const std::vector& their_xpub_serialized, QString& error) +{ + if (their_xpub_serialized.size() != 65) { + error = tr("invalid friendship public key in contact request"); + return false; + } + const auto my_id{m_service.myIdentityId()}; + if (!my_id) { error = tr("register a username first"); return false; } + // Validate the decrypted material before it is persisted; the contact's + // chain itself never enters the wallet (see importFriendshipKeychains). + const CPubKey pubkey{their_xpub_serialized.begin(), their_xpub_serialized.begin() + 33}; + if (!pubkey.IsFullyValid()) { + error = tr("invalid friendship public key in contact request"); + return false; + } + const uint256 my_hash{std::vector(my_id->begin(), my_id->end())}; + const uint256 their_hash{std::vector(their_identity.begin(), their_identity.end())}; + const std::string label{ + m_service.contactAddressLabel(QString::fromStdString(HexStr(their_identity))).toStdString()}; + Wallet& wallet{m_service.walletModel().wallet()}; + std::string wallet_error; + if (!wallet.importFriendshipKeychains(/*account=*/0, my_hash, their_hash, label, wallet_error)) { + error = QString::fromStdString(wallet_error); + return false; + } + // Ranged descriptors cannot carry an address-book label, so label the + // receiving chain explicitly: incoming payments from this contact then + // show up in transaction history under their username. + CPubKey our_pubkey; + uint256 our_chaincode; + if (wallet.getFriendshipXpub(/*account=*/0, my_hash, their_hash, our_pubkey, our_chaincode)) { + for (uint32_t i = 0; i < 20; ++i) { + CTxDestination destination; + if (!wallet.getFriendshipPaymentDestination(our_pubkey, our_chaincode, i, destination)) break; + wallet.setAddressBook(destination, label, "receive"); + } + } + return true; +} + +void ContactFlow::confirmRequest(const platform::Identifier& to_identity, int attempts_left) +{ + const auto my_id{m_service.myIdentityId()}; + if (!my_id) return; + const QString id_hex{QString::fromStdString(HexStr(to_identity))}; + QPointer self{this}; + m_service.client().getContactRequests(*my_id, /*to_me=*/false, 0, + [self, to_identity, id_hex, attempts_left](platform::Result> result) { + if (!self) return; + self->m_service.post([self, to_identity, id_hex, attempts_left, result = std::move(result)] { + if (!self) return; + if (result.ok() && std::any_of(result.value->begin(), result.value->end(), + [&to_identity](const auto& request) { return request.to_user_id == to_identity; })) { + self->m_service.writeRecord("contact/out/" + id_hex.toStdString(), {1}); + Q_EMIT self->requestSent(id_hex); + Q_EMIT self->contactAdded(id_hex); + return; + } + if (attempts_left <= 1) { + Q_EMIT self->requestFailed(id_hex, result.ok() ? tr("contact request was not confirmed") + : QString::fromStdString(result.error)); + return; + } + QTimer::singleShot(2500, self, [self, to_identity, attempts_left] { + if (self) self->confirmRequest(to_identity, attempts_left - 1); + }); + }); + }); +} diff --git a/src/qt/platform/contactflow.h b/src/qt/platform/contactflow.h new file mode 100644 index 000000000000..33704edde69e --- /dev/null +++ b/src/qt/platform/contactflow.h @@ -0,0 +1,71 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_CONTACTFLOW_H +#define BITCOIN_QT_PLATFORM_CONTACTFLOW_H + +#include +#include + +#include +#include + +#include +#include +#include + +class PlatformService; + +/** + * Sends a DashPay contact request and imports the resulting friendship + * keychains so payments to/from the contact are tracked by the wallet. + * + * A contact request carries our DIP-15 receiving xpub, ECDH-encrypted + * (AES-256-CBC) to the recipient's identity key so only they can read it. + * Accepting an incoming request is symmetric: we decrypt their xpub, import + * it as a watch-only sending chain, and send our own request back. + * + * Per-contact state is persisted under "contact/out/" and + * "contact/in/" platform data records so requests survive restarts and + * are re-derivable (the request document itself is on Platform; the local + * record is a cursor). + */ +class ContactFlow : public QObject +{ + Q_OBJECT + +public: + explicit ContactFlow(PlatformService& service, QObject* parent = nullptr); + + //! Send a contact request to the given identity. Requires an unlocked + //! wallet (ECDH + xpub derivation). Emits requestSent/requestFailed. + void sendRequest(const platform::Identifier& to_identity, uint32_t recipient_key_id, + const platform::IdentityPublicKey& recipient_key); + + //! Accept an incoming request: decrypt the sender's xpub, import the + //! sending keychain, and send a request back. + void accept(const platform::ContactRequest& incoming); + + //! Import our receiving keychain and (for accept) their sending keychain + //! into the wallet so addresses are watched. + bool importKeychains(const platform::Identifier& their_identity, + const std::vector& their_xpub_serialized, QString& error); + +Q_SIGNALS: + void requestSent(const QString& to_identity_hex); + void requestFailed(const QString& to_identity_hex, const QString& error); + void contactAdded(const QString& identity_hex); + +private: + void confirmRequest(const platform::Identifier& to_identity, int attempts_left); + //! ECDH+AES helpers (encrypt our xpub for them / decrypt theirs for us). + bool encryptXpub(const platform::IdentityPublicKey& their_key, const std::vector& our_xpub, + std::vector& out) const; + bool decryptXpub(uint32_t our_key_id, const std::vector& their_pubkey, + const std::vector& encrypted, std::vector& out) const; + + PlatformService& m_service; +}; + +#endif // BITCOIN_QT_PLATFORM_CONTACTFLOW_H diff --git a/src/qt/platform/contactpickerdialog.cpp b/src/qt/platform/contactpickerdialog.cpp new file mode 100644 index 000000000000..313be199453f --- /dev/null +++ b/src/qt/platform/contactpickerdialog.cpp @@ -0,0 +1,92 @@ +// 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 + +ContactPickerDialog::ContactPickerDialog(PlatformService& service, QWidget* parent) : + QDialog(parent), + m_service(service) +{ + setWindowTitle(tr("Pay a DashPay contact")); + resize(440, 360); + + auto* layout = new QVBoxLayout(this); + auto* hint = new QLabel(tr("Choose who to pay. The payment goes to a fresh address that " + "only you and this contact can link to each other."), this); + hint->setWordWrap(true); + layout->addWidget(hint); + + m_model = new ContactsModel(m_service, this); + m_proxy = new QSortFilterProxyModel(this); + m_proxy->setSourceModel(m_model); + m_proxy->setFilterRole(ContactsModel::KindRole); + m_proxy->setFilterFixedString(QString::number(static_cast(ContactsModel::Kind::Established))); + m_proxy->setFilterKeyColumn(0); + + m_view = new QTableView(this); + m_view->setModel(m_proxy); + m_view->setSelectionBehavior(QAbstractItemView::SelectRows); + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->horizontalHeader()->setStretchLastSection(true); + m_view->verticalHeader()->hide(); + m_view->hideColumn(ContactsModel::Direction); + layout->addWidget(m_view); + + m_empty = new QLabel(tr("You do not have any payable contacts yet.\n" + "Open the DashPay tab and use \"Find people\" to send a contact " + "request. Once it is accepted you can pay them by username."), + this); + m_empty->setWordWrap(true); + m_empty->setAlignment(Qt::AlignCenter); + layout->addWidget(m_empty); + + auto* buttons = new QDialogButtonBox(this); + m_choose_button = buttons->addButton(tr("Pay this contact"), QDialogButtonBox::AcceptRole); + buttons->addButton(QDialogButtonBox::Cancel); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, &ContactPickerDialog::chooseCurrent); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(m_view, &QTableView::doubleClicked, this, &ContactPickerDialog::chooseCurrent); + connect(m_view->selectionModel(), &QItemSelectionModel::currentChanged, + this, &ContactPickerDialog::updateState); + connect(m_proxy, &QAbstractItemModel::modelReset, this, &ContactPickerDialog::updateState); + connect(m_proxy, &QAbstractItemModel::rowsInserted, this, &ContactPickerDialog::updateState); + connect(m_proxy, &QAbstractItemModel::rowsRemoved, this, &ContactPickerDialog::updateState); + + updateState(); + m_model->refresh(); +} + +void ContactPickerDialog::updateState() +{ + const bool have_contacts{m_proxy->rowCount() > 0}; + m_view->setVisible(have_contacts); + m_empty->setVisible(!have_contacts); + const QModelIndex current{m_view->currentIndex()}; + m_choose_button->setEnabled(current.isValid() && + !m_proxy->data(current, ContactsModel::UsernameRole).toString().isEmpty()); +} + +void ContactPickerDialog::chooseCurrent() +{ + const QModelIndex current{m_view->currentIndex()}; + if (!current.isValid()) return; + const QString username{m_proxy->data(current, ContactsModel::UsernameRole).toString()}; + if (username.isEmpty()) return; + m_selected_username = username; + accept(); +} diff --git a/src/qt/platform/contactpickerdialog.h b/src/qt/platform/contactpickerdialog.h new file mode 100644 index 000000000000..e9bdeef759aa --- /dev/null +++ b/src/qt/platform/contactpickerdialog.h @@ -0,0 +1,46 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H +#define BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H + +#include +#include + +class ContactsModel; +class PlatformService; + +QT_BEGIN_NAMESPACE +class QLabel; +class QPushButton; +class QSortFilterProxyModel; +class QTableView; +QT_END_NAMESPACE + +/** Modal picker over the established (payable) contacts. On accept, + * selectedUsername() holds the chosen contact's DashPay username. */ +class ContactPickerDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ContactPickerDialog(PlatformService& service, QWidget* parent = nullptr); + + QString selectedUsername() const { return m_selected_username; } + +private Q_SLOTS: + void updateState(); + void chooseCurrent(); + +private: + PlatformService& m_service; + ContactsModel* m_model{nullptr}; + QSortFilterProxyModel* m_proxy{nullptr}; + QTableView* m_view{nullptr}; + QLabel* m_empty{nullptr}; + QPushButton* m_choose_button{nullptr}; + QString m_selected_username; +}; + +#endif // BITCOIN_QT_PLATFORM_CONTACTPICKERDIALOG_H diff --git a/src/qt/platform/contactsmodel.cpp b/src/qt/platform/contactsmodel.cpp new file mode 100644 index 000000000000..74ee3e80bc2a --- /dev/null +++ b/src/qt/platform/contactsmodel.cpp @@ -0,0 +1,145 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include + +#include + +ContactsModel::ContactsModel(PlatformService& service, QObject* parent) : + QAbstractTableModel(parent), + m_service(service) +{ + connect(&m_service, &PlatformService::contactsUpdated, this, + [this](const QVector>& incoming, + const QVector>& outgoing) { + m_incoming = incoming; + m_outgoing = outgoing; + rebuild(); + }); +} + +int ContactsModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_rows.size(); +} + +int ContactsModel::columnCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : COLUMN_COUNT; +} + +QVariant ContactsModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_rows.size()) return {}; + const Row& r = m_rows[index.row()]; + switch (role) { + case UsernameRole: return r.username; + case DisplayNameRole: return r.display_name; + case IdentityRole: return r.identity_hex; + case KindRole: return static_cast(r.kind); + case Qt::ToolTipRole: + switch (r.kind) { + case Kind::Established: + return tr("You are connected. You can send Dash directly to this contact's username."); + case Kind::Incoming: + return tr("This person wants to add you as a contact. Accept the request to be able to " + "pay each other by username."); + case Kind::Outgoing: + return tr("Waiting for this person to accept your contact request."); + } + return {}; + case Qt::ForegroundRole: + if (index.column() == Direction) { + switch (r.kind) { + case Kind::Established: return QColor{GUIUtil::getThemedQColor(GUIUtil::ThemedColor::GREEN)}; + case Kind::Incoming: return QColor{GUIUtil::getThemedQColor(GUIUtil::ThemedColor::ORANGE)}; + case Kind::Outgoing: return {}; + } + } + return {}; + case Qt::DisplayRole: + switch (index.column()) { + case Username: return r.username.isEmpty() ? r.identity_hex.left(16) + "…" : r.username; + case DisplayName: return r.display_name; + case Direction: + switch (r.kind) { + case Kind::Established: return tr("Contact"); + case Kind::Incoming: return tr("Wants to connect with you"); + case Kind::Outgoing: return tr("Invitation sent — waiting"); + } + return {}; + } + return {}; + } + return {}; +} + +QVariant ContactsModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) return {}; + switch (section) { + case Username: return tr("Username"); + case DisplayName: return tr("Display name"); + case Direction: return tr("Status"); + } + return {}; +} + +void ContactsModel::refresh() +{ + m_service.refreshContacts(); +} + +void ContactsModel::onIncoming(const QVector>& contacts) { m_incoming = contacts; rebuild(); } +void ContactsModel::onOutgoing(const QVector>& contacts) { m_outgoing = contacts; rebuild(); } + +void ContactsModel::rebuild() +{ + beginResetModel(); + m_rows.clear(); + // Requests appearing both incoming and outgoing are established contacts. + for (const auto& in : m_incoming) { + bool mutual = false; + for (const auto& out : m_outgoing) { + if (out.first == in.first) { mutual = true; break; } + } + // Mutual documents alone do not establish a payable contact. The + // incoming request must also have been decrypted and its 65-byte + // friendship xpub imported into this wallet. + const bool have_friendship_key{ + m_service.readRecord("contact/key/" + in.first.toStdString()).size() == 65}; + m_rows.append({in.first, in.second, + m_service.contactMetadata(in.first, "display-name"), + mutual && have_friendship_key ? Kind::Established : Kind::Incoming}); + } + for (const auto& out : m_outgoing) { + bool seen = false; + for (const auto& r : m_rows) { + if (r.identity_hex == out.first) { seen = true; break; } + } + if (!seen) m_rows.append({out.first, out.second, + m_service.contactMetadata(out.first, "display-name"), + Kind::Outgoing}); + } + // Requests that need the user's attention first, then contacts, then + // outgoing requests that are merely waiting on the other side. + const auto priority = [](Kind kind) { + switch (kind) { + case Kind::Incoming: return 0; + case Kind::Established: return 1; + case Kind::Outgoing: return 2; + } + return 3; + }; + std::stable_sort(m_rows.begin(), m_rows.end(), [&priority](const Row& a, const Row& b) { + if (priority(a.kind) != priority(b.kind)) return priority(a.kind) < priority(b.kind); + return a.username.localeAwareCompare(b.username) < 0; + }); + endResetModel(); +} diff --git a/src/qt/platform/contactsmodel.h b/src/qt/platform/contactsmodel.h new file mode 100644 index 000000000000..d563ff032f42 --- /dev/null +++ b/src/qt/platform/contactsmodel.h @@ -0,0 +1,63 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_CONTACTSMODEL_H +#define BITCOIN_QT_PLATFORM_CONTACTSMODEL_H + +#include +#include +#include + +class PlatformService; + +/** Table model backing the contacts list: established contacts plus incoming + * and outgoing contact requests, refreshed from the DashPay contactRequest + * documents via the PlatformService. */ +class ContactsModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Column { Username = 0, DisplayName, Direction, COLUMN_COUNT }; + enum class Kind { Established, Incoming, Outgoing }; + enum Roles { + UsernameRole = Qt::UserRole + 1, //!< QString: DPNS username (may be empty) + DisplayNameRole, //!< QString: profile display name + IdentityRole, //!< QString: identity id, hex + KindRole, //!< int: static_cast(Kind) + }; + + struct Row { + QString identity_hex; + QString username; + QString display_name; + Kind kind{Kind::Established}; + }; + + explicit ContactsModel(PlatformService& service, QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + const Row* rowAt(int r) const { return (r >= 0 && r < m_rows.size()) ? &m_rows[r] : nullptr; } + +public Q_SLOTS: + void refresh(); + +private Q_SLOTS: + void onIncoming(const QVector>& contacts); + void onOutgoing(const QVector>& contacts); + +private: + void rebuild(); + + PlatformService& m_service; + QVector m_rows; + QVector> m_incoming; // (identity hex, username) + QVector> m_outgoing; +}; + +#endif // BITCOIN_QT_PLATFORM_CONTACTSMODEL_H diff --git a/src/qt/platform/contactspage.cpp b/src/qt/platform/contactspage.cpp new file mode 100644 index 000000000000..a61f64b6cc37 --- /dev/null +++ b/src/qt/platform/contactspage.cpp @@ -0,0 +1,159 @@ +// 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 + +ContactsPage::ContactsPage(PlatformService& service, QWidget* parent) : + QWidget(parent), + m_service(service) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + auto* heading = new QLabel(tr("Contacts"), this); + GUIUtil::setFont({heading}, GUIUtil::FontWeight::Bold, 16); + layout->addWidget(heading); + + auto* buttons = new QHBoxLayout(); + m_send_button = new QPushButton(tr("Send Dash"), this); + m_send_button->setToolTip(tr("Pay the selected contact by username")); + m_accept_button = new QPushButton(tr("Accept request"), this); + m_accept_button->setToolTip(tr("Connect with this person so you can pay each other by username")); + auto* refresh = new QPushButton(tr("Refresh"), this); + buttons->addWidget(m_send_button); + buttons->addWidget(m_accept_button); + buttons->addStretch(); + buttons->addWidget(refresh); + layout->addLayout(buttons); + + m_model = new ContactsModel(m_service, this); + m_view = new QTableView(this); + m_view->setModel(m_model); + m_view->setSelectionBehavior(QAbstractItemView::SelectRows); + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + m_view->horizontalHeader()->setStretchLastSection(true); + m_view->verticalHeader()->hide(); + layout->addWidget(m_view); + + m_empty = new QLabel(tr("No contacts yet.\nUse \"Find people\" above to look someone up by " + "username and send them a contact request. Once they accept, you can " + "pay each other by name."), + this); + m_empty->setWordWrap(true); + m_empty->setAlignment(Qt::AlignCenter); + layout->addWidget(m_empty); + + m_status = new QLabel(this); + m_status->setWordWrap(true); + layout->addWidget(m_status); + + connect(refresh, &QPushButton::clicked, this, [this] { m_model->refresh(); }); + connect(m_send_button, &QPushButton::clicked, this, &ContactsPage::sendToSelected); + connect(m_accept_button, &QPushButton::clicked, this, &ContactsPage::acceptSelected); + connect(m_view->selectionModel(), &QItemSelectionModel::currentChanged, + this, &ContactsPage::updateActions); + connect(m_view, &QTableView::doubleClicked, this, &ContactsPage::sendToSelected); + connect(m_view, &QTableView::customContextMenuRequested, this, &ContactsPage::showContextMenu); + connect(m_model, &QAbstractItemModel::modelReset, this, &ContactsPage::updateActions); + connect(&m_service, &PlatformService::contactRequestFinished, this, + [this](const QString& id, bool ok, const QString& error) { + m_unlock.reset(); + if (ok) { + m_status->setStyleSheet(QString{"QLabel { color: %1; }"} + .arg(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::GREEN).name())); + m_status->setText(tr("You and %1 are now contacts.") + .arg(m_service.contactDisplayString(id))); + } else { + m_status->setStyleSheet(QString{"QLabel { color: %1; }"} + .arg(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::RED).name())); + m_status->setText(tr("Could not accept the request from %1: %2") + .arg(m_service.contactDisplayString(id), error)); + } + updateActions(); + m_model->refresh(); + }); + + updateActions(); + m_model->refresh(); +} + +void ContactsPage::updateActions() +{ + const auto* row{m_model->rowAt(m_view->currentIndex().row())}; + m_accept_button->setEnabled(row && row->kind == ContactsModel::Kind::Incoming && !m_unlock); + m_send_button->setEnabled(row && row->kind == ContactsModel::Kind::Established && + !row->username.isEmpty()); + const bool have_rows{m_model->rowCount() > 0}; + m_view->setVisible(have_rows); + m_empty->setVisible(!have_rows); +} + +void ContactsPage::acceptSelected() +{ + const auto* row{m_model->rowAt(m_view->currentIndex().row())}; + if (!row || row->kind != ContactsModel::Kind::Incoming) return; + auto unlock{m_service.walletModel().requestUnlock()}; + if (!unlock.isValid()) return; + m_unlock = std::make_unique(std::move(unlock)); + m_status->setStyleSheet({}); + m_status->setText(tr("Accepting the request from %1…").arg(m_service.contactDisplayString(row->identity_hex))); + updateActions(); + m_service.acceptContact(row->identity_hex); +} + +void ContactsPage::sendToSelected() +{ + const auto* row{m_model->rowAt(m_view->currentIndex().row())}; + if (!row || row->kind != ContactsModel::Kind::Established || row->username.isEmpty()) return; + Q_EMIT sendToContact(row->username); +} + +void ContactsPage::showContextMenu(const QPoint& pos) +{ + const QModelIndex index{m_view->indexAt(pos)}; + if (!index.isValid()) return; + m_view->setCurrentIndex(index); + const auto* row{m_model->rowAt(index.row())}; + if (!row) return; + + QMenu menu(this); + switch (row->kind) { + case ContactsModel::Kind::Established: { + auto* send{menu.addAction(tr("Send Dash to %1").arg(row->username))}; + send->setEnabled(!row->username.isEmpty()); + connect(send, &QAction::triggered, this, &ContactsPage::sendToSelected); + break; + } + case ContactsModel::Kind::Incoming: { + auto* accept{menu.addAction(tr("Accept contact request"))}; + accept->setEnabled(!m_unlock); + connect(accept, &QAction::triggered, this, &ContactsPage::acceptSelected); + break; + } + case ContactsModel::Kind::Outgoing: { + menu.addAction(tr("Waiting for them to accept your request"))->setEnabled(false); + break; + } + } + auto* copy{menu.addAction(tr("Copy identity ID"))}; + connect(copy, &QAction::triggered, this, [row_id = row->identity_hex] { + GUIUtil::setClipboard(row_id); + }); + menu.exec(m_view->viewport()->mapToGlobal(pos)); +} diff --git a/src/qt/platform/contactspage.h b/src/qt/platform/contactspage.h new file mode 100644 index 000000000000..6ebd112c6307 --- /dev/null +++ b/src/qt/platform/contactspage.h @@ -0,0 +1,54 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_CONTACTSPAGE_H +#define BITCOIN_QT_PLATFORM_CONTACTSPAGE_H + +#include +#include + +#include + +class ContactsModel; +class PlatformService; + +QT_BEGIN_NAMESPACE +class QLabel; +class QPoint; +class QTableView; +class QPushButton; +QT_END_NAMESPACE + +/** Contacts list: established contacts and pending requests, with accept / + * pay actions. Finding new contacts and editing the profile live on the + * surrounding dashboard (PlatformPage). */ +class ContactsPage : public QWidget +{ + Q_OBJECT + +public: + explicit ContactsPage(PlatformService& service, QWidget* parent = nullptr); + +Q_SIGNALS: + //! Ask the wallet view to open the Send tab with this username prefilled. + void sendToContact(const QString& username); + +private Q_SLOTS: + void acceptSelected(); + void sendToSelected(); + void updateActions(); + void showContextMenu(const QPoint& pos); + +private: + PlatformService& m_service; + ContactsModel* m_model{nullptr}; + QTableView* m_view{nullptr}; + QLabel* m_empty{nullptr}; + QLabel* m_status{nullptr}; + QPushButton* m_accept_button{nullptr}; + QPushButton* m_send_button{nullptr}; + std::unique_ptr m_unlock; +}; + +#endif // BITCOIN_QT_PLATFORM_CONTACTSPAGE_H diff --git a/src/qt/platform/createusernamewizard.cpp b/src/qt/platform/createusernamewizard.cpp new file mode 100644 index 000000000000..537e77dbd7be --- /dev/null +++ b/src/qt/platform/createusernamewizard.cpp @@ -0,0 +1,273 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int PAGE_ENTRY{0}; +constexpr int PAGE_COST{1}; +constexpr int PAGE_PROGRESS{2}; +constexpr int DEBOUNCE_MS{400}; + +void ReserveWrappedLabelHeight(QLabel* label, int lines = 1) +{ + label->setWordWrap(true); + label->setMinimumHeight(label->fontMetrics().lineSpacing() * lines); + label->setAlignment(Qt::AlignLeft | Qt::AlignTop); +} + +void AddPageHeader(QVBoxLayout* layout, QWidget* parent, const QString& title, const QString& subtitle = {}) +{ + // QWizard's native ModernStyle header is repainted incorrectly by the + // macOS dark theme when a page emits completeChanged(). Keep the header + // in the page layout so its geometry and palette remain stable. + auto* heading = new QLabel(title, parent); + QFont heading_font{heading->font()}; + heading_font.setBold(true); + heading->setFont(heading_font); + ReserveWrappedLabelHeight(heading); + layout->addWidget(heading); + + if (!subtitle.isEmpty()) { + auto* description = new QLabel(subtitle, parent); + ReserveWrappedLabelHeight(description, 2); + layout->addWidget(description); + } + layout->addSpacing(16); +} +} // namespace + +// ---- Wizard ----------------------------------------------------------------- + +CreateUsernameWizard::CreateUsernameWizard(PlatformService& service, WalletModel& wallet_model, QWidget* parent) : + QWizard(parent), + m_service(service), + m_wallet_model(wallet_model) +{ + setWindowTitle(tr("Register a DashPay username")); + setWizardStyle(QWizard::ModernStyle); + setMinimumSize(520, 400); + setOption(QWizard::NoBackButtonOnStartPage, true); + setOption(QWizard::DisabledBackButtonOnLastPage, true); + + setPage(PAGE_ENTRY, new UsernameEntryPage(service, this)); + setPage(PAGE_COST, new UsernameCostPage(service, wallet_model, this)); + setPage(PAGE_PROGRESS, new UsernameProgressPage(service, this)); +} + +void CreateUsernameWizard::startAtProgress() +{ + setStartId(PAGE_PROGRESS); +} + +// ---- Entry page ------------------------------------------------------------- + +UsernameEntryPage::UsernameEntryPage(PlatformService& service, QWidget* parent) : + QWizardPage(parent), + m_service(service) +{ + auto* layout = new QVBoxLayout(this); + AddPageHeader(layout, this, tr("Choose your username"), + tr("Your username is registered on Dash Platform and is unique across the network.")); + m_input = new QLineEdit(this); + m_input->setPlaceholderText(tr("username")); + // DPNS labels: 3-63 chars, letters/digits/hyphens, no leading/trailing hyphen. + m_input->setValidator(new QRegularExpressionValidator( + QRegularExpression("^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]$"), this)); + layout->addWidget(m_input); + + m_status = new QLabel(this); + ReserveWrappedLabelHeight(m_status, 2); + layout->addWidget(m_status); + layout->addStretch(); + + m_debounce = new QTimer(this); + m_debounce->setSingleShot(true); + m_debounce->setInterval(DEBOUNCE_MS); + + connect(m_input, &QLineEdit::textChanged, this, &UsernameEntryPage::onTextChanged); + connect(m_debounce, &QTimer::timeout, this, [this] { + const QString name{m_input->text().trimmed()}; + if (!name.isEmpty()) m_service.checkNameAvailability(name); + }); + connect(&m_service, &PlatformService::nameAvailability, this, &UsernameEntryPage::onAvailability); + connect(&m_service, &PlatformService::nameAvailabilityFailed, this, &UsernameEntryPage::onAvailabilityFailed); +} + +void UsernameEntryPage::onAvailabilityFailed(const QString& normalized_label, const QString& error) +{ + const std::string typed_norm{platform::st::NormalizeLabel(m_input->text().trimmed().toStdString())}; + if (QString::fromStdString(typed_norm) != normalized_label) return; + m_available = false; + m_status->setText(tr("Could not verify availability: %1").arg(error)); + Q_EMIT completeChanged(); +} + +void UsernameEntryPage::onTextChanged() +{ + m_available = false; + m_checked_normalized.clear(); + m_status->setText(tr("Checking availability…")); + Q_EMIT completeChanged(); + m_debounce->start(); +} + +void UsernameEntryPage::onAvailability(const QString& normalized_label, bool available, bool contested) +{ + const std::string typed_norm{platform::st::NormalizeLabel(m_input->text().trimmed().toStdString())}; + if (QString::fromStdString(typed_norm) != normalized_label) return; // stale + + m_available = available; + m_contested = contested; + m_checked_normalized = normalized_label; + if (!available) { + m_status->setText(tr("\"%1\" is already taken.").arg(normalized_label)); + } else if (contested) { + m_status->setText(tr("\"%1\" is a premium name. Registration triggers a masternode vote " + "that can take weeks and may be awarded to someone else.").arg(normalized_label)); + } else { + m_status->setText(tr("\"%1\" is available.").arg(normalized_label)); + } + Q_EMIT completeChanged(); +} + +bool UsernameEntryPage::isComplete() const +{ + return m_available && !m_checked_normalized.isEmpty(); +} + +int UsernameEntryPage::nextId() const { return PAGE_COST; } + +QString UsernameEntryPage::username() const { return m_input->text().trimmed(); } + +// ---- Cost page -------------------------------------------------------------- + +UsernameCostPage::UsernameCostPage(PlatformService& service, WalletModel& wallet_model, QWidget* parent) : + QWizardPage(parent), + m_service(service), + m_wallet_model(wallet_model) +{ + auto* layout = new QVBoxLayout(this); + AddPageHeader(layout, this, tr("Confirm registration")); + m_summary = new QLabel(this); + ReserveWrappedLabelHeight(m_summary, 3); + layout->addWidget(m_summary); + m_warning = new QLabel(this); + ReserveWrappedLabelHeight(m_warning, 3); + m_warning->setStyleSheet("color:#c0392b;"); + layout->addWidget(m_warning); + layout->addStretch(); +} + +void UsernameCostPage::initializePage() +{ + auto* entry = qobject_cast(wizard()->page(PAGE_ENTRY)); + m_funding_amount = entry && entry->contested() + ? m_service.params().contested_identity_funding_amount + : m_service.params().default_identity_funding_amount; + const auto unit{m_wallet_model.getOptionsModel()->getDisplayUnit()}; + m_summary->setText(tr("Registering your username funds a Dash Platform identity with %1. " + "This creates an on-chain asset lock transaction and several Platform " + "state transitions.") + .arg(BitcoinUnits::formatWithUnit(unit, m_funding_amount))); + + m_warning->setVisible(entry && entry->contested()); + if (entry && entry->contested()) { + m_warning->setText(tr("This is a premium (contested) name. The funding includes the 0.2 DASH " + "vote reserve. Masternodes will vote on who receives it; the process " + "can take weeks and you may not win it.")); + } +} + +bool UsernameCostPage::validatePage() +{ + auto* entry = qobject_cast(wizard()->page(PAGE_ENTRY)); + if (!entry) return false; + + WalletModel::UnlockContext ctx{m_wallet_model.requestUnlock()}; + if (!ctx.isValid()) return false; + + QString error; + if (!m_service.identityFlow().start(entry->username(), m_funding_amount, error)) { + m_warning->setVisible(true); + m_warning->setText(error); + return false; + } + return true; +} + +// ---- Progress page ---------------------------------------------------------- + +UsernameProgressPage::UsernameProgressPage(PlatformService& service, QWidget* parent) : + QWizardPage(parent), + m_service(service) +{ + auto* layout = new QVBoxLayout(this); + AddPageHeader(layout, this, tr("Registering…"), + tr("You can close this window; registration continues in the background.")); + m_headline = new QLabel(this); + ReserveWrappedLabelHeight(m_headline, 2); + layout->addWidget(m_headline); + m_log = new QPlainTextEdit(this); + m_log->setReadOnly(true); + layout->addWidget(m_log); + + connect(&m_service, &PlatformService::identityStateChanged, this, &UsernameProgressPage::refresh); + connect(&m_service, &PlatformService::flowFailed, this, [this](const QString& step, const QString& err) { + m_log->appendPlainText(tr("[%1] error: %2").arg(step, err)); + }); +} + +void UsernameProgressPage::initializePage() { refresh(); } + +void UsernameProgressPage::refresh() +{ + using State = IdentityFlow::State; + const auto& rec{m_service.identityFlow().record()}; + QString label; + switch (rec.state) { + case State::FUNDING_SENT: label = tr("Waiting for the funding transaction to lock…"); break; + case State::FUNDING_LOCKED: label = tr("Funding locked. Creating your identity…"); break; + case State::IDENTITY_BROADCAST: label = tr("Identity broadcast. Waiting for confirmation…"); break; + case State::IDENTITY_CONFIRMED: label = tr("Identity created. Reserving your name…"); break; + case State::PREORDER_BROADCAST: + case State::PREORDER_WAIT: label = tr("Name reserved. Registering the domain…"); break; + case State::DOMAIN_BROADCAST: label = tr("Domain broadcast. Confirming…"); break; + case State::CONTESTED_PENDING: label = tr("Your premium name is now subject to a masternode vote."); break; + case State::REGISTERED: label = tr("Done! Your username \"%1\" is registered.").arg(QString::fromStdString(rec.label)); break; + case State::FAILED: label = tr("Registration failed: %1").arg(QString::fromStdString(rec.last_error)); break; + case State::NONE: label = tr("Starting…"); break; + } + // Keep the current state prominent without repeating it immediately in + // the history. When the state advances, archive the previous headline. + if (!m_last_headline.isEmpty() && m_last_headline != label) { + m_log->appendPlainText(m_last_headline); + } + m_last_headline = label; + m_headline->setText(label); + + const bool done{rec.state == State::REGISTERED || rec.state == State::FAILED || + rec.state == State::CONTESTED_PENDING}; + if (done != m_done) { + m_done = done; + Q_EMIT completeChanged(); + } +} + +bool UsernameProgressPage::isComplete() const { return m_done; } diff --git a/src/qt/platform/createusernamewizard.h b/src/qt/platform/createusernamewizard.h new file mode 100644 index 000000000000..ec804a19e26b --- /dev/null +++ b/src/qt/platform/createusernamewizard.h @@ -0,0 +1,114 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_PLATFORM_CREATEUSERNAMEWIZARD_H +#define BITCOIN_QT_PLATFORM_CREATEUSERNAMEWIZARD_H + +#include + +#include + +class PlatformService; +class WalletModel; + +QT_BEGIN_NAMESPACE +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QTimer; +QT_END_NAMESPACE + +/** + * Guided flow for registering a username: name entry with live + * availability + contested-name warnings, a cost confirmation, wallet + * unlock, and a live progress view bound to the IdentityFlow state machine. + * The wizard can be closed at any point after the funding step — the flow + * continues headless in PlatformService and the dashboard shows progress. + */ +class CreateUsernameWizard : public QWizard +{ + Q_OBJECT + +public: + CreateUsernameWizard(PlatformService& service, WalletModel& wallet_model, QWidget* parent = nullptr); + + //! Open directly on the live progress page (used when a registration is + //! already underway and the user asks to see how it is going). + void startAtProgress(); + +private: + PlatformService& m_service; + WalletModel& m_wallet_model; +}; + +//! Page 1: username entry + debounced availability check. +class UsernameEntryPage : public QWizardPage +{ + Q_OBJECT + +public: + UsernameEntryPage(PlatformService& service, QWidget* parent = nullptr); + bool isComplete() const override; + int nextId() const override; + + QString username() const; + bool contested() const { return m_contested; } + +private Q_SLOTS: + void onTextChanged(); + void onAvailability(const QString& normalized_label, bool available, bool contested); + void onAvailabilityFailed(const QString& normalized_label, const QString& error); + +private: + PlatformService& m_service; + QLineEdit* m_input{nullptr}; + QLabel* m_status{nullptr}; + QTimer* m_debounce{nullptr}; + bool m_available{false}; + bool m_contested{false}; + QString m_checked_normalized; +}; + +//! Page 2: cost confirmation + wallet unlock trigger. +class UsernameCostPage : public QWizardPage +{ + Q_OBJECT + +public: + UsernameCostPage(PlatformService& service, WalletModel& wallet_model, QWidget* parent = nullptr); + void initializePage() override; + bool validatePage() override; + + CAmount fundingAmount() const { return m_funding_amount; } + +private: + PlatformService& m_service; + WalletModel& m_wallet_model; + QLabel* m_summary{nullptr}; + QLabel* m_warning{nullptr}; + CAmount m_funding_amount{0}; +}; + +//! Page 3: live progress bound to IdentityFlow. +class UsernameProgressPage : public QWizardPage +{ + Q_OBJECT + +public: + UsernameProgressPage(PlatformService& service, QWidget* parent = nullptr); + void initializePage() override; + bool isComplete() const override; + +private Q_SLOTS: + void refresh(); + +private: + PlatformService& m_service; + QPlainTextEdit* m_log{nullptr}; + QLabel* m_headline{nullptr}; + QString m_last_headline; + bool m_done{false}; +}; + +#endif // BITCOIN_QT_PLATFORM_CREATEUSERNAMEWIZARD_H diff --git a/src/qt/platform/identityflow.cpp b/src/qt/platform/identityflow.cpp new file mode 100644 index 000000000000..12e582a0da76 --- /dev/null +++ b/src/qt/platform/identityflow.cpp @@ -0,0 +1,562 @@ +// 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